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,264 @@
import { useState } from 'react'
import { Tecnico } from '../stores/tecnicosStore'
import toast from 'react-hot-toast'
interface ChangePasswordModalProps {
tecnico: Tecnico | null
isOpen: boolean
onClose: () => void
}
const ChangePasswordModal = ({ tecnico, isOpen, onClose }: ChangePasswordModalProps) => {
const [formData, setFormData] = useState({
newPassword: '',
confirmPassword: '',
forceChange: false
})
const [isLoading, setIsLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!tecnico) return
// Validaciones
if (!formData.newPassword) {
toast.error('La nueva contraseña es obligatoria')
return
}
if (!formData.confirmPassword) {
toast.error('La confirmación de contraseña es obligatoria')
return
}
if (formData.newPassword !== formData.confirmPassword) {
toast.error('Las contraseñas no coinciden')
return
}
if (!validatePasswordStrength(formData.newPassword)) {
toast.error('La contraseña debe tener al menos 8 caracteres, incluir mayúsculas, minúsculas, números y caracteres especiales')
return
}
try {
setIsLoading(true)
const response = await fetch(`http://localhost:3001/api/tecnicos/${tecnico.id}?action=change_password`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
new_password: formData.newPassword,
force_change: formData.forceChange
})
})
const data = await response.json()
if (data.success) {
toast.success('Contraseña cambiada exitosamente')
handleClose()
} else {
toast.error(data.message || 'Error al cambiar contraseña')
}
} catch (error) {
console.error('Error cambiando contraseña:', error)
toast.error('Error de conexión')
} finally {
setIsLoading(false)
}
}
const handleClose = () => {
setFormData({
newPassword: '',
confirmPassword: '',
forceChange: false
})
setShowPassword(false)
setShowConfirmPassword(false)
onClose()
}
const validatePasswordStrength = (password: string): boolean => {
const minLength = password.length >= 8
const hasUpperCase = /[A-Z]/.test(password)
const hasLowerCase = /[a-z]/.test(password)
const hasNumbers = /\d/.test(password)
const hasNonalphas = /[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]/.test(password)
return minLength && hasUpperCase && hasLowerCase && hasNumbers && hasNonalphas
}
const getPasswordStrength = (password: string): { level: number; text: string; color: string } => {
if (password.length === 0) return { level: 0, text: '', color: '' }
let score = 0
if (password.length >= 8) score++
if (/[A-Z]/.test(password)) score++
if (/[a-z]/.test(password)) score++
if (/\d/.test(password)) score++
if (/[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]/.test(password)) score++
if (score <= 2) return { level: 1, text: 'Débil', color: 'text-danger' }
if (score <= 3) return { level: 2, text: 'Regular', color: 'text-warning' }
if (score <= 4) return { level: 3, text: 'Buena', color: 'text-info' }
return { level: 4, text: 'Fuerte', color: 'text-success' }
}
if (!isOpen || !tecnico) return null
const passwordStrength = getPasswordStrength(formData.newPassword)
return (
<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-key me-2"></i>
Cambiar Contraseña
</h5>
<button type="button" className="btn-close" onClick={handleClose}></button>
</div>
<div className="modal-body">
<div className="alert alert-info">
<i className="fas fa-info-circle me-2"></i>
Cambiando contraseña para: <strong>{tecnico.first_name} {tecnico.last_name}</strong>
<br />
<small className="text-muted">ID DTIC: {tecnico.dtic_id}</small>
</div>
<form onSubmit={handleSubmit}>
<div className="mb-3">
<label htmlFor="newPassword" className="form-label">
Nueva Contraseña <span className="text-danger">*</span>
</label>
<div className="input-group">
<input
type={showPassword ? 'text' : 'password'}
className="form-control"
id="newPassword"
value={formData.newPassword}
onChange={(e) => setFormData({ ...formData, newPassword: e.target.value })}
placeholder="Ingrese la nueva contraseña"
required
/>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setShowPassword(!showPassword)}
>
<i className={`fas fa-${showPassword ? 'eye-slash' : 'eye'}`}></i>
</button>
</div>
{formData.newPassword && (
<div className="mt-2">
<small className={`form-text ${passwordStrength.color}`}>
Fortaleza: {passwordStrength.text}
</small>
<div className="progress mt-1" style={{ height: '4px' }}>
<div
className={`progress-bar ${passwordStrength.level === 1 ? 'bg-danger' :
passwordStrength.level === 2 ? 'bg-warning' :
passwordStrength.level === 3 ? 'bg-info' : 'bg-success'}`}
style={{ width: `${(passwordStrength.level / 4) * 100}%` }}
></div>
</div>
</div>
)}
<div className="form-text">
La contraseña debe tener al menos 8 caracteres, incluir mayúsculas, minúsculas, números y caracteres especiales.
</div>
</div>
<div className="mb-3">
<label htmlFor="confirmPassword" className="form-label">
Confirmar Contraseña <span className="text-danger">*</span>
</label>
<div className="input-group">
<input
type={showConfirmPassword ? 'text' : 'password'}
className="form-control"
id="confirmPassword"
value={formData.confirmPassword}
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
placeholder="Confirme la nueva contraseña"
required
/>
<button
className="btn btn-outline-secondary"
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
<i className={`fas fa-${showConfirmPassword ? 'eye-slash' : 'eye'}`}></i>
</button>
</div>
{formData.confirmPassword && formData.newPassword !== formData.confirmPassword && (
<div className="mt-1">
<small className="form-text text-danger">
Las contraseñas no coinciden
</small>
</div>
)}
</div>
<div className="mb-3">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="forceChange"
checked={formData.forceChange}
onChange={(e) => setFormData({ ...formData, forceChange: e.target.checked })}
/>
<label className="form-check-label" htmlFor="forceChange">
Forzar cambio de contraseña en el próximo inicio de sesión
</label>
</div>
<div className="form-text">
Si se marca, el técnico deberá cambiar su contraseña la próxima vez que inicie sesión.
</div>
</div>
</form>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={handleClose}
disabled={isLoading}
>
Cancelar
</button>
<button
type="button"
className="btn btn-warning"
onClick={handleSubmit}
disabled={isLoading || !formData.newPassword || !formData.confirmPassword || formData.newPassword !== formData.confirmPassword}
>
{isLoading ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status"></span>
Cambiando...
</>
) : (
<>
<i className="fas fa-save me-2"></i>
Cambiar Contraseña
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}
export default ChangePasswordModal
@@ -0,0 +1,299 @@
import { useEffect, useState } from 'react'
import { Recurso } from '../stores/recursosStore'
import { recursoUtils } from '../utils/entityUtils'
import { ResourceAssignmentControl } from './common/ResourceAssignmentControl'
import { useResourceAssignment } from '../hooks/useResourceAssignment'
interface RecursoProfileModalProps {
recurso: Recurso | null
isOpen: boolean
onClose: () => void
onEdit: (id: number) => void
}
interface RecursoStats {
total_assignments: number
active_assignments: number
assignment_history: any[]
maintenance_history: any[]
}
const RecursoProfileModal = ({ recurso, isOpen, onClose, onEdit }: RecursoProfileModalProps) => {
const [stats, setStats] = useState<RecursoStats | null>(null)
const [loading, setLoading] = useState(false)
// Initialize resource assignment hook for this recurso
const resourceAssignment = useResourceAssignment('recurso', recurso.id)
// Load assigned resources when modal opens
useEffect(() => {
if (isOpen && recurso) {
resourceAssignment.refreshAssignments()
}
}, [isOpen, recurso?.id])
useEffect(() => {
if (recurso && isOpen) {
fetchRecursoStats()
}
}, [recurso, isOpen])
const fetchRecursoStats = async () => {
if (!recurso) return
setLoading(true)
try {
const response = await fetch(`/api/recursos/${recurso.id}?include_history=true`)
const data = await response.json()
if (data.success) {
setStats({
total_assignments: data.data.resource.total_assignments || 0,
active_assignments: data.data.resource.active_assignments || 0,
assignment_history: data.data.resource.assignment_history || [],
maintenance_history: data.data.resource.maintenance_history || []
})
}
} catch (error) {
console.error('Error fetching recurso stats:', error)
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
if (!isOpen || !recurso) return null
return (
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header bg-primary text-white">
<h5 className="modal-title">
<i className={`fas ${recursoUtils.getIcon(recurso.category)} me-2`}></i>Perfil de {recurso.name}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<div className="row">
{/* Información básica y avatar */}
<div className="col-md-4 text-center mb-4">
<div className="avatar-circle bg-primary text-white mx-auto mb-3"
style={{ width: '80px', height: '80px', fontSize: '2rem', border: '4px solid #dee2e6' }}>
<i className={`fas ${recursoUtils.getIcon(recurso.category)}`}></i>
</div>
<h5 className="mb-1">{recurso.name}</h5>
<span className={`badge fs-6 mb-2 ${recurso.status === 'available' ? 'bg-success' : recurso.status === 'assigned' ? 'bg-info' : recurso.status === 'maintenance' ? 'bg-warning' : 'bg-secondary'}`}>
{recursoUtils.getBadge(recurso.status).text}
</span>
<div>
<span className={`badge fs-6 ${recurso.category === 'hardware' ? 'bg-primary' : recurso.category === 'software' ? 'bg-info' : recurso.category === 'network' ? 'bg-success' : recurso.category === 'security' ? 'bg-danger' : 'bg-secondary'}`}>
{recursoUtils.formatCategory(recurso.category)}
</span>
</div>
</div>
{/* Información detallada */}
<div className="col-md-8">
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">ID DTIC</strong>
<span className="badge bg-light text-dark fs-6">{recurso.dtic_id}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Categoría</strong>
<span>{recursoUtils.formatCategory(recurso.category)}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Estado</strong>
<span className={`badge ${recurso.status === 'available' ? 'bg-success' : recurso.status === 'assigned' ? 'bg-info' : recurso.status === 'maintenance' ? 'bg-warning' : 'bg-secondary'}`}>
{recursoUtils.getBadge(recurso.status).text}
</span>
</div>
</div>
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Ubicación</strong>
<span>{recurso.location || 'No especificada'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Modelo</strong>
<span>{recurso.model || 'No especificado'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Número de Serie</strong>
<span>{recurso.serial_number || 'No especificado'}</span>
</div>
</div>
</div>
{recurso.description && (
<>
<hr />
<div className="mb-3">
<strong className="text-muted d-block">Descripción</strong>
<p className="mb-0">{recurso.description}</p>
</div>
</>
)}
{recurso.technical_specs && (
<>
<hr />
<div className="mb-3">
<strong className="text-muted d-block">Especificaciones Técnicas</strong>
<pre className="bg-light p-2 rounded small">{JSON.stringify(recurso.technical_specs, null, 2)}</pre>
</div>
</>
)}
<hr />
{/* Estadísticas de asignaciones */}
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-chart-bar me-2"></i>Estadísticas de Relaciones
</h6>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : stats ? (
<div className="row text-center mb-4">
<div className="col-sm-6">
<div className="p-3 bg-light rounded">
<div className="h4 text-primary mb-1">{stats.total_assignments}</div>
<small className="text-muted fw-bold">Total Relaciones</small>
</div>
</div>
<div className="col-sm-6">
<div className="p-3 bg-info rounded text-white">
<div className="h4 mb-1">{stats.active_assignments}</div>
<small className="fw-bold">Relaciones Activas</small>
</div>
</div>
</div>
) : null}
{/* Historial de asignaciones */}
{stats && stats.assignment_history && stats.assignment_history.length > 0 && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-history me-2"></i>Historial de Relaciones
</h6>
<div className="list-group mb-4">
{stats.assignment_history.slice(0, 5).map((assignment: any, index: number) => (
<div key={index} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{assignment.usuario_name || 'Usuario desconocido'}</h6>
<small className={`badge ${assignment.action === 'assigned' ? 'bg-success' : 'bg-warning'}`}>
{assignment.action === 'assigned' ? 'Relacionado' : 'Desrelacionado'}
</small>
</div>
<p className="mb-1">ID DTIC: {assignment.usuario_dtic_id}</p>
<small className="text-muted">
{assignment.action === 'assigned' ? 'Relacionado' : 'Desrelacionado'}: {formatDate(assignment.created_at)}
{assignment.tecnico_name && ` • Por: ${assignment.tecnico_name}`}
</small>
</div>
))}
</div>
</>
)}
{/* Historial de mantenimiento */}
{stats && stats.maintenance_history && stats.maintenance_history.length > 0 && (
<>
<h6 className="fw-bold text-warning mb-3">
<i className="fas fa-tools me-2"></i>Historial de Mantenimiento
</h6>
<div className="list-group">
{stats.maintenance_history.slice(0, 3).map((maintenance: any, index: number) => (
<div key={index} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{maintenance.action || 'Mantenimiento'}</h6>
<small className="badge bg-warning text-dark">
{formatDate(maintenance.created_at)}
</small>
</div>
{maintenance.details && <p className="mb-1">{maintenance.details}</p>}
<small className="text-muted">
{maintenance.tecnico_name && `Técnico: ${maintenance.tecnico_name}`}
</small>
</div>
))}
</div>
</>
)}
{stats && (!stats.assignment_history || stats.assignment_history.length === 0) &&
(!stats.maintenance_history || stats.maintenance_history.length === 0) && (
<div className="text-center py-4 text-muted">
<i className="fas fa-inbox fa-2x mb-2"></i>
<p>No hay historial disponible</p>
</div>
)}
{/* Resource Assignment Control - Reverse Assignment View */}
<hr className="my-4" />
<h6 className="fw-bold text-info mb-3">
<i className="fas fa-link me-2"></i>Entidades que Usan Este Recurso
</h6>
<div className="bg-light p-3 rounded">
<p className="text-muted mb-0">
<i className="fas fa-info-circle me-2"></i>
Este recurso actualmente está relacionado con {stats?.active_assignments || 0} entidad{stats?.active_assignments !== 1 ? 'es' : ''}.
</p>
{stats && stats.active_assignments > 0 && (
<small className="text-muted">
Las asignaciones se pueden gestionar desde los perfiles de tareas y usuarios.
</small>
)}
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
<i className="fas fa-times me-2"></i>Cerrar
</button>
<button
type="button"
className="btn btn-warning"
onClick={() => {
onClose()
onEdit(recurso.id)
}}
>
<i className="fas fa-edit me-2"></i>Editar Recurso
</button>
</div>
</div>
</div>
</div>
)
}
export default RecursoProfileModal
@@ -0,0 +1,174 @@
import React, { useEffect } from 'react'
import { Tarea } from '../stores/tareasStore'
import { tareaUtils } from '../utils/entityUtils'
import { ResourceAssignmentControl } from './common/ResourceAssignmentControl'
import { useResourceAssignment } from '../hooks/useResourceAssignment'
interface TareaProfileModalProps {
tarea: Tarea | null
isOpen: boolean
onClose: () => void
onEdit: (id: number) => void
onRefresh?: () => void
}
const TareaProfileModal: React.FC<TareaProfileModalProps> = ({
tarea,
isOpen,
onClose,
onEdit,
onRefresh
}) => {
if (!isOpen || !tarea) return null
// Initialize resource assignment hook for this tarea
const resourceAssignment = useResourceAssignment('tarea', tarea.id)
// Load assigned resources when modal opens
useEffect(() => {
if (isOpen && tarea) {
resourceAssignment.refreshAssignments()
}
}, [isOpen, tarea?.id])
return (
<div className="modal show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-xl">
<div className="modal-content">
<div className="modal-header bg-primary text-white">
<h5 className="modal-title">
<i className="fas fa-tasks me-2"></i>
Detalles de Tarea
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<div className="row">
<div className="col-md-8">
<h4 className="mb-3">{tarea.title}</h4>
{tarea.description && (
<p className="text-muted mb-4">{tarea.description}</p>
)}
</div>
<div className="col-md-4 text-end">
<span className={`badge fs-6 ${tareaUtils.getBadge(tarea.status).class}`}>
{tareaUtils.getBadge(tarea.status).text}
</span>
</div>
</div>
<div className="row g-3">
<div className="col-md-4">
<div className="card h-100">
<div className="card-body">
<h6 className="card-title">
<i className="fas fa-info-circle me-2 text-primary"></i>
Información General
</h6>
<hr />
<p className="mb-2"><strong>ID DTIC:</strong> {tarea.dtic_id}</p>
<p className="mb-2">
<strong>Prioridad:</strong>
<span className={`badge ms-2 ${tareaUtils.getBadge(tarea.priority).class}`}>
{tareaUtils.formatPriority(tarea.priority)}
</span>
</p>
<p className="mb-2"><strong>Estado:</strong> {tareaUtils.getBadge(tarea.status).text}</p>
<p className="mb-2"><strong>Creada:</strong> {tareaUtils.formatDate(tarea.created_at)}</p>
<p className="mb-2"><strong>Actualizada:</strong> {tareaUtils.formatDate(tarea.updated_at)}</p>
{tarea.due_date && (
<p className="mb-2"><strong>Fecha límite:</strong> {tareaUtils.formatDate(tarea.due_date)}</p>
)}
{tarea.completed_at && (
<p className="mb-0"><strong>Completada:</strong> {tareaUtils.formatDate(tarea.completed_at)}</p>
)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100">
<div className="card-body">
<h6 className="card-title">
<i className="fas fa-user me-2 text-success"></i>
Asignación
</h6>
<hr />
{tarea.technician_name ? (
<>
<p className="mb-2"><strong>Técnico relacionado:</strong></p>
<div className="d-flex align-items-center">
<div className="avatar-circle bg-primary text-white me-3" style={{ width: '40px', height: '40px' }}>
<i className="fas fa-user"></i>
</div>
<div>
<h6 className="mb-0">{tarea.technician_name}</h6>
<small className="text-muted">ID: {tarea.technician_id}</small>
</div>
</div>
</>
) : (
<div className="text-center py-3">
<i className="fas fa-user-slash fa-2x text-muted mb-2"></i>
<p className="text-muted mb-0">Sin técnico relacionado</p>
</div>
)}
</div>
</div>
</div>
<div className="col-md-4">
<div className="card h-100">
<div className="card-body">
<h6 className="card-title">
<i className="fas fa-boxes me-2 text-info"></i>
Recursos Relacionados
</h6>
<hr />
<ResourceAssignmentControl
entityId={tarea.id}
entityType="tarea"
assignedResources={resourceAssignment.assignedResources}
availableResources={resourceAssignment.availableResources}
onAssignResource={resourceAssignment.assignResource}
onUnassignResource={resourceAssignment.unassignResource}
loading={resourceAssignment.loading}
className="border-0 p-0"
showAddIcon={true}
maxHeight="300px"
/>
</div>
</div>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
<i className="fas fa-times me-2"></i>Cerrar
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => {
onEdit(tarea.id)
onClose()
}}
>
<i className="fas fa-edit me-2"></i>Editar Tarea
</button>
</div>
</div>
</div>
</div>
)
}
export default TareaProfileModal
@@ -0,0 +1,288 @@
import { useEffect, useState } from 'react'
import { Tecnico } from '../stores/tecnicosStore'
import { ResourceAssignmentControl } from './common/ResourceAssignmentControl'
import { useResourceAssignment } from '../hooks/useResourceAssignment'
interface TecnicoProfileModalProps {
tecnico: Tecnico | null
isOpen: boolean
onClose: () => void
onEdit: (id: number) => void
}
interface TecnicoStats {
total_tasks: number
completed_tasks: number
active_tasks: number
recent_tasks: any[]
}
const TecnicoProfileModal = ({ tecnico, isOpen, onClose, onEdit }: TecnicoProfileModalProps) => {
const [stats, setStats] = useState<TecnicoStats | null>(null)
const [loading, setLoading] = useState(false)
// Initialize resource assignment hook for this tecnico
const resourceAssignment = useResourceAssignment('tecnico', tecnico.id)
// Load assigned resources when modal opens
useEffect(() => {
if (isOpen && tecnico) {
resourceAssignment.refreshAssignments()
}
}, [isOpen, tecnico?.id])
useEffect(() => {
if (tecnico && isOpen) {
fetchTecnicoStats()
}
}, [tecnico, isOpen])
const fetchTecnicoStats = async () => {
if (!tecnico) return
setLoading(true)
try {
const response = await fetch(`http://localhost:3001/api/tecnicos/${tecnico.id}?include_tasks=true`)
const data = await response.json()
if (data.success) {
setStats({
total_tasks: data.data.technician.total_tasks || 0,
completed_tasks: data.data.technician.completed_tasks || 0,
active_tasks: data.data.technician.active_tasks || 0,
recent_tasks: data.data.technician.recent_tasks || []
})
}
} catch (error) {
console.error('Error fetching tecnico stats:', error)
} finally {
setLoading(false)
}
}
const formatRole = (role: string) => {
const roles = {
'admin': 'Administrador',
'technician': 'Técnico',
'viewer': 'Visualizador'
}
return roles[role as keyof typeof roles] || role
}
const formatDepartment = (dept: string) => {
const departments = {
'dtic': 'DTIC',
'sistemas': 'Sistemas',
'redes': 'Redes',
'seguridad': 'Seguridad'
}
return departments[dept as keyof typeof departments] || dept
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const formatTaskStatus = (status: string) => {
const statuses = {
'pending': 'Pendiente',
'in_progress': 'En Progreso',
'completed': 'Completada',
'cancelled': 'Cancelada'
}
return statuses[status as keyof typeof statuses] || status
}
if (!isOpen || !tecnico) return null
return (
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header bg-primary text-white">
<h5 className="modal-title">
<i className="fas fa-user me-2"></i>Perfil de {tecnico.first_name} {tecnico.last_name}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<div className="row">
{/* Información básica y avatar */}
<div className="col-md-4 text-center mb-4">
<div className="avatar-circle bg-primary text-white mx-auto mb-3"
style={{ width: '80px', height: '80px', fontSize: '2rem', border: '4px solid #dee2e6' }}>
<i className="fas fa-user"></i>
</div>
<h5 className="mb-1">{tecnico.full_name}</h5>
<span className={`badge fs-6 mb-2 ${tecnico.role === 'admin' ? 'bg-danger' : tecnico.role === 'technician' ? 'bg-info' : 'bg-secondary'}`}>
{formatRole(tecnico.role)}
</span>
<div>
<span className={`badge ${tecnico.is_active ? 'bg-success' : 'bg-warning'} fs-6`}>
{tecnico.is_active ? 'Activo' : 'Inactivo'}
</span>
</div>
</div>
{/* Información detallada */}
<div className="col-md-8">
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">ID DTIC</strong>
<span className="badge bg-light text-dark fs-6">{tecnico.dtic_id}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Email</strong>
<span>{tecnico.email}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Teléfono</strong>
<span>{tecnico.phone || 'No especificado'}</span>
</div>
</div>
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Departamento</strong>
<span>{formatDepartment(tecnico.department)}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Registro</strong>
<span>{formatDate(tecnico.created_at)}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Última actualización</strong>
<span>{formatDate(tecnico.updated_at)}</span>
</div>
</div>
</div>
<hr />
{/* Estadísticas de tareas */}
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-chart-bar me-2"></i>Estadísticas de Tareas
</h6>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : stats ? (
<div className="row text-center mb-4">
<div className="col-sm-4">
<div className="p-3 bg-light rounded">
<div className="h4 text-primary mb-1">{stats.total_tasks}</div>
<small className="text-muted fw-bold">Total Tareas</small>
</div>
</div>
<div className="col-sm-4">
<div className="p-3 bg-success rounded text-white">
<div className="h4 mb-1">{stats.completed_tasks}</div>
<small className="fw-bold">Completadas</small>
</div>
</div>
<div className="col-sm-4">
<div className="p-3 bg-warning rounded">
<div className="h4 text-dark mb-1">{stats.active_tasks}</div>
<small className="text-dark fw-bold">Activas</small>
</div>
</div>
</div>
) : null}
{/* Tareas recientes */}
{stats && stats.recent_tasks && stats.recent_tasks.length > 0 && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-clock me-2"></i>Tareas Recientes
</h6>
<div className="list-group">
{stats.recent_tasks.slice(0, 3).map((task: any, index: number) => (
<div key={index} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{task.title}</h6>
<small className={`badge ${
task.status === 'completed' ? 'bg-success' :
task.status === 'in_progress' ? 'bg-warning text-dark' :
'bg-secondary'
}`}>
{formatTaskStatus(task.status)}
</small>
</div>
<p className="mb-1">{task.description || 'Sin descripción'}</p>
<small className="text-muted">
Creada: {formatDate(task.created_at)}
{task.due_date && ` • Vence: ${formatDate(task.due_date)}`}
</small>
</div>
))}
</div>
</>
)}
{stats && (!stats.recent_tasks || stats.recent_tasks.length === 0) && (
<div className="text-center py-4 text-muted">
<i className="fas fa-inbox fa-2x mb-2"></i>
<p>No hay tareas recientes</p>
</div>
)}
{/* Resource Assignment Control */}
<hr className="my-4" />
<h6 className="fw-bold text-info mb-3">
<i className="fas fa-boxes me-2"></i>Gestión de Recursos Asignados
</h6>
<ResourceAssignmentControl
entityId={tecnico.id}
entityType="tecnico"
assignedResources={resourceAssignment.assignedResources}
availableResources={resourceAssignment.availableResources}
onAssignResource={resourceAssignment.assignResource}
onUnassignResource={resourceAssignment.unassignResource}
loading={resourceAssignment.loading}
className="border-0 p-0"
showAddIcon={true}
maxHeight="250px"
/>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
<i className="fas fa-times me-2"></i>Cerrar
</button>
<button
type="button"
className="btn btn-warning"
onClick={() => {
onClose()
onEdit(tecnico.id)
}}
>
<i className="fas fa-edit me-2"></i>Editar Técnico
</button>
</div>
</div>
</div>
</div>
)
}
export default TecnicoProfileModal
@@ -0,0 +1,310 @@
import { useEffect, useState } from 'react'
import { ResourceAssignmentControl } from './common/ResourceAssignmentControl'
import { useResourceAssignment } from '../hooks/useResourceAssignment'
interface UsuarioRelacionado {
id: number
dtic_id: string
first_name: string
last_name: string
full_name?: string
email?: string
phone?: string
department?: string
position?: string
created_at: string
updated_at?: string
assigned_resources_count?: number
}
interface UsuarioProfileModalProps {
usuario: UsuarioRelacionado | null
isOpen: boolean
onClose: () => void
onEdit: (id: number) => void
}
interface UsuarioStats {
total_assignments: number
active_assignments: number
assignment_history: any[]
resource_categories: { [key: string]: number }
}
const UsuarioProfileModal = ({ usuario, isOpen, onClose, onEdit }: UsuarioProfileModalProps) => {
const [stats, setStats] = useState<UsuarioStats | null>(null)
const [loading, setLoading] = useState(false)
// Initialize resource assignment hook for this usuario
const resourceAssignment = useResourceAssignment('usuario', usuario.id)
// Load assigned resources when modal opens
useEffect(() => {
if (isOpen && usuario) {
resourceAssignment.refreshAssignments()
}
}, [isOpen, usuario?.id])
useEffect(() => {
if (usuario && isOpen) {
fetchUsuarioStats()
}
}, [usuario, isOpen])
const fetchUsuarioStats = async () => {
if (!usuario) return
setLoading(true)
try {
const response = await fetch(`/api/usuarios_relacionados/${usuario.id}?include_history=true`)
const data = await response.json()
if (data.success) {
setStats({
total_assignments: data.data.usuario.total_assignments || 0,
active_assignments: data.data.usuario.active_assignments || 0,
assignment_history: data.data.usuario.assignment_history || [],
resource_categories: data.data.usuario.resource_categories || {}
})
}
} catch (error) {
console.error('Error fetching usuario stats:', error)
} finally {
setLoading(false)
}
}
const formatDepartment = (dept: string) => {
const departments = {
'dtic': 'DTIC',
'sistemas': 'Sistemas',
'redes': 'Redes',
'seguridad': 'Seguridad'
}
return departments[dept as keyof typeof departments] || dept
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const formatCategory = (category: string) => {
const categories = {
'hardware': 'Hardware',
'software': 'Software',
'network': 'Redes',
'security': 'Seguridad',
'tools': 'Herramientas',
'facilities': 'Instalaciones'
}
return categories[category as keyof typeof categories] || category
}
if (!isOpen || !usuario) return null
return (
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header bg-primary text-white">
<h5 className="modal-title">
<i className="fas fa-user me-2"></i>Perfil de {usuario.full_name || `${usuario.first_name} ${usuario.last_name}`}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<div className="row">
{/* Información básica y avatar */}
<div className="col-md-4 text-center mb-4">
<div className="avatar-circle bg-primary text-white mx-auto mb-3"
style={{ width: '80px', height: '80px', fontSize: '2rem', border: '4px solid #dee2e6' }}>
<i className="fas fa-user"></i>
</div>
<h5 className="mb-1">{usuario.full_name || `${usuario.first_name} ${usuario.last_name}`}</h5>
{usuario.position && (
<span className="badge fs-6 mb-2 bg-info">
{usuario.position}
</span>
)}
<div>
<span className="badge fs-6 bg-secondary">
Usuario Relacionado
</span>
</div>
</div>
{/* Información detallada */}
<div className="col-md-8">
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">ID DTIC</strong>
<span className="badge bg-light text-dark fs-6">{usuario.dtic_id}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Email</strong>
<span>{usuario.email || 'No especificado'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Teléfono</strong>
<span>{usuario.phone || 'No especificado'}</span>
</div>
</div>
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Departamento</strong>
<span>{usuario.department ? formatDepartment(usuario.department) : 'No especificado'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Cargo</strong>
<span>{usuario.position || 'No especificado'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Registro</strong>
<span>{formatDate(usuario.created_at)}</span>
</div>
</div>
</div>
<hr />
{/* Estadísticas de recursos asignados */}
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-chart-bar me-2"></i>Estadísticas de Recursos Relacionados
</h6>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : stats ? (
<div className="row text-center mb-4">
<div className="col-sm-6">
<div className="p-3 bg-light rounded">
<div className="h4 text-primary mb-1">{stats.total_assignments}</div>
<small className="text-muted fw-bold">Total Relaciones</small>
</div>
</div>
<div className="col-sm-6">
<div className="p-3 bg-success rounded text-white">
<div className="h4 mb-1">{stats.active_assignments}</div>
<small className="fw-bold">Relaciones Activas</small>
</div>
</div>
</div>
) : null}
{/* Categorías de recursos */}
{stats && stats.resource_categories && Object.keys(stats.resource_categories).length > 0 && (
<>
<h6 className="fw-bold text-info mb-3">
<i className="fas fa-tags me-2"></i>Categorías de Recursos Relacionados
</h6>
<div className="row mb-4">
{Object.entries(stats.resource_categories).map(([category, count]) => (
<div key={category} className="col-sm-6 col-md-4 mb-2">
<div className="p-2 bg-light rounded text-center">
<div className="fw-bold text-primary">{count}</div>
<small className="text-muted">{formatCategory(category)}</small>
</div>
</div>
))}
</div>
</>
)}
{/* Historial de asignaciones */}
{stats && stats.assignment_history && stats.assignment_history.length > 0 && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-history me-2"></i>Historial de Relaciones
</h6>
<div className="list-group">
{stats.assignment_history.slice(0, 5).map((assignment: any, index: number) => (
<div key={index} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{assignment.recurso_name || 'Recurso desconocido'}</h6>
<small className={`badge ${assignment.action === 'assigned' ? 'bg-success' : 'bg-warning'}`}>
{assignment.action === 'assigned' ? 'Relacionado' : 'Desrelacionado'}
</small>
</div>
<p className="mb-1">
ID DTIC: {assignment.recurso_dtic_id} Categoría: {formatCategory(assignment.recurso_category)}
</p>
<small className="text-muted">
{assignment.action === 'assigned' ? 'Relacionado' : 'Desrelacionado'}: {formatDate(assignment.created_at)}
{assignment.tecnico_name && ` • Por: ${assignment.tecnico_name}`}
</small>
</div>
))}
</div>
</>
)}
{stats && (!stats.assignment_history || stats.assignment_history.length === 0) && (
<div className="text-center py-4 text-muted">
<i className="fas fa-inbox fa-2x mb-2"></i>
<p>No hay historial de relaciones</p>
</div>
)}
{/* Resource Assignment Control */}
<hr className="my-4" />
<h6 className="fw-bold text-info mb-3">
<i className="fas fa-boxes me-2"></i>Gestión de Recursos Relacionados
</h6>
<ResourceAssignmentControl
entityId={usuario.id}
entityType="usuario"
assignedResources={resourceAssignment.assignedResources}
availableResources={resourceAssignment.availableResources}
onAssignResource={resourceAssignment.assignResource}
onUnassignResource={resourceAssignment.unassignResource}
loading={resourceAssignment.loading}
className="border-0 p-0"
showAddIcon={true}
maxHeight="250px"
/>
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
<i className="fas fa-times me-2"></i>Cerrar
</button>
<button
type="button"
className="btn btn-warning"
onClick={() => {
onClose()
onEdit(usuario.id)
}}
>
<i className="fas fa-edit me-2"></i>Editar Usuario
</button>
</div>
</div>
</div>
</div>
)
}
export default UsuarioProfileModal
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../stores/authStore'
interface PrivateRouteProps {
children: React.ReactNode
}
const PrivateRoute = ({ children }: PrivateRouteProps) => {
const { isAuthenticated, checkAuth } = useAuthStore()
const location = useLocation()
useEffect(() => {
if (!isAuthenticated) {
checkAuth()
}
}, [isAuthenticated, checkAuth])
if (!isAuthenticated) {
// Redirect to login page with return url
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}
export default PrivateRoute
@@ -0,0 +1,297 @@
import React, { useState, useEffect } from 'react'
import { ResourceAssignmentControl } from './ResourceAssignmentControl'
import { useResourceAssignment } from '../../hooks/useResourceAssignment'
export interface FormField {
name: string
label: string
type: 'text' | 'email' | 'select' | 'textarea' | 'tel' | 'date' | 'multiselect' | 'resource_assignment'
required?: boolean
options?: Array<{ value: string, label: string }>
placeholder?: string
rows?: number
dynamicOptions?: {
endpoint: string
labelField: string
valueField: string
params?: Record<string, any>
}
resourceAssignmentConfig?: {
entityType: 'tarea' | 'usuario' | 'tecnico' | 'recurso'
entityId: number
}
}
interface EntityFormProps<T> {
onSubmit: (data: Partial<T>) => void
onCancel: () => void
initialData?: Partial<T>
fields: FormField[]
submitText?: string
title?: string
}
export const EntityForm = <T extends Record<string, any>>({
onSubmit,
onCancel,
initialData = {},
fields,
submitText = 'Guardar',
title
}: EntityFormProps<T>) => {
const [formData, setFormData] = useState<Partial<T>>(initialData)
const [dynamicOptions, setDynamicOptions] = useState<Record<string, Array<{ value: string, label: string }>>>({})
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({})
// Load dynamic options for select fields with caching
useEffect(() => {
const loadDynamicOptions = async () => {
const newOptions: Record<string, Array<{ value: string, label: string }>> = {}
const newLoading: Record<string, boolean> = {}
// Process fields in parallel for better performance
const promises = fields.map(async (field) => {
if (field.dynamicOptions) {
newLoading[field.name] = true
try {
const { endpoint, labelField, valueField, params } = field.dynamicOptions
const queryParams = new URLSearchParams(params || {})
const url = `${endpoint}?${queryParams}`
const response = await fetch(url)
if (response.ok) {
const data = await response.json()
if (data.success && data.data) {
const entities = Array.isArray(data.data) ? data.data :
data.data.tecnicos || data.data.recursos || data.data.usuarios || data.data.tareas || []
const options = entities.map((entity: any) => ({
value: entity[valueField].toString(),
label: entity[labelField]
}))
newOptions[field.name] = options
}
}
} catch (error) {
console.error(`Error loading dynamic options for ${field.name}:`, error)
} finally {
newLoading[field.name] = false
}
}
})
await Promise.all(promises)
setDynamicOptions(newOptions)
setLoadingOptions(newLoading)
}
loadDynamicOptions()
}, [fields])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Basic validation
const errors: string[] = []
fields.forEach(field => {
if (field.required && !formData[field.name]) {
errors.push(`${field.label} es obligatorio`)
}
})
if (errors.length > 0) {
alert(`Errores de validación:\n${errors.join('\n')}`)
return
}
onSubmit(formData)
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({ ...prev, [name]: value }))
}
const renderField = (field: FormField) => {
const value = formData[field.name] || ''
switch (field.type) {
case 'textarea':
return (
<textarea
className="form-control"
id={field.name}
name={field.name}
value={value}
onChange={handleChange}
placeholder={field.placeholder}
rows={field.rows || 3}
required={field.required}
/>
)
case 'select':
const selectOptions = dynamicOptions[field.name] || field.options || []
const isLoading = loadingOptions[field.name]
return (
<select
className="form-select"
id={field.name}
name={field.name}
value={value}
onChange={handleChange}
required={field.required}
disabled={isLoading}
>
<option value="">
{isLoading ? 'Cargando...' : 'Seleccionar...'}
</option>
{selectOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
)
case 'multiselect':
const multiselectOptions = dynamicOptions[field.name] || field.options || []
const isLoadingMulti = loadingOptions[field.name]
const selectedValues = Array.isArray(value) ? value : []
return (
<div className="multiselect-container">
<select
className="form-select"
multiple
id={field.name}
name={field.name}
value={selectedValues}
onChange={(e) => {
const selectedOptions = Array.from(e.target.selectedOptions, option => option.value)
setFormData(prev => ({ ...prev, [field.name]: selectedOptions }))
}}
style={{ minHeight: '120px' }}
disabled={isLoadingMulti}
>
{isLoadingMulti && <option disabled>Cargando...</option>}
{multiselectOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{selectedValues.length > 0 && (
<small className="text-muted mt-1">
{selectedValues.length} recurso{selectedValues.length !== 1 ? 's' : ''} seleccionado{selectedValues.length !== 1 ? 's' : ''}
</small>
)}
</div>
)
case 'resource_assignment':
if (!field.resourceAssignmentConfig) {
return (
<div className="alert alert-warning">
<i className="fas fa-exclamation-triangle me-2"></i>
Configuración de asignación de recursos no encontrada
</div>
)
}
const { entityType } = field.resourceAssignmentConfig
let entityId = field.resourceAssignmentConfig.entityId
// Replace template variables in entityId
if (typeof entityId === 'string' && entityId.includes('{{id}}')) {
const actualId = initialData?.id
if (actualId) {
entityId = actualId.toString()
} else {
// For new entities without ID, don't show resource assignment
return (
<div className="alert alert-info">
<i className="fas fa-info-circle me-2"></i>
La asignación de recursos estará disponible después de guardar la entidad
</div>
)
}
}
const resourceAssignment = useResourceAssignment(entityType, parseInt(entityId.toString()))
return (
<ResourceAssignmentControl
entityId={entityId}
entityType={entityType}
assignedResources={resourceAssignment.assignedResources}
availableResources={resourceAssignment.availableResources}
onAssignResource={resourceAssignment.assignResource}
onUnassignResource={resourceAssignment.unassignResource}
loading={resourceAssignment.loading}
className="border rounded p-3"
showAddIcon={true}
maxHeight="400px"
/>
)
default:
return (
<input
type={field.type}
className="form-control"
id={field.name}
name={field.name}
value={value}
onChange={handleChange}
placeholder={field.placeholder}
required={field.required}
/>
)
}
}
return (
<form onSubmit={handleSubmit}>
{title && (
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-edit me-2"></i>
{title}
</h6>
)}
<div className="row g-3">
{fields.map(field => (
<div key={field.name} className="col-md-6">
<label className="form-label small fw-bold">
{field.label} {field.required && '*'}
</label>
{renderField(field)}
</div>
))}
</div>
<div className="d-flex justify-content-between align-items-center mt-3">
<small className="text-muted">
Los campos marcados con * son obligatorios
</small>
<div>
<button
type="button"
className="btn btn-outline-secondary btn-sm me-2"
onClick={onCancel}
>
<i className="fas fa-times me-1"></i>Cancelar
</button>
<button
type="submit"
className="btn btn-success btn-sm"
disabled={!fields.some(f => f.required) || !fields.filter(f => f.required).every(f => formData[f.name])}
>
<i className="fas fa-save me-1"></i>{submitText}
</button>
</div>
</div>
</form>
)
}
@@ -0,0 +1,66 @@
import React from 'react'
interface StatCard {
title: string
value: number
subtitle: string
color: string
}
interface EntityLayoutProps {
title: string
subtitle: string
icon: string
stats: StatCard[]
children: React.ReactNode
className?: string
}
export const EntityLayout = ({
title,
subtitle,
icon,
stats,
children,
className = ''
}: EntityLayoutProps) => {
return (
<div className={`container mt-4 scroll-smooth ${className}`}>
{/* Header */}
<div className="row mb-4">
<div className="col-12">
<div className="d-flex align-items-center mb-3">
<i className={`fas ${icon} fa-2x text-primary me-3`}></i>
<div>
<h1 className="h3 mb-0">{title}</h1>
<p className="text-muted mb-0">{subtitle}</p>
</div>
</div>
</div>
</div>
{/* Statistics Cards */}
<div className="row mb-4">
{stats.map((stat, index) => (
<div key={index} className="col-md-3 mb-3">
<div className="card h-100">
<div className="card-header">
<i className={`fas fa-chart-line text-${stat.color} me-2`}></i>
{stat.title}
</div>
<div className="card-body text-center">
<div className={`display-4 text-${stat.color} mb-2 fw-bold`}>
{stat.value}
</div>
<small className="text-muted">{stat.subtitle}</small>
</div>
</div>
</div>
))}
</div>
{/* Main Content */}
{children}
</div>
)
}
@@ -0,0 +1,376 @@
import { useEffect, useState } from 'react'
import { EntityConfig } from '../../stores/genericEntityStore'
interface ProfileModalProps {
entity: any
entityKey: string
config: EntityConfig
isOpen: boolean
onClose: () => void
onEdit: (id: number) => void
}
interface EntityStats {
total_tasks?: number
completed_tasks?: number
active_tasks?: number
recent_tasks?: any[]
assigned_resources_count?: number
}
const ProfileModal = ({ entity, entityKey, config, isOpen, onClose, onEdit }: ProfileModalProps) => {
const [stats, setStats] = useState<EntityStats | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
if (entity && isOpen) {
fetchEntityStats()
}
}, [entity, isOpen])
const fetchEntityStats = async () => {
if (!entity) return
setLoading(true)
try {
const response = await fetch(`http://localhost:3001/api/${entityKey}/${entity.id}?include_tasks=true`)
const data = await response.json()
if (data.success) {
setStats({
total_tasks: data.data.technician?.total_tasks || data.data.tarea?.total_tasks || 0,
completed_tasks: data.data.technician?.completed_tasks || data.data.tarea?.completed_tasks || 0,
active_tasks: data.data.technician?.active_tasks || data.data.tarea?.active_tasks || 0,
recent_tasks: data.data.technician?.recent_tasks || data.data.tarea?.recent_tasks || [],
assigned_resources_count: data.data.usuario?.assigned_resources_count || 0
})
}
} catch (error) {
console.error('Error fetching entity stats:', error)
} finally {
setLoading(false)
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('es-AR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const formatTaskStatus = (status: string) => {
const statuses = {
'pending': 'Pendiente',
'in_progress': 'En Progreso',
'completed': 'Completada',
'cancelled': 'Cancelada'
}
return statuses[status as keyof typeof statuses] || status
}
const getEntityIcon = () => {
const icons: { [key: string]: string } = {
'tecnicos': 'fa-user',
'recursos': 'fa-box',
'usuarios': 'fa-user-cog',
'tareas': 'fa-tasks'
}
return icons[entityKey] || 'fa-info-circle'
}
const getEntityTitle = () => {
const titles: { [key: string]: string } = {
'tecnicos': 'Perfil de Técnico',
'recursos': 'Detalles del Recurso',
'usuarios': 'Perfil de Usuario',
'tareas': 'Detalles de Tarea'
}
return titles[entityKey] || 'Detalles'
}
if (!isOpen || !entity) return null
return (
<div className="modal fade show d-block" tabIndex={-1} style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
<div className="modal-dialog modal-lg">
<div className="modal-content">
<div className="modal-header bg-primary text-white">
<h5 className="modal-title">
<i className={`fas ${getEntityIcon()} me-2`}></i>
{getEntityTitle()}: {entity.first_name ? `${entity.first_name} ${entity.last_name}` : entity.name || entity.title || 'Sin nombre'}
</h5>
<button
type="button"
className="btn-close btn-close-white"
onClick={onClose}
></button>
</div>
<div className="modal-body">
<div className="row">
{/* Información básica y avatar */}
<div className="col-md-4 text-center mb-4">
<div className="avatar-circle bg-primary text-white mx-auto mb-3"
style={{ width: '80px', height: '80px', fontSize: '2rem', border: '4px solid #dee2e6' }}>
<i className={`fas ${getEntityIcon()}`}></i>
</div>
<h5 className="mb-1">{entity.first_name ? `${entity.first_name} ${entity.last_name}` : entity.name || entity.title || 'Sin nombre'}</h5>
{entity.role && (
<span className={`badge fs-6 mb-2 ${entity.role === 'admin' ? 'bg-danger' : entity.role === 'technician' ? 'bg-info' : 'bg-secondary'}`}>
{entity.role === 'admin' ? 'Administrador' : entity.role === 'technician' ? 'Técnico' : entity.role === 'viewer' ? 'Visualizador' : entity.role}
</span>
)}
{entity.status && (
<div>
<span className={`badge ${entity.status === 'active' || entity.is_active ? 'bg-success' : 'bg-warning'} fs-6`}>
{entity.status === 'active' || entity.is_active ? 'Activo' : 'Inactivo'}
</span>
</div>
)}
</div>
{/* Información detallada */}
<div className="col-md-8">
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">ID DTIC</strong>
<span className="badge bg-light text-dark fs-6">{entity.dtic_id}</span>
</div>
{entity.email && (
<div className="mb-3">
<strong className="text-muted d-block">Email</strong>
<span>{entity.email}</span>
</div>
)}
{entity.phone && (
<div className="mb-3">
<strong className="text-muted d-block">Teléfono</strong>
<span>{entity.phone || 'No especificado'}</span>
</div>
)}
</div>
<div className="col-sm-6">
{entity.department && (
<div className="mb-3">
<strong className="text-muted d-block">Departamento</strong>
<span>{entity.department}</span>
</div>
)}
{entity.created_at && (
<div className="mb-3">
<strong className="text-muted d-block">Registro</strong>
<span>{formatDate(entity.created_at)}</span>
</div>
)}
{entity.updated_at && (
<div className="mb-3">
<strong className="text-muted d-block">Última actualización</strong>
<span>{formatDate(entity.updated_at)}</span>
</div>
)}
</div>
</div>
<hr />
{/* Estadísticas específicas por entidad */}
{entityKey === 'tecnicos' && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-chart-bar me-2"></i>Estadísticas de Tareas
</h6>
{loading ? (
<div className="text-center py-3">
<div className="spinner-border spinner-border-sm text-primary" role="status">
<span className="visually-hidden">Cargando...</span>
</div>
</div>
) : stats ? (
<div className="row text-center mb-4">
<div className="col-sm-4">
<div className="p-3 bg-light rounded">
<div className="h4 text-primary mb-1">{stats.total_tasks}</div>
<small className="text-muted fw-bold">Total Tareas</small>
</div>
</div>
<div className="col-sm-4">
<div className="p-3 bg-success rounded text-white">
<div className="h4 mb-1">{stats.completed_tasks}</div>
<small className="fw-bold">Completadas</small>
</div>
</div>
<div className="col-sm-4">
<div className="p-3 bg-warning rounded">
<div className="h4 text-dark mb-1">{stats.active_tasks}</div>
<small className="text-dark fw-bold">Activas</small>
</div>
</div>
</div>
) : null}
{/* Tareas recientes */}
{stats && stats.recent_tasks && stats.recent_tasks.length > 0 && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-clock me-2"></i>Tareas Recientes
</h6>
<div className="list-group">
{stats.recent_tasks.slice(0, 3).map((task: any, index: number) => (
<div key={index} className="list-group-item">
<div className="d-flex w-100 justify-content-between">
<h6 className="mb-1">{task.title}</h6>
<small className={`badge ${
task.status === 'completed' ? 'bg-success' :
task.status === 'in_progress' ? 'bg-warning text-dark' :
'bg-secondary'
}`}>
{formatTaskStatus(task.status)}
</small>
</div>
<p className="mb-1">{task.description || 'Sin descripción'}</p>
<small className="text-muted">
Creada: {formatDate(task.created_at)}
{task.due_date && ` • Vence: ${formatDate(task.due_date)}`}
</small>
</div>
))}
</div>
</>
)}
{stats && (!stats.recent_tasks || stats.recent_tasks.length === 0) && (
<div className="text-center py-4 text-muted">
<i className="fas fa-inbox fa-2x mb-2"></i>
<p>No hay tareas recientes</p>
</div>
)}
</>
)}
{entityKey === 'usuarios' && stats && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-chart-bar me-2"></i>Estadísticas de Recursos
</h6>
<div className="row text-center mb-4">
<div className="col-sm-6">
<div className="p-3 bg-info rounded text-white">
<div className="h4 mb-1">{stats.assigned_resources_count || 0}</div>
<small className="fw-bold">Recursos Asignados</small>
</div>
</div>
<div className="col-sm-6">
<div className="p-3 bg-success rounded text-white">
<div className="h4 mb-1">{entity.position || 'Sin cargo'}</div>
<small className="fw-bold">Cargo</small>
</div>
</div>
</div>
</>
)}
{entityKey === 'recursos' && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-info-circle me-2"></i>Detalles del Recurso
</h6>
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Categoría</strong>
<span>{entity.category}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Estado</strong>
<span className={`badge ${entity.status === 'available' ? 'bg-success' : entity.status === 'assigned' ? 'bg-info' : 'bg-warning'}`}>
{entity.status === 'available' ? 'Disponible' : entity.status === 'assigned' ? 'Asignado' : entity.status === 'maintenance' ? 'Mantenimiento' : entity.status}
</span>
</div>
</div>
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Ubicación</strong>
<span>{entity.location || 'No especificada'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Modelo</strong>
<span>{entity.model || 'No especificado'}</span>
</div>
</div>
</div>
</>
)}
{entityKey === 'tareas' && (
<>
<h6 className="fw-bold text-primary mb-3">
<i className="fas fa-tasks me-2"></i>Detalles de la Tarea
</h6>
<div className="row">
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Estado</strong>
<span className={`badge ${entity.status === 'completed' ? 'bg-success' : entity.status === 'in_progress' ? 'bg-warning text-dark' : 'bg-secondary'}`}>
{formatTaskStatus(entity.status)}
</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Prioridad</strong>
<span className={`badge ${entity.priority === 'urgent' ? 'bg-danger' : entity.priority === 'high' ? 'bg-warning text-dark' : entity.priority === 'medium' ? 'bg-info' : 'bg-light text-dark'}`}>
{entity.priority === 'urgent' ? 'Urgente' : entity.priority === 'high' ? 'Alta' : entity.priority === 'medium' ? 'Media' : 'Baja'}
</span>
</div>
</div>
<div className="col-sm-6">
<div className="mb-3">
<strong className="text-muted d-block">Fecha Límite</strong>
<span>{entity.due_date ? formatDate(entity.due_date) : 'Sin fecha límite'}</span>
</div>
<div className="mb-3">
<strong className="text-muted d-block">Técnico Asignado</strong>
<span>{entity.technician_name || 'Sin asignar'}</span>
</div>
</div>
</div>
{entity.description && (
<div className="mb-3">
<strong className="text-muted d-block">Descripción</strong>
<p className="mb-0">{entity.description}</p>
</div>
)}
</>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button
type="button"
className="btn btn-secondary"
onClick={onClose}
>
<i className="fas fa-times me-2"></i>Cerrar
</button>
<button
type="button"
className="btn btn-warning"
onClick={() => {
onClose()
onEdit(entity.id)
}}
>
<i className="fas fa-edit me-2"></i>Editar {config.name.slice(0, -1)}
</button>
</div>
</div>
</div>
</div>
)
}
export { ProfileModal }
@@ -0,0 +1,284 @@
import React, { useState } from 'react'
// Types for the control
export interface AssignedResource {
id: number
name: string
category?: string
status?: string
location?: string
}
export interface ResourceOption {
id: number
name: string
category?: string
status?: string
location?: string
}
export interface ResourceAssignmentControlProps {
entityId: number
entityType: 'tarea' | 'usuario' | 'tecnico' | 'recurso'
assignedResources: AssignedResource[]
availableResources: ResourceOption[]
onAssignResource: (resourceId: number) => Promise<boolean>
onUnassignResource: (resourceId: number) => Promise<boolean>
loading?: boolean
className?: string
showAddIcon?: boolean
maxHeight?: string
}
export const ResourceAssignmentControl: React.FC<ResourceAssignmentControlProps> = ({
entityId,
entityType,
assignedResources,
availableResources,
onAssignResource,
onUnassignResource,
loading = false,
className = '',
showAddIcon = true,
maxHeight = '300px'
}) => {
const [isAssigning, setIsAssigning] = useState<number | null>(null)
const [isUnassigning, setIsUnassigning] = useState<number | null>(null)
const [showAddResource, setShowAddResource] = useState(false)
const [selectedResourceId, setSelectedResourceId] = useState<string>('')
// Get icon and color for resource status
const getResourceStatusInfo = (status?: string) => {
switch (status) {
case 'available':
return { icon: 'fa-check', color: 'success', text: 'Disponible' }
case 'assigned':
return { icon: 'fa-user', color: 'info', text: 'Relacionado' }
case 'maintenance':
return { icon: 'fa-spinner', color: 'warning', text: 'Mantenimiento' }
case 'retired':
return { icon: 'fa-trash', color: 'danger', text: 'Retirado' }
default:
return { icon: 'fa-box', color: 'secondary', text: 'Desconocido' }
}
}
// Handle resource assignment
const handleAssignResource = async () => {
if (!selectedResourceId) return
setIsAssigning(parseInt(selectedResourceId))
try {
const success = await onAssignResource(parseInt(selectedResourceId))
if (success) {
setShowAddResource(false)
setSelectedResourceId('')
}
} catch (error) {
console.error('Error assigning resource:', error)
} finally {
setIsAssigning(null)
}
}
// Handle resource unassignment
const handleUnassignResource = async (resourceId: number) => {
setIsUnassigning(resourceId)
try {
await onUnassignResource(resourceId)
} catch (error) {
console.error('Error unassigning resource:', error)
} finally {
setIsUnassigning(null)
}
}
// Get category color
const getCategoryColor = (category?: string) => {
switch (category) {
case 'hardware':
return 'primary'
case 'software':
return 'info'
case 'network':
return 'success'
case 'security':
return 'danger'
case 'tools':
return 'warning'
case 'facilities':
return 'secondary'
default:
return 'secondary'
}
}
return (
<div className={`resource-assignment-control ${className}`}>
{/* Header with Add Resource Button */}
<div className="d-flex justify-content-between align-items-center mb-3">
<h6 className="mb-0">
<i className="fas fa-boxes me-2"></i>
Recursos Relacionados
</h6>
{showAddIcon && !loading && (
<button
type="button"
className="btn btn-sm btn-outline-primary"
onClick={() => setShowAddResource(!showAddResource)}
disabled={isAssigning !== null}
>
<i className="fas fa-plus me-1"></i>
Relacionar Recurso
</button>
)}
</div>
{/* Add Resource Section */}
{showAddResource && (
<div className="card mb-3 border-primary">
<div className="card-body">
<div className="row g-2 align-items-end">
<div className="col-md-8">
<label className="form-label small fw-bold">
Seleccionar Recurso:
</label>
<select
className="form-select form-select-sm"
value={selectedResourceId}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setSelectedResourceId(e.target.value)}
disabled={isAssigning !== null}
>
<option value="">Seleccionar recurso...</option>
{availableResources
.filter((resource: ResourceOption) =>
!assignedResources.some((assigned: AssignedResource) => assigned.id === resource.id)
)
.map((resource: ResourceOption) => (
<option key={resource.id} value={resource.id}>
{resource.name} {resource.category ? `(${resource.category})` : ''}
</option>
))
}
</select>
</div>
<div className="col-md-4">
<div className="d-flex gap-1">
<button
type="button"
className="btn btn-sm btn-success"
onClick={handleAssignResource}
disabled={!selectedResourceId || isAssigning !== null}
>
{isAssigning !== null ? (
<>
<i className="fas fa-spinner fa-spin me-1"></i>
Relacionando...
</>
) : (
<>
<i className="fas fa-check me-1"></i>
Relacionar
</>
)}
</button>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => {
setShowAddResource(false)
setSelectedResourceId('')
}}
disabled={isAssigning !== null}
>
<i className="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Currently Assigned Resources */}
<div className="resource-list" style={{ maxHeight, overflowY: 'auto' }}>
{loading ? (
<div className="text-center py-3">
<i className="fas fa-spinner fa-spin fa-2x text-muted"></i>
<p className="text-muted mt-2">Cargando recursos...</p>
</div>
) : assignedResources.length > 0 ? (
<div className="row g-2">
{assignedResources.map((resource: AssignedResource) => {
const statusInfo = getResourceStatusInfo(resource.status)
return (
<div key={resource.id} className="col-12">
<div className="card border-0 bg-light">
<div className="card-body py-2 px-3">
<div className="d-flex justify-content-between align-items-center">
<div className="d-flex align-items-center">
<div className={`badge bg-${getCategoryColor(resource.category)} me-2`}>
<i className="fas fa-box me-1"></i>
{resource.category || 'Recurso'}
</div>
<div>
<h6 className="mb-0 small">{resource.name}</h6>
{resource.location && (
<small className="text-muted">
<i className="fas fa-map-marker-alt me-1"></i>
{resource.location}
</small>
)}
</div>
</div>
<div className="d-flex align-items-center gap-2">
<span className={`badge bg-${statusInfo.color} small`}>
<i className={`fas ${statusInfo.icon} me-1`}></i>
{statusInfo.text}
</span>
<button
type="button"
className="btn btn-sm btn-outline-danger"
onClick={() => handleUnassignResource(resource.id)}
disabled={isUnassigning === resource.id}
title="Quitar relación"
>
{isUnassigning === resource.id ? (
<i className="fas fa-spinner fa-spin"></i>
) : (
<i className="fas fa-trash"></i>
)}
</button>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-center py-4">
<i className="fas fa-box fa-3x text-muted mb-3"></i>
<p className="text-muted mb-0">No hay recursos relacionados</p>
<small className="text-muted">
{showAddResource ? 'Selecciona un recurso para asignar' : 'Haz clic en "Asignar Recurso" para comenzar'}
</small>
</div>
)}
</div>
{/* Summary */}
{assignedResources.length > 0 && (
<div className="mt-3 pt-2 border-top">
<small className="text-muted">
<i className="fas fa-info-circle me-1"></i>
Total: {assignedResources.length} recurso{assignedResources.length !== 1 ? 's' : ''} relacionado{assignedResources.length !== 1 ? 's' : ''}
</small>
</div>
)}
</div>
)
}
export default ResourceAssignmentControl
@@ -0,0 +1,497 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../stores/authStore'
import { useState, useEffect } from 'react'
const Navbar = () => {
const location = useLocation()
const navigate = useNavigate()
const { user, isAuthenticated, logout } = useAuthStore()
const [isScrolled, setIsScrolled] = useState(false)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const [isHidden, setIsHidden] = useState(false)
const [isHovered, setIsHovered] = useState(false)
const [lastScrollY, setLastScrollY] = useState(0)
const handleLogout = () => {
logout()
navigate('/login')
}
const isActive = (path: string) => {
return location.pathname === path
}
// Detect scroll for navbar styling and auto-hide
useEffect(() => {
const handleScroll = () => {
const currentScrollY = window.scrollY
const scrollThreshold = 100
// Update scrolled state for visual effects
setIsScrolled(currentScrollY > 20)
// Auto-hide logic: hide when scrolling down, show when scrolling up
if (currentScrollY > lastScrollY && currentScrollY > scrollThreshold) {
setIsHidden(true)
} else if (currentScrollY < lastScrollY) {
setIsHidden(false)
}
setLastScrollY(currentScrollY)
}
window.addEventListener('scroll', handleScroll, { passive: true })
return () => window.removeEventListener('scroll', handleScroll)
}, [lastScrollY])
// Close mobile menu when route changes
useEffect(() => {
setIsMenuOpen(false)
}, [location.pathname])
if (!isAuthenticated) {
return null
}
const navItems = [
// Nuevo orden: Dashboard, Calendario, Recursos, Tareas, Técnicos, Usuarios, Reportes
{ path: '/dashboard', icon: 'fas fa-tachometer-alt', label: 'Dashboard', color: 'text-primary', group: 'vision' },
{ path: '/calendario', icon: 'fas fa-calendar', label: 'Calendario', color: 'text-secondary', group: 'vision' },
{ path: '/recursos', icon: 'fas fa-server', label: 'Recursos', color: 'text-info', group: 'gestion' },
{ path: '/tareas', icon: 'fas fa-tasks', label: 'Tareas', color: 'text-warning', group: 'operaciones' },
{ path: '/tecnicos', icon: 'fas fa-users', label: 'Técnicos', color: 'text-success', group: 'operaciones' },
...(user?.role === 'admin' ? [
{ path: '/usuarios', icon: 'fas fa-user-cog', label: 'Usuarios', color: 'text-dark', group: 'gestion' },
{ path: '/sistema', icon: 'fas fa-cogs', label: 'Sistema', color: 'text-secondary', group: 'sistema' }
] : []),
{ path: '/reportes', icon: 'fas fa-chart-bar', label: 'Reportes', color: 'text-danger', group: 'reportes' }
]
return (
<>
{/* Custom CSS for modern navbar */}
<style>{`
.modern-navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
transform: translateY(0);
}
.modern-navbar.scrolled {
background: rgba(102, 126, 234, 0.95);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.modern-navbar.hidden {
transform: translateY(-100%);
}
.modern-navbar:hover {
transform: translateY(0) !important;
}
.nav-item-modern {
position: relative;
margin: 0 4px;
}
.nav-group-vision {
margin-right: 20px;
}
.nav-group-gestion {
margin-right: 20px;
}
.nav-group-operaciones {
margin-right: 20px;
}
.nav-group-sistema {
margin-right: 20px;
}
.nav-group-separators::before {
content: '';
position: absolute;
left: -12px;
top: 50%;
transform: translateY(-50%);
width: 1px;
height: 60%;
background: rgba(255, 255, 255, 0.3);
}
.nav-link-modern {
color: rgba(255, 255, 255, 0.9) !important;
font-weight: 500;
padding: 12px 20px;
border-radius: 25px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
}
.nav-link-modern::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.nav-link-modern:hover::before {
left: 100%;
}
.nav-link-modern:hover {
color: white !important;
background: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.nav-link-modern.active {
background: rgba(255, 255, 255, 0.15);
color: white !important;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);
}
.nav-link-modern i {
margin-bottom: 4px;
font-size: 1.1em;
}
.navbar-brand-modern {
font-weight: 700;
font-size: 1.5rem;
color: white !important;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.navbar-brand-modern:hover {
color: rgba(255, 255, 255, 0.8) !important;
transform: scale(1.05);
transition: all 0.3s ease;
}
.version-badge {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.05));
border: 1px solid rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.8);
font-size: 0.75rem;
font-weight: 500;
padding: 4px 8px;
border-radius: 12px;
backdrop-filter: blur(10px);
transition: var(--transition);
}
.version-badge:hover {
background: rgba(255, 255, 255, 0.2);
color: white;
transform: scale(1.05);
}
.user-menu-modern {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 25px;
padding: 8px 16px;
color: white !important;
transition: all 0.3s ease;
}
.user-menu-modern:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.dropdown-menu-modern {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 15px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
margin-top: 10px;
}
.dropdown-item-modern {
color: rgba(255, 255, 255, 0.9) !important;
padding: 12px 20px;
border-radius: 8px;
margin: 4px 8px;
transition: all 0.3s ease;
}
.dropdown-item-modern:hover {
background: rgba(255, 255, 255, 0.2);
color: white !important;
transform: translateX(5px);
}
.mobile-menu-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
z-index: 1040;
opacity: 0;
visibility: hidden;
transition: all 0.3s ease;
}
.mobile-menu-overlay.active {
opacity: 1;
visibility: visible;
}
.mobile-menu {
position: fixed;
top: 0;
right: -300px;
width: 280px;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: -5px 0 25px rgba(0, 0, 0, 0.3);
transition: right 0.3s ease;
z-index: 1050;
padding: 80px 20px 20px;
}
.mobile-menu.active {
right: 0;
}
.mobile-nav-item {
margin-bottom: 10px;
}
.mobile-nav-link {
display: block;
color: rgba(255, 255, 255, 0.9) !important;
padding: 15px 20px;
border-radius: 12px;
text-decoration: none;
transition: all 0.3s ease;
font-weight: 500;
}
.mobile-nav-link:hover,
.mobile-nav-link.active {
background: rgba(255, 255, 255, 0.2);
color: white !important;
transform: translateX(10px);
}
.mobile-nav-link i {
margin-right: 12px;
width: 20px;
}
.hamburger-menu {
display: none;
flex-direction: column;
cursor: pointer;
padding: 10px;
border-radius: 8px;
transition: all 0.3s ease;
}
.hamburger-menu:hover {
background: rgba(255, 255, 255, 0.1);
}
.hamburger-line {
width: 25px;
height: 3px;
background: white;
margin: 3px 0;
transition: all 0.3s ease;
border-radius: 2px;
}
.hamburger-menu.active .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(5px, 5px);
}
.hamburger-menu.active .hamburger-line:nth-child(2) {
opacity: 0;
}
.hamburger-menu.active .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(7px, -6px);
}
@media (max-width: 991px) {
.hamburger-menu {
display: flex;
}
.desktop-nav {
display: none;
}
}
@media (min-width: 992px) {
.mobile-menu-overlay,
.mobile-menu {
display: none !important;
}
}
`}</style>
{/* Mobile menu overlay */}
<div
className={`mobile-menu-overlay ${isMenuOpen ? 'active' : ''}`}
onClick={() => setIsMenuOpen(false)}
/>
{/* Mobile menu */}
<div className={`mobile-menu ${isMenuOpen ? 'active' : ''}`}>
<div className="text-center mb-4">
<h5 className="text-white mb-4">Menú de Navegación</h5>
</div>
{navItems.map((item) => (
<div key={item.path} className="mobile-nav-item">
<Link
to={item.path}
className={`mobile-nav-link ${isActive(item.path) ? 'active' : ''}`}
onClick={() => setIsMenuOpen(false)}
>
<i className={item.icon}></i>
{item.label}
</Link>
</div>
))}
<hr className="border-light my-4" />
<div className="mobile-nav-item">
<Link
to="/perfil"
className="mobile-nav-link"
onClick={() => setIsMenuOpen(false)}
>
<i className="fas fa-user-edit"></i>
Mi Perfil
</Link>
</div>
<div className="mobile-nav-item">
<button
className="mobile-nav-link w-100 text-start border-0 bg-transparent"
onClick={() => {
handleLogout()
setIsMenuOpen(false)
}}
>
<i className="fas fa-sign-out-alt"></i>
Cerrar Sesión
</button>
</div>
</div>
{/* Main Navbar */}
<nav
className={`navbar navbar-expand-lg fixed-top modern-navbar ${isScrolled ? 'scrolled' : ''} ${isHidden ? 'hidden' : ''}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="container-fluid">
{/* Brand */}
<Link className="navbar-brand navbar-brand-modern" to="/dashboard">
<i className="fas fa-cogs me-2"></i>
DTIC Bitácoras
<span className="version-badge ms-2">v{import.meta.env.VERSION}</span>
</Link>
{/* Hamburger Menu for Mobile */}
<div
className={`hamburger-menu ${isMenuOpen ? 'active' : ''}`}
onClick={() => setIsMenuOpen(!isMenuOpen)}
>
<div className="hamburger-line"></div>
<div className="hamburger-line"></div>
<div className="hamburger-line"></div>
</div>
{/* Desktop Navigation */}
<div className="collapse navbar-collapse desktop-nav">
<ul className="navbar-nav me-auto">
{navItems.map((item, index) => {
const prevItem = index > 0 ? navItems[index - 1] : null
const showSeparator = prevItem && prevItem.group !== item.group
return (
<li
key={item.path}
className={`nav-item nav-item-modern ${showSeparator ? 'nav-group-separators' : ''}`}
>
<Link
to={item.path}
className={`nav-link nav-link-modern ${isActive(item.path) ? 'active' : ''}`}
>
<i className={item.icon}></i>
{item.label}
</Link>
</li>
)
})}
</ul>
{/* User Menu */}
<ul className="navbar-nav">
<li className="nav-item dropdown">
<a
className="nav-link dropdown-toggle user-menu-modern"
href="#"
id="navbarDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<i className="fas fa-user-circle me-2"></i>
<span className="d-none d-md-inline">{user?.first_name}</span>
<span className="d-md-none">Perfil</span>
</a>
<ul className="dropdown-menu dropdown-menu-end dropdown-menu-modern" aria-labelledby="navbarDropdown">
<li>
<Link className="dropdown-item dropdown-item-modern" to="/perfil">
<i className="fas fa-user-edit me-2"></i>
Mi Perfil
</Link>
</li>
<li><hr className="dropdown-divider border-light" /></li>
<li>
<button className="dropdown-item dropdown-item-modern" onClick={handleLogout}>
<i className="fas fa-sign-out-alt me-2"></i>
Cerrar Sesión
</button>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</>
)
}
export default Navbar
@@ -0,0 +1,279 @@
import React, { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useAuthStore } from '../../stores/authStore'
interface SidebarProps {
onToggle?: (collapsed: boolean) => void
}
const Sidebar = ({ onToggle }: SidebarProps) => {
const location = useLocation()
const { user, logout } = useAuthStore()
const [isCollapsed, setIsCollapsed] = useState(false)
const handleLogout = () => {
logout()
// Navigate to login will be handled by the auth store
}
const isActive = (path: string) => {
return location.pathname === path
}
// Load collapsed state from localStorage on mount
useEffect(() => {
const savedState = localStorage.getItem('sidebarCollapsed')
if (savedState === 'true') {
setIsCollapsed(true)
}
}, [])
// Save collapsed state to localStorage
const toggleSidebar = () => {
const newState = !isCollapsed
setIsCollapsed(newState)
localStorage.setItem('sidebarCollapsed', newState.toString())
if (onToggle) {
onToggle(newState)
}
}
const navItems = [
{ path: '/dashboard', icon: 'fas fa-tachometer-alt', label: 'Dashboard', color: 'text-primary' },
{ path: '/calendario', icon: 'fas fa-calendar', label: 'Calendario', color: 'text-secondary' },
{ path: '/recursos', icon: 'fas fa-server', label: 'Recursos', color: 'text-info' },
{ path: '/tareas', icon: 'fas fa-tasks', label: 'Tareas', color: 'text-warning' },
{ path: '/tecnicos', icon: 'fas fa-users', label: 'Técnicos', color: 'text-success' },
...(user?.role === 'admin' ? [
{ path: '/usuarios', icon: 'fas fa-user-cog', label: 'Usuarios', color: 'text-dark' },
{ path: '/sistema', icon: 'fas fa-cogs', label: 'Sistema', color: 'text-secondary' }
] : []),
{ path: '/reportes', icon: 'fas fa-chart-bar', label: 'Reportes', color: 'text-danger' }
]
return (
<>
{/* Modern Material Design 3 Inspired Sidebar CSS */}
<style>{`
.sidebar-modern {
width: 280px;
background: var(--bg-secondary);
color: var(--text-primary);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: fixed;
top: 0;
left: 0;
height: 100vh;
z-index: 1000;
box-shadow: 0 4px 6px -1px var(--shadow-primary), 0 2px 4px -1px var(--shadow-secondary);
border-right: 1px solid var(--border-primary);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar-modern.collapsed {
width: 72px;
}
.sidebar-header-modern {
padding: 24px 20px 16px;
border-bottom: 1px solid var(--border-primary);
display: flex;
align-items: center;
justify-content: space-between;
min-height: 80px;
background: var(--bg-tertiary);
}
.sidebar-modern.collapsed .sidebar-header-modern {
justify-content: center;
padding: 24px 12px 16px;
}
.sidebar-brand-modern {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
font-weight: 700;
font-size: 1.1rem;
color: #1f2937;
}
.sidebar-modern.collapsed .brand-text-modern {
display: none;
}
.sidebar-toggle-modern {
background: var(--bg-tertiary);
border: 1px solid var(--border-primary);
color: var(--text-secondary);
font-size: 1.1rem;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px 0 var(--shadow-primary), 0 1px 2px 0 var(--shadow-secondary);
}
.sidebar-toggle-modern:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: scale(1.05);
box-shadow: 0 4px 6px -1px var(--shadow-primary), 0 2px 4px -1px var(--shadow-secondary);
}
.sidebar-nav-modern {
padding: 16px 0;
}
.sidebar-link-modern {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 20px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 12px;
margin: 4px 12px;
text-align: center;
position: relative;
font-weight: 500;
font-size: 0.875rem;
}
.sidebar-link-modern:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: translateY(-1px);
box-shadow: 0 2px 4px -1px var(--shadow-primary), 0 1px 2px -1px var(--shadow-secondary);
}
.sidebar-link-modern.active {
background: var(--bg-active);
color: white;
box-shadow: 0 4px 6px -1px var(--shadow-accent), 0 2px 4px -1px rgba(59, 130, 246, 0.2);
}
.sidebar-link-modern.active:hover {
background: #2563eb;
}
.sidebar-link-modern i {
font-size: 1.5rem;
margin-bottom: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.sidebar-link-modern:hover i,
.sidebar-link-modern.active i {
transform: scale(1.1);
}
.sidebar-modern.collapsed .link-text-modern {
display: none;
}
/* Modern dark scrollbar */
.sidebar-modern::-webkit-scrollbar {
width: 6px;
}
.sidebar-modern::-webkit-scrollbar-track {
background: var(--bg-tertiary);
}
.sidebar-modern::-webkit-scrollbar-thumb {
background: var(--border-secondary);
border-radius: 3px;
}
.sidebar-modern::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Mobile responsive */
@media (max-width: 768px) {
.sidebar-modern {
transform: translateX(-100%);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 4px 0 6px -1px rgba(0, 0, 0, 0.1), 2px 0 4px -1px rgba(0, 0, 0, 0.06);
}
.sidebar-modern.mobile-open {
transform: translateX(0);
}
.sidebar-overlay-modern {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.5);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
backdrop-filter: blur(4px);
}
.sidebar-overlay-modern.active {
opacity: 1;
visibility: visible;
}
}
/* Focus states for accessibility */
.sidebar-link-modern:focus,
.sidebar-toggle-modern:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
`}</style>
{/* Modern Material Design Sidebar */}
<aside className={`sidebar-modern ${isCollapsed ? 'collapsed' : ''}`}>
{/* Header */}
<div className="sidebar-header-modern">
<button
className="sidebar-toggle-modern"
onClick={toggleSidebar}
title={isCollapsed ? 'Expandir menú' : 'Colapsar menú'}
aria-label={isCollapsed ? 'Expandir menú' : 'Colapsar menú'}
>
<i className={`fas ${isCollapsed ? 'fa-chevron-right' : 'fa-chevron-left'}`}></i>
</button>
<div className="sidebar-brand-modern">
<i className="fas fa-cogs text-primary" style={{ fontSize: '1.8rem', marginBottom: '4px' }}></i>
<span className="brand-text-modern fw-bold">DTIC</span>
</div>
</div>
{/* Navigation */}
<nav className="sidebar-nav-modern" role="navigation" aria-label="Navegación principal">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`sidebar-link-modern ${isActive(item.path) ? 'active' : ''}`}
title={isCollapsed ? item.label : ''}
aria-label={item.label}
>
<i className={item.icon} aria-hidden="true"></i>
<span className="link-text-modern">{item.label}</span>
</Link>
))}
</nav>
</aside>
</>
)
}
export default Sidebar
@@ -0,0 +1,582 @@
import React, { useState, useEffect } from 'react'
import Sidebar from './Sidebar'
import { useAuthStore } from '../../stores/authStore'
import { useTheme } from '../../contexts/ThemeContext'
interface SidebarLayoutProps {
children: React.ReactNode
className?: string
}
export const SidebarLayout = ({ children, className = '' }: SidebarLayoutProps) => {
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false)
const { user, logout } = useAuthStore()
const { theme, toggleTheme } = useTheme()
const handleLogout = () => {
logout()
setIsUserMenuOpen(false)
}
// Load initial collapsed state from localStorage
useEffect(() => {
const savedState = localStorage.getItem('sidebarCollapsed')
if (savedState === 'true') {
setIsSidebarCollapsed(true)
}
}, [])
const handleSidebarToggle = (collapsed: boolean) => {
setIsSidebarCollapsed(collapsed)
}
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element
if (!target.closest('.floating-user-menu')) {
setIsUserMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
return (
<div className={`sidebar-layout ${isSidebarCollapsed ? 'has-collapsed-sidebar' : ''} ${className}`}>
{/* Custom CSS for sidebar layout */}
<style>{`
.sidebar-layout {
display: flex;
min-height: 100vh;
background: var(--bg-primary);
}
.sidebar-main-content {
flex: 1;
margin-left: 280px;
transition: margin-left 0.3s ease;
display: flex;
flex-direction: column;
}
.sidebar-layout.has-collapsed-sidebar .sidebar-main-content {
margin-left: 70px;
}
/* Floating User Menu Styles */
.floating-user-menu {
position: fixed;
top: 20px;
right: 20px;
z-index: 1050;
}
.floating-user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--bg-active);
border: 3px solid var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 1.2rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px var(--shadow-primary);
}
.floating-user-avatar:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px var(--shadow-accent);
border-color: var(--bg-active);
}
.floating-user-avatar.active {
border-color: var(--bg-active);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3);
}
.floating-user-dropdown {
position: absolute;
top: 60px;
right: 0;
width: 280px;
background: var(--bg-card);
border: 1px solid var(--border-primary);
border-radius: 16px;
box-shadow: 0 20px 25px -5px var(--shadow-primary), 0 10px 10px -5px var(--shadow-secondary);
opacity: 0;
visibility: hidden;
transform: translateY(-10px) scale(0.95);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 1060;
}
.floating-user-dropdown.open {
opacity: 1;
visibility: visible;
transform: translateY(0) scale(1);
}
.user-dropdown-header {
padding: 20px;
border-bottom: 1px solid var(--border-primary);
background: var(--bg-tertiary);
border-radius: 16px 16px 0 0;
}
.user-avatar-large {
width: 64px;
height: 64px;
border-radius: 50%;
background: var(--bg-active);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 1.5rem;
margin: 0 auto 12px;
border: 3px solid var(--bg-secondary);
}
.user-full-info {
text-align: center;
}
.user-full-name {
font-weight: 700;
font-size: 1.1rem;
color: var(--text-primary);
margin-bottom: 4px;
}
.user-full-role {
font-size: 0.875rem;
color: var(--text-secondary);
background: var(--bg-active);
padding: 4px 12px;
border-radius: 20px;
display: inline-block;
font-weight: 500;
}
.user-dropdown-body {
padding: 8px 0;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
color: var(--text-primary);
text-decoration: none;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
margin: 4px 12px;
}
.user-menu-item:hover {
background: var(--bg-hover);
transform: translateX(4px);
}
.user-menu-item i {
font-size: 1.1rem;
width: 20px;
color: var(--text-secondary);
}
.user-menu-item.logout {
color: var(--error);
border-top: 1px solid var(--border-primary);
margin-top: 8px;
padding-top: 16px;
}
.user-menu-item.logout:hover {
background: rgba(239, 68, 68, 0.1);
}
.user-menu-item.logout i {
color: var(--error);
}
/* Content adjustment - no top margin needed since no header */
.sidebar-content-wrapper {
flex: 1;
overflow-y: auto;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.sidebar-main-content {
margin-left: 0;
}
.sidebar-layout.has-collapsed-sidebar .sidebar-main-content {
margin-left: 0;
}
.sidebar-top-header {
left: 0;
right: 0;
padding: 0 20px;
}
.sidebar-layout.has-collapsed-sidebar .sidebar-top-header {
left: 0;
}
}
.sidebar-top-header {
background: white;
padding: 15px 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 70px;
}
.sidebar-page-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
color: #495057;
}
.sidebar-page-subtitle {
margin: 0;
font-size: 1rem;
color: #6c757d;
font-weight: 400;
}
.sidebar-page-content {
flex: 1;
padding: 30px;
overflow-y: auto;
}
.sidebar-breadcrumb {
background: white;
padding: 10px 30px;
border-bottom: 1px solid #e9ecef;
font-size: 0.875rem;
}
.sidebar-breadcrumb a {
color: #667eea;
text-decoration: none;
}
.sidebar-breadcrumb a:hover {
text-decoration: underline;
}
.sidebar-breadcrumb .current {
color: #6c757d;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.sidebar-main-content {
margin-left: 0;
}
.sidebar-layout.has-collapsed-sidebar .sidebar-main-content {
margin-left: 0;
}
.sidebar-page-content {
padding: 20px 15px;
}
.sidebar-top-header {
padding: 15px 20px;
}
}
/* Animation for content when sidebar collapses */
.sidebar-content-wrapper {
transition: all 0.3s ease;
}
/* Ensure proper spacing for fixed sidebar */
.sidebar-layout .sidebar-main-content {
width: calc(100% - 280px);
}
.sidebar-layout.has-collapsed-sidebar .sidebar-main-content {
width: calc(100% - 70px);
}
@media (max-width: 768px) {
.sidebar-layout .sidebar-main-content {
width: 100%;
}
.sidebar-layout.has-collapsed-sidebar .sidebar-main-content {
width: 100%;
}
}
/* Application Header Styles */
.app-header {
position: fixed;
top: 80px;
left: 280px;
right: 0;
height: 70px;
background: var(--bg-card);
border-bottom: 1px solid var(--border-primary);
box-shadow: 0 2px 4px var(--shadow-primary);
z-index: 1020;
transition: left 0.3s ease;
}
.sidebar-layout.has-collapsed-sidebar .app-header {
left: 70px;
}
.app-header-content {
height: 100%;
display: flex;
align-items: center;
padding: 0 30px;
}
.app-brand {
display: flex;
align-items: center;
gap: 15px;
}
.app-logo {
font-size: 2rem;
color: var(--bg-active);
}
.app-info {
display: flex;
flex-direction: column;
}
.app-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
line-height: 1.2;
}
.app-version {
font-size: 0.875rem;
color: var(--text-secondary);
background: var(--bg-active);
color: white;
padding: 2px 8px;
border-radius: 12px;
font-weight: 500;
display: inline-block;
margin-top: 2px;
}
/* Application Footer Styles */
.app-footer {
position: fixed;
bottom: 0;
left: 280px;
right: 0;
height: 50px;
background: var(--bg-card);
border-top: 1px solid var(--border-primary);
z-index: 1020;
transition: left 0.3s ease;
}
.sidebar-layout.has-collapsed-sidebar .app-footer {
left: 70px;
}
.app-footer-content {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0 30px;
}
.footer-info {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
font-size: 0.875rem;
color: var(--text-muted);
}
.footer-text {
display: flex;
align-items: center;
}
.footer-version {
font-weight: 500;
color: var(--text-secondary);
}
/* Adjust main content for header and footer */
.sidebar-main-content {
margin-top: 150px;
margin-bottom: 50px;
min-height: calc(100vh - 200px);
}
/* Mobile responsiveness for header and footer */
@media (max-width: 768px) {
.app-header {
top: 80px;
left: 0;
height: 60px;
}
.sidebar-layout.has-collapsed-sidebar .app-header {
left: 0;
}
.app-header-content {
padding: 0 20px;
}
.app-name {
font-size: 1.25rem;
}
.app-logo {
font-size: 1.5rem;
}
.app-footer {
left: 0;
height: 45px;
}
.sidebar-layout.has-collapsed-sidebar .app-footer {
left: 0;
}
.app-footer-content {
padding: 0 20px;
}
.footer-info {
flex-direction: column;
gap: 5px;
text-align: center;
}
.sidebar-main-content {
margin-top: 140px;
margin-bottom: 45px;
min-height: calc(100vh - 185px);
}
}
`}</style>
<Sidebar onToggle={handleSidebarToggle} />
{/* Application Header */}
<header className="app-header">
<div className="app-header-content">
<div className="app-brand">
<i className="fas fa-cogs app-logo"></i>
<div className="app-info">
<h1 className="app-name">DTIC Bitácoras</h1>
<span className="app-version">v{(import.meta as any).env.VERSION}</span>
</div>
</div>
</div>
</header>
{/* Floating User Menu */}
<div className="floating-user-menu">
<div
className={`floating-user-avatar ${isUserMenuOpen ? 'active' : ''}`}
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
title="Menú de usuario"
>
{user?.first_name?.charAt(0)?.toUpperCase() || 'U'}
</div>
<div className={`floating-user-dropdown ${isUserMenuOpen ? 'open' : ''}`}>
<div className="user-dropdown-header">
<div className="user-avatar-large">
{user?.first_name?.charAt(0)?.toUpperCase() || 'U'}
</div>
<div className="user-full-info">
<div className="user-full-name">{user?.first_name}</div>
<div className="user-full-role">
{user?.role === 'admin' ? 'Administrador' : 'Usuario'}
</div>
</div>
</div>
<div className="user-dropdown-body">
<a href="/perfil" className="user-menu-item" onClick={() => setIsUserMenuOpen(false)}>
<i className="fas fa-user-edit"></i>
<span>Mi Perfil</span>
</a>
<button
className="user-menu-item"
onClick={() => {
toggleTheme()
setIsUserMenuOpen(false)
}}
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`}></i>
<span>{theme === 'dark' ? 'Tema Claro' : 'Tema Oscuro'}</span>
</button>
<button
className="user-menu-item logout"
onClick={handleLogout}
>
<i className="fas fa-sign-out-alt"></i>
<span>Cerrar Sesión</span>
</button>
</div>
</div>
</div>
<main className="sidebar-main-content">
<div className="sidebar-content-wrapper">
{children}
</div>
</main>
{/* Application Footer */}
<footer className="app-footer">
<div className="app-footer-content">
<div className="footer-info">
<span className="footer-text">
<i className="fas fa-university me-2"></i>
DTIC - Universidad Tecnológica Nacional
</span>
<span className="footer-version">
Versión {(import.meta as any).env.VERSION}
</span>
</div>
</div>
</footer>
</div>
)
}