comentario: sistema de gestion de archivos y transmites

This commit is contained in:
Cap. Miguel Arcangel Ollarves Mayorquin 2026-06-02 15:21:41 -04:00
commit 217a605f2d
40 changed files with 7201 additions and 0 deletions

52
.env Normal file
View File

@ -0,0 +1,52 @@
# ============================================================
# SISTEMA DE GESTIÓN DE DOCUMENTACIÓN - Configuración .env
# Copie este archivo como .env y complete los valores
# NUNCA suba el archivo .env a repositorios de código
# ============================================================
# --- Base de Datos ---
DB_HOST=localhost
DB_PORT=3306
DB_NAME=gestion_documentos
DB_USER=root
DB_PASS=
# --- Aplicación ---
APP_NAME="Sistema de Gestión de Documentación"
APP_URL=http://localhost/ProyectoGestion
APP_ENV=development
APP_DEBUG=true
APP_TIMEZONE=America/Caracas
# --- Seguridad ---
APP_SECRET_KEY=CAMBIE_ESTA_CLAVE_POR_UNA_SEGURA_ALEATORIA_64_CHARS
SESSION_TIMEOUT=3600
SESSION_NAME=gestion_doc_session
CSRF_TOKEN_NAME=_csrf_token
# --- Correo SMTP (PHPMailer) ---
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=su_correo@gmail.com
MAIL_PASSWORD=su_contraseña_de_aplicacion
MAIL_FROM_ADDRESS=no-reply@sistema.local
MAIL_FROM_NAME="Sistema de Gestión"
# --- Archivos Adjuntos ---
UPLOAD_PATH=uploads/
UPLOAD_MAX_SIZE=10485760
UPLOAD_ALLOWED_TYPES=pdf,doc,docx,jpg,jpeg,png,gif,xls,xlsx
# --- Alertas y Cron ---
ALERT_CRON_SECRET=CLAVE_SECRETA_PARA_CRON_JOB
WEEKLY_REPORT_DAY=1
# --- Respaldo ---
BACKUP_PATH=exports/backups/
MYSQLDUMP_PATH=mysqldump
# --- Logo Institucional ---
LOGO_PATH=assets/img/logo.png
INSTITUCION_NOMBRE="DTIC - Ejército Bolivariano"
INSTITUCION_CARGO_FIRMA="Director(a) General"

52
.env.example Normal file
View File

@ -0,0 +1,52 @@
# ============================================================
# SISTEMA DE GESTIÓN DE DOCUMENTACIÓN - Configuración .env
# Copie este archivo como .env y complete los valores
# NUNCA suba el archivo .env a repositorios de código
# ============================================================
# --- Base de Datos ---
DB_HOST=localhost
DB_PORT=3306
DB_NAME=gestion_documentos
DB_USER=root
DB_PASS=
# --- Aplicación ---
APP_NAME="Sistema de Gestión de Documentación"
APP_URL=http://localhost/ProyectoGestion
APP_ENV=development
APP_DEBUG=true
APP_TIMEZONE=America/Caracas
# --- Seguridad ---
APP_SECRET_KEY=CAMBIE_ESTA_CLAVE_POR_UNA_SEGURA_ALEATORIA_64_CHARS
SESSION_TIMEOUT=3600
SESSION_NAME=gestion_doc_session
CSRF_TOKEN_NAME=_csrf_token
# --- Correo SMTP (PHPMailer) ---
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_ENCRYPTION=tls
MAIL_USERNAME=su_correo@gmail.com
MAIL_PASSWORD=su_contraseña_de_aplicacion
MAIL_FROM_ADDRESS=no-reply@sistema.local
MAIL_FROM_NAME="Sistema de Gestión"
# --- Archivos Adjuntos ---
UPLOAD_PATH=uploads/
UPLOAD_MAX_SIZE=10485760
UPLOAD_ALLOWED_TYPES=pdf,doc,docx,jpg,jpeg,png,gif,xls,xlsx
# --- Alertas y Cron ---
ALERT_CRON_SECRET=CLAVE_SECRETA_PARA_CRON_JOB
WEEKLY_REPORT_DAY=1
# --- Respaldo ---
BACKUP_PATH=exports/backups/
MYSQLDUMP_PATH=mysqldump
# --- Logo Institucional ---
LOGO_PATH=assets/img/logo.png
INSTITUCION_NOMBRE="Institución Jurídica"
INSTITUCION_CARGO_FIRMA="Director(a) General"

56
.htaccess Normal file
View File

@ -0,0 +1,56 @@
# .htaccess principal — ProyectoGestion
# Seguridad, URL clean y redirects
Options -Indexes
ServerSignature Off
# ── Caché de assets estáticos ────────────────────────────────────────────────
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/gif "access plus 1 year"
ExpiresByType image/svg+xml "access plus 1 year"
ExpiresByType font/woff2 "access plus 1 year"
</IfModule>
# ── Cabeceras de seguridad ────────────────────────────────────────────────────
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
Header set Referrer-Policy "strict-origin-when-cross-origin"
Header set Permissions-Policy "geolocation=(), microphone=(), camera=()"
</IfModule>
# ── Proteger archivos sensibles ───────────────────────────────────────────────
<FilesMatch "(\.env|\.env\.example|composer\.json|composer\.lock)$">
Order Deny,Allow
Deny from all
</FilesMatch>
<FilesMatch "^(config|cron|lib|database|exports)">
Order Deny,Allow
Deny from all
</FilesMatch>
# ── GZIP compression ─────────────────────────────────────────────────────────
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json
</IfModule>
# ── Reescritura de URL (si se quiere URL limpia) ─────────────────────────────
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /ProyectoGestion/
# Redirigir /ProyectoGestion/ → /ProyectoGestion/index.php
RewriteRule ^$ index.php [L]
# No reescribir archivos/directorios que existen
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?url=$1 [L,QSA]
</IfModule>

175
INSTALACION.md Normal file
View File

@ -0,0 +1,175 @@
# 📘 Guía de Instalación — Sistema de Gestión de Documentación
## Requisitos del Sistema
| Requisito | Mínimo |
|---|---|
| PHP | 8.0+ |
| MySQL / MariaDB | 5.7+ / 10.3+ |
| Servidor Web | Apache 2.4+ (con mod_rewrite) |
| Extensiones PHP | PDO, PDO_MySQL, mbstring, openssl, fileinfo |
---
## Paso 1: Clonar / Copiar el Proyecto
Copie la carpeta `ProyectoGestion` a la raíz web de su servidor:
- **XAMPP (Windows):** `C:\xampp\htdocs\ProyectoGestion\`
- **LAMP (Linux):** `/var/www/html/ProyectoGestion/`
- **cPanel:** Carpeta `public_html/ProyectoGestion/`
---
## Paso 2: Crear la Base de Datos
1. Abra **phpMyAdmin** o el cliente MySQL de su preferencia.
2. Cree una nueva base de datos llamada `gestion_documentos` con codificación `utf8mb4`.
3. Importe el archivo: `database/gestion_documentos.sql`
```sql
-- Desde línea de comando:
mysql -u root -p -e "CREATE DATABASE gestion_documentos CHARACTER SET utf8mb4;"
mysql -u root -p gestion_documentos < database/gestion_documentos.sql
```
---
## Paso 3: Configurar Variables de Entorno
1. Edite el archivo `.env` (ya creado desde `.env.example`):
```ini
DB_HOST=localhost
DB_NAME=gestion_documentos
DB_USER=root
DB_PASS=SU_CONTRASEÑA
APP_URL=http://localhost/ProyectoGestion
APP_SECRET_KEY=GENERE_UNA_CLAVE_ALEATORIA_DE_64_CARACTERES
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=su_correo@gmail.com
MAIL_PASSWORD=su_contraseña_de_aplicacion
INSTITUCION_NOMBRE=Nombre de su Institución
```
> ⚠️ **NUNCA** suba el archivo `.env` a Git ni repositorios públicos.
---
## Paso 4: Instalar Dependencias (Librerías)
Las librerías PHP van en la carpeta `lib/`. Descárguelas manualmente:
### PHPMailer (para correos)
```bash
# Con Composer:
composer require phpmailer/phpmailer
# O descargue desde: https://github.com/PHPMailer/PHPMailer/releases
# Extraiga en: lib/PHPMailer/
```
### DomPDF (para PDF)
```bash
# Con Composer:
composer require dompdf/dompdf
# O descargue desde: https://github.com/dompdf/dompdf/releases
# Extraiga en: lib/dompdf/
```
### PhpSpreadsheet (para Excel opcional)
```bash
composer require phpoffice/phpspreadsheet
```
---
## Paso 5: Permisos de Carpetas
En Linux/Mac, asigne permisos de escritura:
```bash
chmod -R 755 ProyectoGestion/
chmod -R 775 ProyectoGestion/uploads/
chmod -R 775 ProyectoGestion/exports/
chmod -R 775 ProyectoGestion/logs/
```
---
## Paso 6: Configurar el Cron Job (Alertas)
Para recibir alertas automáticas, configure una tarea programada:
**Linux (crontab):**
```bash
crontab -e
# Agregar esta línea (ejecuta a las 8 AM todos los días):
0 8 * * * php /ruta/completa/ProyectoGestion/cron/enviar_alertas.php
```
**Windows Task Scheduler:**
- Acción: `php.exe C:\xampp\htdocs\ProyectoGestion\cron\enviar_alertas.php`
- Activador: Diariamente a las 8:00 AM
**Prueba manual desde navegador:**
```
http://localhost/ProyectoGestion/cron/enviar_alertas.php?secret=SU_CLAVE_SECRETA
```
---
## Paso 7: Primer Acceso
Acceda a la URL del sistema:
```
http://localhost/ProyectoGestion/
```
**Credenciales de administrador por defecto:**
| Campo | Valor |
|---|---|
| **Usuario** | `admin` |
| **Contraseña** | `Admin@2026` |
> 🔐 **CAMBIAR LA CONTRASEÑA INMEDIATAMENTE** después del primer acceso.
> Ir a: Perfil → Cambiar Contraseña
---
## Paso 8: Configuración Inicial
1. Ir a **Usuarios** → Crear usuarios del sistema
2. Ir a **Alertas** → Configurar tiempos de notificación
3. Subir logo institucional en `assets/img/logo.png`
4. Actualizar `INSTITUCION_NOMBRE` en `.env`
---
## Estructura de Archivos Importantes
| Archivo | Descripción |
|---|---|
| `.env` | Configuración del sistema (NO subir a Git) |
| `database/gestion_documentos.sql` | Script SQL completo |
| `cron/enviar_alertas.php` | Script de alertas automáticas |
| `uploads/` | Documentos adjuntos (protegido) |
| `exports/` | Respaldos y exportaciones |
| `logs/` | Logs del sistema |
---
## Solución de Problemas Comunes
| Error | Solución |
|---|---|
| `Error de conexión a la BD` | Verificar credenciales en `.env` |
| `403 Forbidden en uploads` | Revisar `.htaccess` y permisos |
| `Correos no se envían` | Verificar credenciales SMTP, `MAIL_PASSWORD` debe ser "contraseña de app" si usa Gmail |
| `PDF no descarga` | Instalar librería DomPDF en `lib/dompdf/` |
| `Modo debug` | Cambiar `APP_DEBUG=true` en `.env` para ver errores |

184
README.md Normal file
View File

@ -0,0 +1,184 @@
## Proyecto: Sistema de Gestión de Oficios
Descripción
----------
Aplicación web para la gestión de oficios y trámites internos: registro, derivación, seguimiento, historial, etiquetas, alertas y reportes.
Stack tecnológico
------------------
- Backend: PHP (tipos estrictos, PDO) — recomendable PHP 7.4+ (8.x recomendado)
- Base de datos: MySQL / MariaDB
- Frontend: HTML, CSS y JavaScript (archivos en `assets/`)
- Persistencia: subidas en `uploads/` (archivos adjuntos)
- Cron jobs: `cron/enviar_alertas.php` para alertas y notificaciones
Modelos de datos (resumen)
--------------------------
Nota: las definiciones abajo se infieren de los modelos `models/Oficio.php` y `models/Usuario.php`.
- Oficio (tabla `oficios` / vista `v_oficios_completo`)
- id (int)
- numero_oficio (string) — formato: PREFIJO-AÑO-SECUENCIA (ej. REC-2026-0001)
- tipo (string) — recibido/enviado
- remitente (string)
- destinatario (string)
- asunto (string)
- descripcion (text, nullable)
- fecha_recepcion (date/datetime)
- fecha_vencimiento (date/datetime, nullable)
- prioridad (enum/string)
- estado (enum/string) — ej. recibido, en_proceso, respondido, archivado
- responsable_id (int, nullable) — FK a `usuarios.id`
- derivado_a_id (int, nullable)
- comentario_derivacion (text, nullable)
- creado_por (int)
- es_confidencial (boolean)
- deleted_at (nullable timestamp) — eliminación lógica
- created_at / updated_at (timestamps)
Relaciones y tablas auxiliares:
- `oficio_etiquetas` (oficio_id, etiqueta_id)
- `etiquetas` (id, nombre)
- `comentarios` (oficio_id, usuario_id, comentario, es_privado, created_at)
- `historial_cambios` (tabla, registro_id, accion, usuario_id, ip_address, detalle, created_at)
- Usuario (tabla `usuarios`)
- id (int)
- rol_id (int) — FK a `roles.id`
- nombre (string)
- apellido (string)
- email (string)
- username (string)
- password_hash (string)
- cargo (string, nullable)
- area (string, nullable)
- supervisor_id (int, nullable)
- activo (boolean)
- deleted_at (nullable timestamp)
- ultimo_login (datetime, nullable)
- token_recuperacion, token_expira (para recuperación de contraseña)
Tablas relacionadas:
- `roles` (id, nombre, permisos)
Ejemplo de contrato de datos (JSON)
---------------------------------
- Oficio (respuesta API / formato de formulario):
{
"numero_oficio": "REC-2026-0001",
"tipo": "recibido",
"remitente": "Oficina X",
"destinatario": "Dirección Y",
"asunto": "Solicitud de información",
"descripcion": "Detalle...",
"fecha_recepcion": "2026-06-01",
"fecha_vencimiento": "2026-06-10",
"prioridad": "alta",
"estado": "recibido",
"responsable_id": 3,
"etiquetas": [1,2],
"es_confidencial": 0
}
- Usuario (creación mínima):
{
"rol_id": 2,
"nombre": "Ana",
"apellido": "Pérez",
"email": "ana@example.com",
"username": "aperez",
"password": "(texto plano; se guardará hash)"
}
Requisitos previos
-------------------
- Servidor con PHP 7.4+ instalado (extensión PDO y pdo_mysql habilitadas).
- MySQL o MariaDB.
- Servidor web: Apache/Nginx o entorno WAMP/XAMPP/Laragon en Windows.
- Carpeta `uploads/` con permisos de escritura por el usuario del servidor web.
- Habilitar cron (Linux) o tarea programada (Windows Task Scheduler) para las alertas.
Uso del sistema (flujo general)
------------------------------
1. Acceder a la URL del sistema y autenticarse (`login.php`).
2. Desde el panel/dashboard ver KPIs y oficios próximos a vencer.
3. Crear un nuevo oficio (`oficios/crear.php`) o generar número automático con la función del modelo.
4. Derivar oficios a responsables, añadir comentarios y etiquetas.
5. Revisar historial y auditoría por oficio.
6. Recuperar contraseña mediante `recuperar_password.php`.
Módulos del sistema
--------------------
- Autenticación y autorización (`controllers/AuthController.php`, `login.php`, `logout.php`)
- Gestión de oficios (`controllers/OficioController.php`, vistas en `views/oficios/`)
- Usuarios y roles (`controllers/UsuarioController.php`, `models/Usuario.php`, vistas en `views/usuarios/`)
- Reportes (`controllers/ReporteController.php`, `views/reportes/`)
- Alertas/cron (`cron/enviar_alertas.php`)
- Exportes y descargas (`exports/`)
- Borrado lógico / papelera (`views/oficios/papelera.php`)
- Plantilla/layout compartido (`views/layout/`)
Seguridad
---------
- Contraseñas: se almacenan con password_hash (Bcrypt) según `models/Usuario.php`.
- Eliminación lógica: uso de `deleted_at` para evitar borrados accidentales.
- Historial de cambios: auditoría en `historial_cambios` para operaciones críticas.
- Recomendaciones adicionales:
- Implementar CSRF tokens en formularios.
- Escapar/validar salida para evitar XSS (especialmente `descripcion` y `comentarios`).
- Forzar HTTPS en producción y HSTS.
- Limitar tamaño/tipo de archivos subidos y almacenar fuera del webroot o en almacenamiento S3 con enlaces firmados.
- Hardenear sesiones (cookie secure, SameSite, regenerar id de sesión en login).
- Logging y alertas sobre intentos fallidos de login.
Instalación y configuración
---------------------------
1. Clonar o copiar el repositorio en el servidor web.
2. Crear una base de datos y ejecutar el SQL primario:
```powershell
# En PowerShell (ejemplo con mysql CLI)
mysql -u root -p nombre_base_de_datos < "e:\PROYECTOS\ProyectoGestion\database\gestion_documentos.sql"
```
3. Copiar y editar el archivo de configuración (si existe) `config/config.php` y actualizar credenciales de DB, base_url, ajustes de correo.
4. Asegurar que `uploads/` es escribible por el servidor web y que `logs/` existe para auditoría.
5. (Opcional) Configurar una tarea programada para alertas:
```powershell
# Ejecutar script de alerta desde PHP en Windows Task Scheduler o desde Linux cron
php "e:\PROYECTOS\ProyectoGestion\cron\enviar_alertas.php"
```
6. Acceder a la aplicación y crear el primer usuario administrador (o importar desde SQL si existe dump).
Posibles mejoras
----------------
- Añadir un sistema de migraciones (Phinx, Doctrine Migrations o laravel-migrations) para versiones de esquema.
- Preparar Dockerfile y docker-compose para entornos reproductibles.
- Añadir tests automatizados (PHPUnit) y linters (PHPStan/Psalm, PHPCS).
- Implementar API RESTful con autenticación basada en tokens (JWT) para integraciones.
- Mejorar seguridad: CSRF, CSP, pruebas de penetración, 2FA.
- Soporte para almacenamiento de archivos en la nube (S3) y vistas previas seguras.
- Sistema de auditoría más detallado y panel de administración de roles y permisos (RBAC fino).
Estado y cobertura de requisitos
--------------------------------
- título: Hecho
- descripción: Hecho
- stack tecnológico: Hecho
- modelos de datos: Hecho (resumen basado en `models/`)
- requisitos previos: Hecho
- uso del sistema: Hecho
- módulos del sistema: Hecho
- seguridad: Hecho (incluye recomendaciones)
- instalación y configuración: Hecho (pasos e import SQL)
- posibles mejoras: Hecho
Contacto y notas
----------------
Si necesitas que adapte el README a un formato distinto (más corto, versión en inglés, o agregar diagramas ER y ejemplos de requests), dímelo y lo preparo.

102
api/oficios.php Normal file
View File

@ -0,0 +1,102 @@
<?php
/**
* oficios.php API REST básica para oficios
* Autenticación: session cookie (misma sesión PHP) o Bearer token (futuro)
* Endpoints:
* GET /api/oficios.php lista (paginada, filtros)
* GET /api/oficios.php?id=N detalle
* POST /api/oficios.php crear
* PUT /api/oficios.php?id=N actualizar estado
* DELETE /api/oficios.php?id=N eliminación lógica
*/
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../models/Oficio.php';
iniciarSesion();
// ── Autenticación mínima ─────────────────────────────────────────────────────
if (empty($_SESSION['usuario_id'])) {
http_response_code(401);
echo json_encode(['error' => 'No autenticado.', 'code' => 401]);
exit();
}
$method = $_SERVER['REQUEST_METHOD'];
$model = new OficioModel();
$userId = (int)$_SESSION['usuario_id'];
$esAdmin = ($_SESSION['usuario_rol'] ?? '') === 'administrador';
// ── Routing ──────────────────────────────────────────────────────────────────
switch ($method) {
case 'GET':
$id = (int)($_GET['id'] ?? 0);
if ($id) {
$oficio = $model->buscarPorId($id);
if (!$oficio) { http_response_code(404); echo json_encode(['error'=>'No encontrado']); exit(); }
echo json_encode(['data' => $oficio, 'ok' => true]);
} else {
$filtros = [
'tipo' => $_GET['tipo'] ?? '',
'estado' => $_GET['estado'] ?? '',
'prioridad' => $_GET['prioridad'] ?? '',
'busqueda' => $_GET['busqueda'] ?? '',
'semaforo' => $_GET['semaforo'] ?? '',
];
$oficios = $model->listar($filtros, !$esAdmin, $userId);
echo json_encode(['data' => $oficios, 'total' => count($oficios), 'ok' => true]);
}
break;
case 'POST':
$body = json_decode(file_get_contents('php://input'), true) ?? $_POST;
$datos = [
'numero_oficio' => clean($body['numero_oficio'] ?? $model->generarNumero($body['tipo'] ?? 'recibido')),
'tipo' => clean($body['tipo'] ?? 'recibido'),
'remitente' => clean($body['remitente'] ?? ''),
'destinatario' => clean($body['destinatario'] ?? ''),
'asunto' => clean($body['asunto'] ?? ''),
'descripcion' => clean($body['descripcion'] ?? ''),
'fecha_recepcion' => clean($body['fecha_recepcion'] ?? date('Y-m-d')),
'fecha_vencimiento' => clean($body['fecha_vencimiento'] ?? ''),
'prioridad' => clean($body['prioridad'] ?? 'media'),
'estado' => clean($body['estado'] ?? 'recibido'),
'responsable_id' => (int)($body['responsable_id'] ?? 0),
'es_confidencial' => (int)($body['es_confidencial'] ?? 0),
'etiquetas' => $body['etiquetas'] ?? [],
];
if (empty($datos['remitente']) || empty($datos['asunto'])) {
http_response_code(422);
echo json_encode(['error' => 'Remitente y asunto son requeridos.']);
break;
}
$id = $model->crear($datos, $userId);
http_response_code(201);
echo json_encode(['data' => ['id' => $id], 'ok' => true]);
break;
case 'PUT':
$id = (int)($_GET['id'] ?? 0);
$body = json_decode(file_get_contents('php://input'), true) ?? [];
if (!$id) { http_response_code(400); echo json_encode(['error'=>'ID requerido']); break; }
$oficio = $model->buscarPorId($id);
if (!$oficio) { http_response_code(404); echo json_encode(['error'=>'No encontrado']); break; }
$datos = array_merge($oficio, $body);
$model->actualizar($id, $datos, $userId);
echo json_encode(['ok' => true, 'message' => 'Oficio actualizado.']);
break;
case 'DELETE':
$id = (int)($_GET['id'] ?? 0);
if (!$id) { http_response_code(400); echo json_encode(['error'=>'ID requerido']); break; }
$model->eliminarLogico($id, $userId);
echo json_encode(['ok' => true, 'message' => 'Oficio eliminado (lógicamente).']);
break;
default:
http_response_code(405);
echo json_encode(['error' => 'Método no permitido.']);
}

1477
assets/css/style.css Normal file

File diff suppressed because it is too large Load Diff

BIN
assets/img/login_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

216
assets/js/app.js Normal file
View File

@ -0,0 +1,216 @@
/**
* app.js JavaScript global del sistema
* Bootstrap para todos los módulos UI
*/
'use strict';
// ── Tema oscuro / claro ───────────────────────────────────────────────────────
const html = document.documentElement;
const themeBtn = document.getElementById('themeToggle');
const themeIcon = document.getElementById('themeIcon');
function applyTheme(theme) {
html.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
if (themeIcon) {
themeIcon.className = theme === 'dark' ? 'fa-solid fa-sun' : 'fa-solid fa-moon';
}
// Sincronizar con Chart.js si hay gráficos
if (window.Chart) {
Chart.defaults.color = theme === 'dark' ? '#94a3b8' : '#64748b';
Chart.defaults.borderColor = theme === 'dark' ? '#334155' : '#e2e8f0';
}
}
// Cargar tema al iniciar
const savedTheme = localStorage.getItem('theme') || html.getAttribute('data-theme') || 'light';
applyTheme(savedTheme);
if (themeBtn) {
themeBtn.addEventListener('click', () => {
const current = html.getAttribute('data-theme');
applyTheme(current === 'dark' ? 'light' : 'dark');
// Guardar en servidor vía AJAX
fetch('?action=set_theme&theme=' + html.getAttribute('data-theme'), { method: 'POST' }).catch(() => {});
});
}
// ── Sidebar toggle (móvil) ────────────────────────────────────────────────────
const sidebar = document.getElementById('sidebar');
const sidebarBtn = document.getElementById('sidebarToggle');
const overlay = document.getElementById('sidebarOverlay');
if (sidebarBtn && sidebar) {
sidebarBtn.addEventListener('click', () => {
sidebar.classList.toggle('open');
});
}
// Click fuera para cerrar sidebar en móvil
document.addEventListener('click', (e) => {
if (window.innerWidth < 768 && sidebar && !sidebar.contains(e.target) && e.target !== sidebarBtn) {
sidebar.classList.remove('open');
}
});
// ── Notificaciones dropdown ───────────────────────────────────────────────────
const notifBtn = document.getElementById('notifToggle');
const notifDropdown = document.getElementById('notifDropdown');
if (notifBtn && notifDropdown) {
notifBtn.addEventListener('click', (e) => {
e.stopPropagation();
notifDropdown.classList.toggle('show');
});
document.addEventListener('click', () => {
notifDropdown.classList.remove('show');
});
}
// ── Búsqueda global ───────────────────────────────────────────────────────────
const searchToggle = document.getElementById('toggleSearch');
const searchWrap = document.getElementById('globalSearchWrap');
const searchInput = document.getElementById('globalSearch');
if (searchToggle && searchWrap) {
searchToggle.addEventListener('click', () => {
const isVisible = searchWrap.style.display !== 'none';
searchWrap.style.display = isVisible ? 'none' : 'flex';
if (!isVisible) searchInput.focus();
});
searchInput && searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const q = searchInput.value.trim();
if (q) {
window.location.href = (window.appUrl || '') + '/views/oficios/lista.php?busqueda=' + encodeURIComponent(q);
}
}
if (e.key === 'Escape') {
searchWrap.style.display = 'none';
}
});
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
const container = this.closest('[data-tabs]') || document.body;
container.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
container.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
this.classList.add('active');
const target = document.getElementById(this.dataset.tab);
if (target) target.classList.add('active');
});
});
// ── Modales ───────────────────────────────────────────────────────────────────
document.querySelectorAll('[data-modal]').forEach(btn => {
btn.addEventListener('click', function() {
const modal = document.getElementById(this.dataset.modal);
if (modal) modal.classList.add('show');
});
});
document.querySelectorAll('[data-modal-close]').forEach(btn => {
btn.addEventListener('click', function() {
this.closest('.modal-overlay').classList.remove('show');
});
});
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', function(e) {
if (e.target === this) this.classList.remove('show');
});
});
// ── Toast notifications ───────────────────────────────────────────────────────
function showToast(message, type = 'success', duration = 4000) {
const container = document.getElementById('toastContainer');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type}`;
const icons = { success: 'fa-check-circle', error: 'fa-circle-xmark', warn: 'fa-triangle-exclamation' };
const colors = { success: '#10b981', error: '#ef4444', warn: '#f59e0b' };
toast.innerHTML = `
<i class="fa-solid ${icons[type] || icons.success}" style="color:${colors[type]};font-size:1.1rem;flex-shrink:0"></i>
<span style="flex:1">${message}</span>
<button onclick="this.parentElement.remove()" style="background:none;border:none;cursor:pointer;color:var(--text-muted);font-size:.9rem">
<i class="fa-solid fa-xmark"></i>
</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
// Leer flash messages desde la URL
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('success')) showToast(decodeURIComponent(urlParams.get('success')));
if (urlParams.get('error')) showToast(decodeURIComponent(urlParams.get('error')), 'error');
// ── Confirmación de eliminación ───────────────────────────────────────────────
document.querySelectorAll('[data-confirm]').forEach(el => {
el.addEventListener('click', function(e) {
if (!confirm(this.dataset.confirm || '¿Confirma esta acción?')) {
e.preventDefault();
}
});
});
// ── DataTables globales ───────────────────────────────────────────────────────
function initDataTable(selector, opts = {}) {
if (typeof $ === 'undefined' || !$.fn.DataTable) return;
const defaults = {
language: {
url: 'https://cdn.datatables.net/plug-ins/1.13.8/i18n/es-ES.json'
},
responsive: true,
pageLength: 25,
order: [],
};
return $(selector).DataTable({ ...defaults, ...opts });
}
// ── Dropzone ──────────────────────────────────────────────────────────────────
document.querySelectorAll('.dropzone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (input && e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
zone.querySelector('.dropzone-label') && (zone.querySelector('.dropzone-label').textContent = `${e.dataTransfer.files.length} archivo(s) seleccionado(s)`);
}
});
zone.addEventListener('click', () => input && input.click());
});
// ── Autocompletar número de oficio ────────────────────────────────────────────
const tipoSelect = document.getElementById('tipo');
const numOficio = document.getElementById('numero_oficio');
const autoGenBtn = document.getElementById('autoGenNumero');
if (tipoSelect && autoGenBtn) {
autoGenBtn.addEventListener('click', () => {
const tipo = tipoSelect.value;
fetch(`${window.appUrl}/api/oficios.php?action=gen_numero&tipo=${tipo}`)
.then(r => r.json())
.then(d => { if (numOficio && d.numero) numOficio.value = d.numero; });
});
}
// ── Contador de caracteres en textarea ───────────────────────────────────────
document.querySelectorAll('textarea[data-maxlen]').forEach(ta => {
const max = parseInt(ta.dataset.maxlen);
const counter = document.createElement('div');
counter.className = 'form-text text-end';
counter.style.marginTop = '.25rem';
ta.parentNode.insertBefore(counter, ta.nextSibling);
const update = () => counter.textContent = `${ta.value.length} / ${max}`;
ta.addEventListener('input', update);
update();
});

161
config/config.php Normal file
View File

@ -0,0 +1,161 @@
<?php
/**
* config.php Configuración principal del sistema
* Carga variables desde .env y proporciona constantes globales
*/
declare(strict_types=1);
// ── Carga del archivo .env ────────────────────────────────────────────────────
function loadEnv(string $path): void {
if (!file_exists($path)) return;
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
$line = trim($line);
if (str_starts_with($line, '#') || !str_contains($line, '=')) continue;
[$key, $value] = explode('=', $line, 2);
$key = trim($key);
$value = trim($value, " \t\n\r\"'");
if (!array_key_exists($key, $_SERVER) && !array_key_exists($key, $_ENV)) {
putenv("$key=$value");
$_ENV[$key] = $value;
$_SERVER[$key] = $value;
}
}
}
$envFile = dirname(__DIR__) . '/.env';
if (!file_exists($envFile)) {
$envFile = dirname(__DIR__) . '/.env.example';
}
loadEnv($envFile);
// ── Helper para leer .env ─────────────────────────────────────────────────────
function env(string $key, mixed $default = null): mixed {
return $_ENV[$key] ?? getenv($key) ?: $default;
}
// ── Constantes de aplicación ──────────────────────────────────────────────────
define('APP_NAME', env('APP_NAME', 'Sistema de Gestión'));
define('APP_URL', env('APP_URL', 'http://localhost/ProyectoGestion'));
define('APP_ENV', env('APP_ENV', 'production'));
define('APP_DEBUG', env('APP_DEBUG', false));
define('APP_TIMEZONE', env('APP_TIMEZONE', 'America/Caracas'));
define('APP_SECRET', env('APP_SECRET_KEY', 'change_me_in_env'));
define('BASE_PATH', dirname(__DIR__));
define('UPLOAD_PATH', BASE_PATH . '/' . env('UPLOAD_PATH', 'uploads/'));
define('EXPORT_PATH', BASE_PATH . '/exports/');
define('LOG_PATH', BASE_PATH . '/logs/');
define('SESSION_TIMEOUT', (int)env('SESSION_TIMEOUT', 3600));
define('SESSION_NAME', env('SESSION_NAME', 'gestion_doc_session'));
define('CSRF_TOKEN_NAME', env('CSRF_TOKEN_NAME', '_csrf_token'));
define('UPLOAD_MAX_SIZE', (int)env('UPLOAD_MAX_SIZE', 10485760));
define('UPLOAD_ALLOWED_TYPES', explode(',', env('UPLOAD_ALLOWED_TYPES', 'pdf,doc,docx,jpg,jpeg,png')));
define('INSTITUCION_NOMBRE', env('INSTITUCION_NOMBRE', 'Institución'));
define('INSTITUCION_CARGO', env('INSTITUCION_CARGO_FIRMA', 'Director(a)'));
define('LOGO_PATH', BASE_PATH . '/' . env('LOGO_PATH', 'assets/img/logo.png'));
// ── Zona horaria ──────────────────────────────────────────────────────────────
date_default_timezone_set(APP_TIMEZONE);
// ── Manejo de errores ─────────────────────────────────────────────────────────
if (APP_DEBUG === 'true' || APP_DEBUG === true) {
ini_set('display_errors', '1');
error_reporting(E_ALL);
} else {
ini_set('display_errors', '0');
error_reporting(0);
}
// ── Conexión a la base de datos (PDO) ────────────────────────────────────────
function getDB(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4',
env('DB_HOST', 'localhost'),
env('DB_PORT', '3306'),
env('DB_NAME', 'gestion_documentos')
);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci",
];
try {
$pdo = new PDO($dsn, env('DB_USER', 'root'), env('DB_PASS', ''), $options);
} catch (PDOException $e) {
if (APP_DEBUG) {
die('Error de conexión: ' . $e->getMessage());
} else {
die('Error de conexión a la base de datos. Contacte al administrador.');
}
}
}
return $pdo;
}
// ── Inicialización de sesión ──────────────────────────────────────────────────
function iniciarSesion(): void {
if (session_status() === PHP_SESSION_NONE) {
session_name(SESSION_NAME);
session_set_cookie_params([
'lifetime' => 0,
'path' => '/',
'secure' => isset($_SERVER['HTTPS']),
'httponly' => true,
'samesite' => 'Strict',
]);
session_start();
}
// Regenerar ID de sesión periódicamente
if (!isset($_SESSION['_last_regen'])) {
session_regenerate_id(true);
$_SESSION['_last_regen'] = time();
} elseif (time() - $_SESSION['_last_regen'] > 300) {
session_regenerate_id(true);
$_SESSION['_last_regen'] = time();
}
}
// ── CSRF ──────────────────────────────────────────────────────────────────────
function csrfToken(): string {
iniciarSesion();
if (empty($_SESSION[CSRF_TOKEN_NAME])) {
$_SESSION[CSRF_TOKEN_NAME] = bin2hex(random_bytes(32));
}
return $_SESSION[CSRF_TOKEN_NAME];
}
function csrfField(): string {
return '<input type="hidden" name="' . CSRF_TOKEN_NAME . '" value="' . htmlspecialchars(csrfToken()) . '">';
}
function verificarCsrf(): void {
$token = $_POST[CSRF_TOKEN_NAME] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION[CSRF_TOKEN_NAME] ?? '', $token)) {
http_response_code(403);
die(json_encode(['error' => 'Token CSRF inválido.']));
}
}
// ── Sanitización ──────────────────────────────────────────────────────────────
function clean(mixed $value): string {
return htmlspecialchars(trim((string)$value), ENT_QUOTES, 'UTF-8');
}
function redirect(string $url): never {
header("Location: $url");
exit();
}
function jsonResponse(mixed $data, int $code = 200): never {
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit();
}

View File

@ -0,0 +1,223 @@
<?php
/**
* AuthController.php Gestión de autenticación de usuarios
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../models/Usuario.php';
class AuthController {
private PDO $db;
private UsuarioModel $usuarioModel;
public function __construct() {
$this->db = getDB();
$this->usuarioModel = new UsuarioModel();
iniciarSesion();
}
// ── Login ─────────────────────────────────────────────────────────────────
public function login(): void {
if ($this->estaAutenticado()) {
redirect(APP_URL . '/dashboard.php');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf();
$username = clean($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
if (empty($username) || empty($password)) {
$_SESSION['error_login'] = 'Complete todos los campos.';
redirect(APP_URL . '/login.php');
}
$usuario = $this->usuarioModel->buscarPorUsername($username);
if (!$usuario || !$usuario['activo'] || $usuario['deleted_at']) {
$_SESSION['error_login'] = 'Credenciales inválidas o cuenta inactiva.';
$this->registrarLog(null, 'login_fallido', 'auth', "Username: $username");
redirect(APP_URL . '/login.php');
}
if (!password_verify($password, $usuario['password_hash'])) {
$_SESSION['error_login'] = 'Credenciales inválidas.';
$this->registrarLog(null, 'login_fallido', 'auth', "Username: $username");
redirect(APP_URL . '/login.php');
}
// Actualizar último login
$this->usuarioModel->actualizarUltimoLogin($usuario['id']);
// Establecer sesión
$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['usuario_nombre'] = $usuario['nombre'] . ' ' . $usuario['apellido'];
$_SESSION['usuario_email'] = $usuario['email'];
$_SESSION['usuario_rol'] = $usuario['rol_nombre'];
$_SESSION['usuario_rol_id'] = $usuario['rol_id'];
$_SESSION['permisos'] = json_decode($usuario['permisos'] ?? '{}', true);
$_SESSION['login_time'] = time();
$_SESSION['tema'] = $usuario['tema'] ?? 'light';
$this->registrarLog($usuario['id'], 'login_exitoso', 'auth', 'Inicio de sesión.');
redirect(APP_URL . '/dashboard.php');
}
}
// ── Logout ────────────────────────────────────────────────────────────────
public function logout(): void {
iniciarSesion();
$userId = $_SESSION['usuario_id'] ?? null;
$this->registrarLog($userId, 'logout', 'auth', 'Cierre de sesión.');
session_destroy();
redirect(APP_URL . '/login.php?msg=logout');
}
// ── Verificar Autenticación ───────────────────────────────────────────────
public function estaAutenticado(): bool {
iniciarSesion();
if (empty($_SESSION['usuario_id'])) return false;
if (time() - ($_SESSION['login_time'] ?? 0) > SESSION_TIMEOUT) {
session_destroy();
return false;
}
return true;
}
// ── Verificar rol / permiso ───────────────────────────────────────────────
public static function esAdmin(): bool {
return ($_SESSION['usuario_rol'] ?? '') === 'administrador';
}
public static function tienePermiso(string $permiso): bool {
$permisos = $_SESSION['permisos'] ?? [];
return !empty($permisos[$permiso]);
}
// ── Proteger rutas (llamar al inicio de cada página protegida) ─────────────
public static function requerirAuth(): void {
iniciarSesion();
if (empty($_SESSION['usuario_id'])) {
redirect(APP_URL . '/login.php?msg=sesion_expirada');
}
if (time() - ($_SESSION['login_time'] ?? 0) > SESSION_TIMEOUT) {
session_destroy();
redirect(APP_URL . '/login.php?msg=sesion_expirada');
}
// Renovar tiempo de sesión
$_SESSION['login_time'] = time();
}
public static function requerirAdmin(): void {
self::requerirAuth();
if (($_SESSION['usuario_rol'] ?? '') !== 'administrador') {
http_response_code(403);
include __DIR__ . '/../views/errors/403.php';
exit();
}
}
// ── Recuperación de contraseña ────────────────────────────────────────────
public function solicitarRecuperacion(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
verificarCsrf();
$email = clean($_POST['email'] ?? '');
$usuario = $this->usuarioModel->buscarPorEmail($email);
if ($usuario) {
$token = bin2hex(random_bytes(32));
$expira = date('Y-m-d H:i:s', time() + 3600);
$this->usuarioModel->guardarTokenRecuperacion($usuario['id'], $token, $expira);
$enlace = APP_URL . '/recuperar_password.php?token=' . $token;
$this->enviarCorreoRecuperacion($email, $usuario['nombre'], $enlace);
$this->registrarLog($usuario['id'], 'recuperacion_solicitada', 'auth');
}
// Siempre mostrar el mismo mensaje por seguridad
$_SESSION['info_recuperacion'] = 'Si el email existe, recibirás un enlace de recuperación.';
redirect(APP_URL . '/login.php?msg=recuperacion');
}
public function restablecerPassword(): void {
if ($_SERVER['REQUEST_METHOD'] !== 'POST') return;
verificarCsrf();
$token = clean($_POST['token'] ?? '');
$nuevaPass = $_POST['password'] ?? '';
$confirmPass = $_POST['password_confirm'] ?? '';
if ($nuevaPass !== $confirmPass || strlen($nuevaPass) < 8) {
$_SESSION['error_reset'] = 'Las contraseñas no coinciden o tienen menos de 8 caracteres.';
redirect(APP_URL . '/recuperar_password.php?token=' . urlencode($token));
}
$usuario = $this->usuarioModel->buscarPorToken($token);
if (!$usuario || strtotime($usuario['token_expira']) < time()) {
$_SESSION['error_reset'] = 'El enlace ha expirado o es inválido.';
redirect(APP_URL . '/recuperar_password.php');
}
$hash = password_hash($nuevaPass, PASSWORD_BCRYPT, ['cost' => 12]);
$this->usuarioModel->actualizarPassword($usuario['id'], $hash);
$this->registrarLog($usuario['id'], 'password_restablecido', 'auth');
$_SESSION['success_login'] = 'Contraseña restablecida. Ya puedes iniciar sesión.';
redirect(APP_URL . '/login.php');
}
// ── Envío de correo de recuperación ──────────────────────────────────────
private function enviarCorreoRecuperacion(string $email, string $nombre, string $enlace): void {
$libPath = BASE_PATH . '/lib/PHPMailer/src/';
if (!file_exists($libPath . 'PHPMailer.php')) return;
require_once $libPath . 'PHPMailer.php';
require_once $libPath . 'SMTP.php';
require_once $libPath . 'Exception.php';
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
try {
$mail->isSMTP();
$mail->Host = env('MAIL_HOST');
$mail->SMTPAuth = true;
$mail->Username = env('MAIL_USERNAME');
$mail->Password = env('MAIL_PASSWORD');
$mail->SMTPSecure = env('MAIL_ENCRYPTION', 'tls');
$mail->Port = (int)env('MAIL_PORT', 587);
$mail->setFrom(env('MAIL_FROM_ADDRESS'), env('MAIL_FROM_NAME'));
$mail->addAddress($email, $nombre);
$mail->isHTML(true);
$mail->CharSet = 'UTF-8';
$mail->Subject = 'Recuperación de contraseña ' . APP_NAME;
$mail->Body = "
<p>Hola, <strong>$nombre</strong>:</p>
<p>Haz clic en el siguiente enlace para restablecer tu contraseña (válido por 1 hora):</p>
<p><a href=\"$enlace\">$enlace</a></p>
<p>Si no solicitaste esto, ignora este mensaje.</p>
";
$mail->send();
} catch (Exception $e) {
// Log silencioso no revelar al usuario
error_log('Mailer error: ' . $mail->ErrorInfo);
}
}
// ── Registro de log ───────────────────────────────────────────────────────
private function registrarLog(?int $userId, string $accion, string $modulo, ?string $desc = null): void {
try {
$stmt = $this->db->prepare(
"INSERT INTO log_actividad (usuario_id, accion, modulo, descripcion, ip_address, user_agent)
VALUES (?, ?, ?, ?, ?, ?)"
);
$stmt->execute([
$userId, $accion, $modulo, $desc,
$_SERVER['REMOTE_ADDR'] ?? null,
$_SERVER['HTTP_USER_AGENT'] ?? null,
]);
} catch (PDOException) {}
}
}

View File

@ -0,0 +1,236 @@
<?php
/**
* OficioController.php Controlador de oficios (acciones POST/GET)
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../controllers/AuthController.php';
require_once __DIR__ . '/../models/Oficio.php';
require_once __DIR__ . '/../models/Usuario.php';
AuthController::requerirAuth();
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$model = new OficioModel();
$esAdmin = AuthController::esAdmin();
$userId = (int)$_SESSION['usuario_id'];
switch ($action) {
// ── CREAR ──────────────────────────────────────────────────────────────
case 'crear':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/oficios/crear.php');
verificarCsrf();
$datos = [
'numero_oficio' => clean($_POST['numero_oficio'] ?? ''),
'tipo' => clean($_POST['tipo'] ?? 'recibido'),
'remitente' => clean($_POST['remitente'] ?? ''),
'destinatario' => clean($_POST['destinatario'] ?? ''),
'asunto' => clean($_POST['asunto'] ?? ''),
'descripcion' => clean($_POST['descripcion'] ?? ''),
'fecha_recepcion' => clean($_POST['fecha_recepcion'] ?? ''),
'fecha_vencimiento' => clean($_POST['fecha_vencimiento'] ?? ''),
'prioridad' => clean($_POST['prioridad'] ?? 'media'),
'estado' => clean($_POST['estado'] ?? 'recibido'),
'responsable_id' => (int)($_POST['responsable_id'] ?? 0),
'es_confidencial' => isset($_POST['es_confidencial']) ? 1 : 0,
'etiquetas' => $_POST['etiquetas'] ?? [],
];
$errores = validarOficio($datos, $model);
if (!empty($errores)) {
$_SESSION['errores_form'] = $errores;
$_SESSION['datos_form'] = $datos;
redirect(APP_URL.'/views/oficios/crear.php');
}
$id = $model->crear($datos, $userId);
// Subir adjuntos
subirAdjuntos($id, $userId);
// Crear notificación al responsable
if ($datos['responsable_id']) {
crearNotificacion($datos['responsable_id'], $id, 'sistema',
'Nuevo oficio asignado',
"Se te ha asignado el oficio {$datos['numero_oficio']}: {$datos['asunto']}");
}
logActividad($userId, 'crear_oficio', 'oficios', "Oficio #{$datos['numero_oficio']} creado.");
redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&success='.urlencode('Oficio creado exitosamente.'));
// ── ACTUALIZAR ─────────────────────────────────────────────────────────
case 'actualizar':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/oficios/lista.php');
verificarCsrf();
$id = (int)($_POST['id'] ?? 0);
if (!$id) redirect(APP_URL.'/views/oficios/lista.php');
$datos = [
'numero_oficio' => clean($_POST['numero_oficio'] ?? ''),
'tipo' => clean($_POST['tipo'] ?? 'recibido'),
'remitente' => clean($_POST['remitente'] ?? ''),
'destinatario' => clean($_POST['destinatario'] ?? ''),
'asunto' => clean($_POST['asunto'] ?? ''),
'descripcion' => clean($_POST['descripcion'] ?? ''),
'fecha_recepcion' => clean($_POST['fecha_recepcion'] ?? ''),
'fecha_vencimiento' => clean($_POST['fecha_vencimiento'] ?? ''),
'prioridad' => clean($_POST['prioridad'] ?? 'media'),
'estado' => clean($_POST['estado'] ?? 'recibido'),
'responsable_id' => (int)($_POST['responsable_id'] ?? 0),
'es_confidencial' => isset($_POST['es_confidencial']) ? 1 : 0,
'etiquetas' => $_POST['etiquetas'] ?? [],
];
$errores = validarOficio($datos, $model, $id);
if (!empty($errores)) {
$_SESSION['errores_form'] = $errores;
$_SESSION['datos_form'] = $datos;
redirect(APP_URL.'/views/oficios/editar.php?id='.$id);
}
$model->actualizar($id, $datos, $userId);
subirAdjuntos($id, $userId);
logActividad($userId, 'editar_oficio', 'oficios', "Oficio #$id actualizado.");
redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&success='.urlencode('Oficio actualizado exitosamente.'));
// ── ELIMINAR (lógico) ──────────────────────────────────────────────────
case 'eliminar':
verificarCsrf();
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
if (!$id) redirect(APP_URL.'/views/oficios/lista.php');
$model->eliminarLogico($id, $userId);
logActividad($userId, 'eliminar_oficio', 'oficios', "Oficio #$id movido a papelera.");
redirect(APP_URL.'/views/oficios/lista.php?success='.urlencode('Oficio movido a la papelera.'));
// ── ELIMINAR FÍSICO (solo admin) ───────────────────────────────────────
case 'eliminar_fisico':
AuthController::requerirAdmin();
verificarCsrf();
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
if (!$id) redirect(APP_URL.'/views/oficios/papelera.php');
$model->eliminarFisico($id);
logActividad($userId, 'eliminar_fisico_oficio', 'oficios', "Oficio #$id eliminado permanentemente.");
redirect(APP_URL.'/views/oficios/papelera.php?success='.urlencode('Oficio eliminado permanentemente.'));
// ── RESTAURAR ─────────────────────────────────────────────────────────
case 'restaurar':
AuthController::requerirAdmin();
verificarCsrf();
$id = (int)($_GET['id'] ?? $_POST['id'] ?? 0);
if (!$id) redirect(APP_URL.'/views/oficios/papelera.php');
$model->restaurar($id, $userId);
logActividad($userId, 'restaurar_oficio', 'oficios', "Oficio #$id restaurado.");
redirect(APP_URL.'/views/oficios/papelera.php?success='.urlencode('Oficio restaurado exitosamente.'));
// ── DERIVAR ───────────────────────────────────────────────────────────
case 'derivar':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/oficios/lista.php');
verificarCsrf();
$id = (int)($_POST['id'] ?? 0);
$nuevoResponsable= (int)($_POST['nuevo_responsable'] ?? 0);
$comentario = clean($_POST['comentario_derivacion'] ?? '');
if (!$id || !$nuevoResponsable) redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&error='.urlencode('Datos incompletos para la derivación.'));
$model->derivar($id, $nuevoResponsable, $userId, $comentario);
crearNotificacion($nuevoResponsable, $id, 'derivacion',
'Oficio derivado hacia ti',
"Se te ha derivado el oficio: $comentario");
logActividad($userId, 'derivar_oficio', 'oficios', "Oficio #$id derivado a usuario #$nuevoResponsable.");
redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&success='.urlencode('Oficio derivado exitosamente.'));
// ── COMENTARIO ────────────────────────────────────────────────────────
case 'comentar':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/oficios/lista.php');
verificarCsrf();
$id = (int)($_POST['oficio_id'] ?? 0);
$texto = clean($_POST['comentario'] ?? '');
$privado = isset($_POST['es_privado']);
if (!$id || strlen($texto) < 2) redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&error='.urlencode('El comentario no puede estar vacío.'));
$model->agregarComentario($id, $userId, $texto, $privado);
redirect(APP_URL.'/views/oficios/detalle.php?id='.$id.'&success='.urlencode('Comentario agregado.'));
// ── GENERAR NÚMERO ────────────────────────────────────────────────────
case 'gen_numero':
$tipo = clean($_GET['tipo'] ?? 'recibido');
jsonResponse(['numero' => $model->generarNumero($tipo)]);
// ── MARCAR NOTIFICACIÓN LEÍDA ─────────────────────────────────────────
case 'marcar_notif':
$nId = (int)($_GET['id'] ?? 0);
if ($nId) {
$db = getDB();
$db->prepare("UPDATE notificaciones SET leida=1,leida_at=NOW() WHERE id=? AND usuario_id=?")
->execute([$nId, $userId]);
}
redirect(APP_URL.'/views/oficios/detalle.php?id='.($_GET['oficio'] ?? 0));
// ── PDF ───────────────────────────────────────────────────────────────
case 'pdf':
$id = (int)($_GET['id'] ?? 0);
$datos = $model->buscarPorId($id);
if (!$dados = $datos) redirect(APP_URL.'/views/oficios/lista.php');
require_once __DIR__ . '/../controllers/ReporteController.php';
ReporteController::generarPdfOficio($datos);
break;
default:
redirect(APP_URL.'/views/oficios/lista.php');
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function validarOficio(array $datos, OficioModel $model, ?int $excepto = null): array {
$errores = [];
if (empty($datos['numero_oficio'])) $errores[] = 'El número de oficio es requerido.';
elseif ($model->existeNumero($datos['numero_oficio'], $excepto)) $errores[] = 'El número de oficio ya existe en el sistema.';
if (empty($datos['remitente'])) $errores[] = 'El remitente es requerido.';
if (empty($datos['destinatario'])) $errores[] = 'El destinatario es requerido.';
if (empty($datos['asunto'])) $errores[] = 'El asunto es requerido.';
if (empty($datos['fecha_recepcion'])) $errores[] = 'La fecha de recepción es requerida.';
if (!in_array($datos['tipo'], ['recibido','enviado'])) $errores[] = 'Tipo inválido.';
if (!in_array($datos['prioridad'], ['alta','media','baja'])) $errores[] = 'Prioridad inválida.';
if (!in_array($datos['estado'], ['recibido','en_proceso','respondido','vencido','archivado'])) $errores[] = 'Estado inválido.';
return $errores;
}
function subirAdjuntos(int $oficioId, int $userId): void {
if (empty($_FILES['adjuntos']['name'][0])) return;
$db = getDB();
$count = count($_FILES['adjuntos']['name']);
for ($i = 0; $i < $count; $i++) {
if ($_FILES['adjuntos']['error'][$i] !== UPLOAD_ERR_OK) continue;
$originalName = $_FILES['adjuntos']['name'][$i];
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
if (!in_array($ext, UPLOAD_ALLOWED_TYPES)) continue;
if ($_FILES['adjuntos']['size'][$i] > UPLOAD_MAX_SIZE) continue;
$safeName = date('Ymd_His') . '_' . bin2hex(random_bytes(6)) . '.' . $ext;
$destDir = UPLOAD_PATH . 'oficios/' . $oficioId . '/';
if (!is_dir($destDir)) mkdir($destDir, 0755, true);
if (move_uploaded_file($_FILES['adjuntos']['tmp_name'][$i], $destDir . $safeName)) {
$db->prepare(
"INSERT INTO documentos_adjuntos (oficio_id,nombre_original,nombre_archivo,ruta,tipo_mime,tamanio,subido_por)
VALUES (?,?,?,?,?,?,?)"
)->execute([$oficioId, $originalName, $safeName, 'oficios/'.$oficioId.'/'.$safeName, $_FILES['adjuntos']['type'][$i], $_FILES['adjuntos']['size'][$i], $userId]);
}
}
}
function crearNotificacion(int $usuarioId, ?int $oficioId, string $tipo, string $titulo, string $mensaje): void {
$db = getDB();
$db->prepare(
"INSERT INTO notificaciones (usuario_id,oficio_id,tipo,titulo,mensaje) VALUES (?,?,?,?,?)"
)->execute([$usuarioId, $oficioId, $tipo, $titulo, $mensaje]);
}
function logActividad(int $userId, string $accion, string $modulo, string $desc = ''): void {
$db = getDB();
$db->prepare(
"INSERT INTO log_actividad (usuario_id,accion,modulo,descripcion,ip_address,user_agent) VALUES (?,?,?,?,?,?)"
)->execute([$userId, $accion, $modulo, $desc, $_SERVER['REMOTE_ADDR']??null, $_SERVER['HTTP_USER_AGENT']??null]);
}

View File

@ -0,0 +1,199 @@
<?php
/**
* ReporteController.php Exportación de PDF e informes
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../controllers/AuthController.php';
require_once __DIR__ . '/../models/Oficio.php';
class ReporteController {
/**
* Genera PDF de un oficio individual usando DomPDF si está disponible,
* o HTML simple de fallback
*/
public static function generarPdfOficio(array $oficio): void {
$dompdfPath = BASE_PATH . '/lib/dompdf/autoload.inc.php';
$html = self::htmlOficio($oficio);
if (file_exists($dompdfPath)) {
require_once $dompdfPath;
$options = new \Dompdf\Options();
$options->set('defaultFont', 'DejaVu Sans');
$options->setIsHtml5ParserEnabled(true);
$options->setIsFontSubsettingEnabled(true);
$dompdf = new \Dompdf\Dompdf($options);
$dompdf->loadHtml($html);
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
$filename = 'Oficio_' . preg_replace('/[^A-Za-z0-9\-_]/', '_', $oficio['numero_oficio']) . '_' . date('Ymd') . '.pdf';
$dompdf->stream($filename, ['Attachment' => true]);
} else {
// Fallback: HTML descargable
header('Content-Type: text/html; charset=utf-8');
header('Content-Disposition: attachment; filename="oficio_' . $oficio['id'] . '.html"');
echo $html;
}
exit();
}
/**
* Genera HTML del oficio para PDF
*/
private static function htmlOficio(array $o): string {
$logo = file_exists(LOGO_PATH) ? '<img src="' . LOGO_PATH . '" style="max-height:60px">' : '';
$fecha = date('d/m/Y H:i');
$vence = $o['fecha_vencimiento'] ? date('d/m/Y', strtotime($o['fecha_vencimiento'])) : 'Sin fecha';
$recep = date('d/m/Y', strtotime($o['fecha_recepcion']));
$etiquetas = $o['etiquetas'] ?? '—';
$colorPrioridad = ['alta' => '#d32f2f', 'media' => '#fbc02d', 'baja' => '#388e3c'];
$colorEstado = ['recibido' => '#2e7d32', 'en_proceso' => '#fbc02d', 'respondido' => '#0288d1', 'vencido' => '#d32f2f', 'archivado' => '#64748b'];
$pColor = $colorPrioridad[$o['prioridad']] ?? '#64748b';
$eColor = $colorEstado[$o['estado']] ?? '#64748b';
return <<<HTML
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: DejaVu Sans, Arial, sans-serif; font-size: 12px; color: #1e293b; }
.header { background: #1e1b4b; color: white; padding: 20px 30px; display: flex; justify-content: space-between; align-items: center; }
.header-title { font-size: 11px; opacity: .7; margin-top: 4px; }
.content { padding: 30px; }
.oficio-title { font-size: 20px; font-weight: bold; color: #1e1b4b; margin-bottom: 4px; }
.oficio-asunto { color: #475569; font-size: 13px; margin-bottom: 24px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
.field-group { border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px; }
.field-label { font-size: 9px; font-weight: bold; color: #94a3b8; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.field-value { font-size: 12px; font-weight: 600; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 99px; font-size: 10px; font-weight: bold; }
.section-title { font-size: 11px; font-weight: bold; color: #64748b; text-transform: uppercase; letter-spacing: .05em; border-bottom: 2px solid #e2e8f0; padding-bottom: 6px; margin: 20px 0 12px; }
.description-box { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.7; min-height: 60px; }
.footer { margin-top: 40px; border-top: 1px solid #e2e8f0; padding-top: 20px; display: flex; justify-content: space-between; }
.firma-box { border-top: 1px solid #1e1b4b; width: 200px; text-align: center; padding-top: 6px; font-size: 11px; }
.watermark { color: #94a3b8; font-size: 10px; }
</style>
</head>
<body>
<div class="header">
<div>
<div style="font-size:15px;font-weight:bold">OFICIO DTIC</div>
<div class="header-title">{$o['numero_oficio']} &nbsp;·&nbsp; {$o['tipo']}</div>
</div>
<div style="text-align:right">
$logo
<div style="font-size:10px;opacity:.7;margin-top:4px">{INSTITUCION_NOMBRE}</div>
</div>
</div>
<div class="content">
<div class="oficio-title">{$o['numero_oficio']}</div>
<div class="oficio-asunto">{$o['asunto']}</div>
<div class="grid">
<div class="field-group">
<div class="field-label">Remitente</div>
<div class="field-value">{$o['remitente']}</div>
</div>
<div class="field-group">
<div class="field-label">Destinatario</div>
<div class="field-value">{$o['destinatario']}</div>
</div>
<div class="field-group">
<div class="field-label">Fecha de Recepción</div>
<div class="field-value">$recep</div>
</div>
<div class="field-group">
<div class="field-label">Fecha de Vencimiento</div>
<div class="field-value">$vence</div>
</div>
<div class="field-group">
<div class="field-label">Prioridad</div>
<div class="field-value">
<span class="badge" style="background:{$pColor}20;color:$pColor">{$o['prioridad']}</span>
</div>
</div>
<div class="field-group">
<div class="field-label">Estado</div>
<div class="field-value">
<span class="badge" style="background:{$eColor}20;color:$eColor">{$o['estado']}</span>
</div>
</div>
<div class="field-group">
<div class="field-label">Responsable</div>
<div class="field-value">{$o['responsable_nombre']}</div>
</div>
<div class="field-group">
<div class="field-label">Etiquetas</div>
<div class="field-value">$etiquetas</div>
</div>
</div>
<div class="section-title">Descripción</div>
<div class="description-box">{$o['descripcion']}</div>
<div class="footer">
<div class="watermark">
Generado el $fecha &nbsp;·&nbsp; {APP_NAME}<br>
Documento generado automáticamente. No requiere firma.
</div>
<div>
<div class="firma-box">
<div>{INSTITUCION_CARGO_FIRMA}</div>
</div>
</div>
</div>
</div>
</body>
</html>
HTML;
}
/**
* Descarga backup de la BD como SQL
*/
public static function descargarBackupSQL(): void {
$dbName = env('DB_NAME', 'gestion_documentos');
$dbHost = env('DB_HOST', 'localhost');
$dbUser = env('DB_USER', 'root');
$dbPass = env('DB_PASS', '');
$fecha = date('Ymd_His');
$archivo = "$dbName-backup-$fecha.sql";
$backupDir = BASE_PATH . '/exports/backups/';
if (!is_dir($backupDir)) mkdir($backupDir, 0755, true);
$cmd = sprintf(
'mysqldump --host=%s --user=%s --password=%s %s > %s',
escapeshellarg($dbHost),
escapeshellarg($dbUser),
escapeshellarg($dbPass),
escapeshellarg($dbName),
escapeshellarg($backupDir . $archivo)
);
system($cmd, $ret);
if ($ret === 0 && file_exists($backupDir . $archivo)) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . $archivo . '"');
header('Content-Length: ' . filesize($backupDir . $archivo));
readfile($backupDir . $archivo);
} else {
http_response_code(500);
echo 'Error al generar el respaldo. Verifique mysqldump en el servidor.';
}
exit();
}
}
// Dispatcher si se invoca directamente
if (basename($_SERVER['PHP_SELF']) === 'ReporteController.php') {
AuthController::requerirAdmin();
$action = $_GET['action'] ?? '';
if ($action === 'backup_sql') ReporteController::descargarBackupSQL();
redirect(APP_URL . '/views/reportes/index.php');
}

View File

@ -0,0 +1,84 @@
<?php
/**
* UsuarioController.php CRUD de usuarios
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
require_once __DIR__ . '/../controllers/AuthController.php';
require_once __DIR__ . '/../models/Usuario.php';
AuthController::requerirAdmin();
$model = new UsuarioModel();
$action = $_GET['action'] ?? $_POST['action'] ?? '';
$userId = (int)$_SESSION['usuario_id'];
switch ($action) {
case 'crear':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/usuarios/lista.php');
verificarCsrf();
$datos = [
'nombre' => clean($_POST['nombre'] ?? ''),
'apellido' => clean($_POST['apellido'] ?? ''),
'email' => clean($_POST['email'] ?? ''),
'username' => clean($_POST['username'] ?? ''),
'password' => $_POST['password'] ?? '',
'rol_id' => (int)($_POST['rol_id'] ?? 3),
'cargo' => clean($_POST['cargo'] ?? ''),
'area' => clean($_POST['area'] ?? ''),
'supervisor_id' => (int)($_POST['supervisor_id'] ?? 0) ?: null,
];
if (empty($datos['nombre']) || empty($datos['email']) || empty($datos['username']) || strlen($datos['password']) < 8) {
redirect(APP_URL.'/views/usuarios/lista.php?error='.urlencode('Complete todos los campos requeridos (contraseña mínima 8 chars).'));
}
try {
$model->crear($datos);
logAct($userId, 'crear_usuario', 'usuarios', "Usuario {$datos['username']} creado.");
redirect(APP_URL.'/views/usuarios/lista.php?success='.urlencode('Usuario creado exitosamente.'));
} catch (\PDOException $e) {
redirect(APP_URL.'/views/usuarios/lista.php?error='.urlencode('El email o usuario ya existe.'));
}
case 'actualizar':
if ($_SERVER['REQUEST_METHOD'] !== 'POST') redirect(APP_URL.'/views/usuarios/lista.php');
verificarCsrf();
$id = (int)($_POST['id'] ?? 0);
$datos = [
'nombre' => clean($_POST['nombre'] ?? ''),
'apellido' => clean($_POST['apellido'] ?? ''),
'email' => clean($_POST['email'] ?? ''),
'username' => clean($_POST['username'] ?? ''),
'rol_id' => (int)($_POST['rol_id'] ?? 3),
'cargo' => clean($_POST['cargo'] ?? ''),
'area' => clean($_POST['area'] ?? ''),
'supervisor_id' => (int)($_POST['supervisor_id'] ?? 0) ?: null,
'activo' => isset($_POST['activo']) ? 1 : 0,
];
$model->actualizar($id, $datos);
if (!empty($_POST['password']) && strlen($_POST['password']) >= 8) {
$model->actualizarPassword($id, password_hash($_POST['password'], PASSWORD_BCRYPT, ['cost' => 12]));
}
logAct($userId, 'editar_usuario', 'usuarios', "Usuario #$id actualizado.");
redirect(APP_URL.'/views/usuarios/lista.php?success='.urlencode('Usuario actualizado.'));
case 'eliminar':
$id = (int)($_GET['id'] ?? 0);
if ($id && $id !== $userId) {
$model->eliminarLogico($id);
logAct($userId, 'eliminar_usuario', 'usuarios', "Usuario #$id desactivado.");
}
redirect(APP_URL.'/views/usuarios/lista.php?success='.urlencode('Usuario desactivado.'));
default:
redirect(APP_URL.'/views/usuarios/lista.php');
}
function logAct(int $uid, string $accion, string $modulo, string $desc = ''): void {
$db = getDB();
$db->prepare("INSERT INTO log_actividad(usuario_id,accion,modulo,descripcion,ip_address) VALUES(?,?,?,?,?)")
->execute([$uid, $accion, $modulo, $desc, $_SERVER['REMOTE_ADDR']??null]);
}

271
cron/enviar_alertas.php Normal file
View File

@ -0,0 +1,271 @@
<?php
/**
* enviar_alertas.php Script de alertas (ejecutado por cron job)
* Cron recomendado: 0 8 * * * php /ruta/al/proyecto/cron/enviar_alertas.php
* O invocar desde navegador con: /cron/enviar_alertas.php?secret=CLAVE_SECRETA
*/
define('RUNNING_FROM_CLI', php_sapi_name() === 'cli');
if (!RUNNING_FROM_CLI) {
// Verificar clave secreta si se invoca por HTTP
require_once __DIR__ . '/../config/config.php';
$secret = $_GET['secret'] ?? '';
if (!hash_equals(env('ALERT_CRON_SECRET', ''), $secret)) {
http_response_code(403);
die('Acceso denegado.');
}
} else {
require_once __DIR__ . '/../config/config.php';
}
$libPath = BASE_PATH . '/lib/PHPMailer/src/';
$mailEnabled = file_exists($libPath . 'PHPMailer.php');
if ($mailEnabled) {
require_once $libPath . 'PHPMailer.php';
require_once $libPath . 'SMTP.php';
require_once $libPath . 'Exception.php';
}
$db = getDB();
$hoy = date('Y-m-d');
$enviados = 0;
$errores = 0;
// ── Cargar configuración de alertas activas ───────────────────────────────────
$alertasConfig = $db->query(
"SELECT * FROM alertas_config WHERE activo=1 ORDER BY dias_antes DESC"
)->fetchAll();
// ── Obtener oficios que requieren alerta ──────────────────────────────────────
foreach ($alertasConfig as $config) {
$diasAntes = (int)$config['dias_antes'];
if ($diasAntes >= 0) {
// Próximos a vencer o día mismo
$fechaTarget = date('Y-m-d', strtotime("+{$diasAntes} days"));
$stmt = $db->prepare(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre,
u.email AS responsable_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento = ?
AND u.email IS NOT NULL"
);
$stmt->execute([$fechaTarget]);
} else {
// Día DESPUÉS de vencido (escalación)
$fechaTarget = date('Y-m-d', strtotime("{$diasAntes} days"));
$stmt = $db->prepare(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre,
u.email AS responsable_email,
u.supervisor_id,
CONCAT(s.nombre,' ',s.apellido) AS supervisor_nombre,
s.email AS supervisor_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
LEFT JOIN usuarios s ON s.id = u.supervisor_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento = ?
AND u.email IS NOT NULL"
);
$stmt->execute([$fechaTarget]);
}
$oficiosAlerta = $stmt->fetchAll();
foreach ($oficiosAlerta as $oficio) {
// ── Notificación interna ──────────────────────────────────────────────
if ($config['enviar_notificacion'] && $oficio['responsable_id']) {
// Evitar duplicados: verificar si ya existe notificación de hoy
$existe = $db->prepare(
"SELECT id FROM notificaciones
WHERE usuario_id=? AND oficio_id=? AND tipo='vencimiento'
AND DATE(created_at)=?"
);
$existe->execute([$oficio['responsable_id'], $oficio['id'], $hoy]);
if (!$existe->fetchColumn()) {
$titulo = $diasAntes > 0
? "⚠️ Oficio vence en {$diasAntes} día(s)"
: ($diasAntes === 0 ? '🔴 Oficio vence HOY' : '🚨 Oficio VENCIDO sin respuesta');
$mensaje = "El oficio {$oficio['numero_oficio']} {$oficio['asunto']} vence el " . date('d/m/Y', strtotime($oficio['fecha_vencimiento']));
$db->prepare(
"INSERT INTO notificaciones(usuario_id,oficio_id,tipo,titulo,mensaje) VALUES(?,?,'vencimiento',?,?)"
)->execute([$oficio['responsable_id'], $oficio['id'], $titulo, $mensaje]);
// Escalación al supervisor si ya venció
if ($diasAntes < 0 && $oficio['supervisor_id']) {
$db->prepare(
"INSERT INTO notificaciones(usuario_id,oficio_id,tipo,titulo,mensaje) VALUES(?,?,'escalacion',?,?)"
)->execute([
$oficio['supervisor_id'],
$oficio['id'],
'🚨 Escalación automática',
"El oficio {$oficio['numero_oficio']} a cargo de {$oficio['responsable_nombre']} está vencido sin respuesta."
]);
$db->prepare(
"INSERT INTO historial_cambios(tabla,registro_id,accion,detalle,usuario_id) VALUES('oficios',?,'escalacion',?,NULL)"
)->execute([$oficio['id'], "Escalación automática al supervisor #{$oficio['supervisor_id']}"]);
}
}
}
// ── Envío de correo ───────────────────────────────────────────────────
if ($config['enviar_email'] && $mailEnabled && $oficio['responsable_email']) {
$result = enviarCorreoAlerta($oficio, $diasAntes, $libPath);
if ($result) $enviados++;
else $errores++;
// Correo al supervisor si hubo escalación
if ($diasAntes < 0 && !empty($oficio['supervisor_email'])) {
enviarCorreoEscalacion($oficio, $libPath);
}
}
}
}
// ── Reporte semanal (lunes) ───────────────────────────────────────────────────
if (date('N') === env('WEEKLY_REPORT_DAY', '1')) {
$stmt = $db->query(
"SELECT u.email, u.nombre, u.apellido,
COUNT(o.id) AS total_vencer
FROM usuarios u
JOIN oficios o ON o.responsable_id = u.id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 7 DAY)
GROUP BY u.id
HAVING total_vencer > 0"
);
$semanal = $stmt->fetchAll();
foreach ($semanal as $row) {
if ($mailEnabled && $row['email']) {
enviarReporteSemanal($row, $libPath);
$enviados++;
}
}
}
$log = "[" . date('Y-m-d H:i:s') . "] Alertas enviadas: $enviados | Errores: $errores\n";
file_put_contents(BASE_PATH . '/logs/cron_alertas.log', $log, FILE_APPEND);
echo $log;
// ── Funciones de correo ───────────────────────────────────────────────────────
function crearMailer(string $libPath): ?\PHPMailer\PHPMailer\PHPMailer {
if (!class_exists('\PHPMailer\PHPMailer\PHPMailer')) return null;
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
$mail->isSMTP();
$mail->Host = env('MAIL_HOST');
$mail->SMTPAuth = true;
$mail->Username = env('MAIL_USERNAME');
$mail->Password = env('MAIL_PASSWORD');
$mail->SMTPSecure = env('MAIL_ENCRYPTION', 'tls');
$mail->Port = (int)env('MAIL_PORT', 587);
$mail->setFrom(env('MAIL_FROM_ADDRESS'), env('MAIL_FROM_NAME'));
$mail->isHTML(true);
$mail->CharSet = 'UTF-8';
return $mail;
}
function enviarCorreoAlerta(array $oficio, int $diasAntes, string $libPath): bool {
try {
$mail = crearMailer($libPath);
if (!$mail) return false;
$mail->addAddress($oficio['responsable_email'], $oficio['responsable_nombre']);
$vence = date('d/m/Y', strtotime($oficio['fecha_vencimiento']));
$asuntoMail = $diasAntes > 0
? "⚠️ ALERTA: Oficio vence en {$diasAntes} día(s)"
: ($diasAntes === 0 ? '🔴 ALERTA: Oficio vence HOY' : '🚨 AVISO: Oficio VENCIDO');
$mail->Subject = $asuntoMail . ' ' . APP_NAME;
$mail->Body = emailTemplate($asuntoMail,
"El siguiente oficio requiere tu atención:",
[
'Número de Oficio' => $oficio['numero_oficio'],
'Asunto' => $oficio['asunto'],
'Fecha Vencimiento'=> $vence,
'Prioridad' => strtoupper($oficio['prioridad']),
'Estado actual' => $oficio['estado'],
],
env('APP_URL') . '/views/oficios/detalle.php?id=' . $oficio['id']
);
$mail->send();
return true;
} catch (\Exception $e) {
error_log('Mailer alerta error: ' . $e->getMessage());
return false;
}
}
function enviarCorreoEscalacion(array $oficio, string $libPath): bool {
try {
$mail = crearMailer($libPath);
if (!$mail || empty($oficio['supervisor_email'])) return false;
$mail->addAddress($oficio['supervisor_email'], $oficio['supervisor_nombre'] ?? 'Supervisor');
$mail->Subject = '🚨 ESCALACIÓN: Oficio vencido ' . APP_NAME;
$mail->Body = emailTemplate('🚨 Escalación Automática',
"Se notifica que el siguiente oficio ha vencido sin respuesta. El responsable es <strong>{$oficio['responsable_nombre']}</strong>.",
[
'Número de Oficio' => $oficio['numero_oficio'],
'Asunto' => $oficio['asunto'],
'Responsable' => $oficio['responsable_nombre'],
'Fecha Vencimiento' => date('d/m/Y', strtotime($oficio['fecha_vencimiento'])),
],
env('APP_URL') . '/views/oficios/detalle.php?id=' . $oficio['id']
);
$mail->send();
return true;
} catch (\Exception $e) {
error_log('Mailer escalacion error: ' . $e->getMessage());
return false;
}
}
function enviarReporteSemanal(array $row, string $libPath): bool {
try {
$mail = crearMailer($libPath);
if (!$mail) return false;
$mail->addAddress($row['email'], $row['nombre'] . ' ' . $row['apellido']);
$mail->Subject = '📊 Reporte Semanal de Oficios ' . APP_NAME;
$mail->Body = emailTemplate('📊 Reporte Semanal',
"Tienes <strong>{$row['total_vencer']}</strong> oficio(s) próximo(s) a vencer en los próximos 7 días. Revisa el sistema para gestionarlos oportunamente.",
[],
env('APP_URL') . '/views/oficios/lista.php?semaforo=proximo'
);
$mail->send();
return true;
} catch (\Exception $e) {
error_log('Mailer semanal error: ' . $e->getMessage());
return false;
}
}
function emailTemplate(string $titulo, string $intro, array $campos, string $url): string {
$filas = '';
foreach ($campos as $k => $v) {
$filas .= "<tr><td style='padding:6px 12px;font-weight:600;color:#64748b;white-space:nowrap'>$k</td><td style='padding:6px 12px'>".htmlspecialchars($v)."</td></tr>";
}
return "
<div style='font-family:Inter,Arial,sans-serif;max-width:600px;margin:0 auto;background:#f8fafc;padding:24px'>
<div style='background:#1e1b4b;padding:24px;border-radius:12px 12px 0 0;text-align:center'>
<h1 style='color:#fff;font-size:1.1rem;margin:0'>".APP_NAME."</h1>
</div>
<div style='background:#fff;padding:24px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px'>
<h2 style='color:#1e293b;font-size:1.1rem;margin:0 0 12px'>$titulo</h2>
<p style='color:#475569;margin:0 0 20px'>$intro</p>
".($filas ? "<table style='width:100%;border-collapse:collapse;margin-bottom:20px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden'>$filas</table>" : "")."
<a href='$url' style='display:inline-block;background:#4f46e5;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600'>
Ver en el Sistema
</a>
<p style='color:#94a3b8;font-size:.8rem;margin-top:24px'>
Este es un mensaje automático del ".APP_NAME.". No responder a este correo.
</p>
</div>
</div>";
}

292
dashboard.php Normal file
View File

@ -0,0 +1,292 @@
<?php
/**
* dashboard.php Panel principal con KPIs y gráficos
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/controllers/AuthController.php';
require_once __DIR__ . '/models/Oficio.php';
AuthController::requerirAuth();
$oficio = new OficioModel();
$kpis = $oficio->kpis();
$global = $kpis['global'];
$estados = $kpis['estados'];
$prioridades = $kpis['prioridades'];
$proximosVencer = $oficio->proximosAVencer(3);
$estadisticasMens = $oficio->estadisticasMensuales(6);
// Solo mis oficios si es usuario estándar
$esAdmin = AuthController::esAdmin();
$userId = $_SESSION['usuario_id'];
$pageTitle = 'Dashboard';
$activeNav = 'dashboard';
include __DIR__ . '/views/layout/header.php';
include __DIR__ . '/views/layout/sidebar.php';
include __DIR__ . '/views/layout/topbar.php';
?>
<div class="page-content">
<!-- Breadcrumb -->
<div class="breadcrumb">
<i class="fa-solid fa-house"></i>
<span>Dashboard</span>
</div>
<!-- Page header -->
<div class="page-header">
<div class="page-header-content">
<h1>Panel de Control</h1>
<p>Resumen general del sistema de gestión de oficios institucionales · <?= date('d/m/Y') ?></p>
</div>
<a href="<?= APP_URL ?>/views/oficios/crear.php" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Nuevo Oficio
</a>
</div>
<!-- KPI Cards -->
<div class="grid-4 mb-4">
<div class="kpi-card kpi-primary">
<div class="kpi-icon"><i class="fa-solid fa-folder-open"></i></div>
<div class="kpi-info">
<div class="kpi-value"><?= number_format((int)($global['total_oficios'] ?? 0)) ?></div>
<div class="kpi-label">Total Oficios</div>
</div>
</div>
<div class="kpi-card kpi-danger">
<div class="kpi-icon"><i class="fa-solid fa-circle-xmark"></i></div>
<div class="kpi-info">
<div class="kpi-value"><?= number_format((int)($global['total_vencidos'] ?? 0)) ?></div>
<div class="kpi-label">Vencidos</div>
</div>
</div>
<div class="kpi-card kpi-warning">
<div class="kpi-icon"><i class="fa-solid fa-clock"></i></div>
<div class="kpi-info">
<div class="kpi-value"><?= number_format((int)($global['total_por_vencer'] ?? 0)) ?></div>
<div class="kpi-label">Por Vencer (≤3 días)</div>
</div>
</div>
<div class="kpi-card kpi-success">
<div class="kpi-icon"><i class="fa-solid fa-circle-check"></i></div>
<div class="kpi-info">
<div class="kpi-value"><?= number_format((int)($global['completados_este_mes'] ?? 0)) ?></div>
<div class="kpi-label">Completados (este mes)</div>
</div>
</div>
</div>
<!-- Charts + Próximos a vencer -->
<div class="grid-2 mb-4" style="grid-template-columns:1.3fr 1fr">
<!-- Estado por mes (línea) -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-chart-line text-primary"></i>
<span class="card-title">Tendencia últimos 6 meses</span>
</div>
<div class="card-body">
<canvas id="chartLinea" height="180"></canvas>
</div>
</div>
<!-- Donut de estados -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-chart-pie text-primary"></i>
<span class="card-title">Oficios por Estado</span>
</div>
<div class="card-body" style="display:flex;flex-direction:column;align-items:center;">
<canvas id="chartDonut" height="180" style="max-width:240px"></canvas>
</div>
</div>
</div>
<div class="grid-2 mb-4" style="grid-template-columns:1fr 1fr">
<!-- Barras por prioridad -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-chart-bar text-warning"></i>
<span class="card-title">Oficios por Prioridad</span>
</div>
<div class="card-body">
<canvas id="chartPrioridad" height="160"></canvas>
</div>
</div>
<!-- Próximos a vencer -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-triangle-exclamation text-warning"></i>
<span class="card-title">Próximos a Vencer</span>
<a href="<?= APP_URL ?>/views/oficios/lista.php?semaforo=proximo" class="btn btn-sm btn-secondary">Ver todos</a>
</div>
<div class="card-body" style="padding:0">
<?php if (empty($proximosVencer)): ?>
<div style="padding:1.5rem;text-align:center;color:var(--text-muted);font-size:.83rem">
<i class="fa-solid fa-circle-check text-success" style="font-size:1.5rem;display:block;margin-bottom:.5rem"></i>
Sin oficios próximos a vencer
</div>
<?php else: ?>
<div class="table-responsive">
<table class="table">
<thead>
<tr><th> Oficio</th><th>Vence</th><th>Días</th><th>Estado</th></tr>
</thead>
<tbody>
<?php foreach (array_slice($proximosVencer, 0, 6) as $ov):
$dias = (int)((new DateTime($ov['fecha_vencimiento']))->diff(new DateTime())->days);
$semaforoClass = $dias <= 0 ? 'semaforo-vencido' : ($dias <= 1 ? 'semaforo-proximo' : 'semaforo-vigente');
?>
<tr>
<td>
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $ov['id'] ?>" class="fw-600" style="color:var(--primary);text-decoration:none;">
<?= htmlspecialchars($ov['numero_oficio']) ?>
</a>
<div class="fs-sm text-muted"><?= mb_strimwidth(htmlspecialchars($ov['asunto']), 0, 35, '…') ?></div>
</td>
<td><?= date('d/m/Y', strtotime($ov['fecha_vencimiento'])) ?></td>
<td><span class="badge <?= $semaforoClass ?>"><?= $dias ?> días</span></td>
<td><span class="badge badge-warning"><?= ucfirst($ov['estado']) ?></span></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
</div>
</div>
</div><!-- /.page-content -->
<?php
// Preparar datos para Chart.js
$meses = [];
$serieCreados = [];
$serieRespondidos = [];
foreach ($estadisticasMens as $row) {
if (!in_array($row['mes'], $meses)) {
$meses[] = $row['mes'];
$serieCreados[$row['mes']] = 0;
$serieRespondidos[$row['mes']] = 0;
}
if ($row['estado'] === 'recibido' || $row['estado'] === 'en_proceso') {
$serieCreados[$row['mes']] += $row['total'];
}
if ($row['estado'] === 'respondido') {
$serieRespondidos[$row['mes']] += $row['total'];
}
}
$estadosLabels = array_keys($estados);
$estadosData = array_values($estados);
$prioLabels = array_keys($prioridades);
$prioData = array_values($prioridades);
?>
<script>
const appUrl = '<?= APP_URL ?>';
// ── Línea tendencia ──────────────────────────────────────────────────────────
const meses = <?= json_encode(array_values($meses)) ?>;
const creados = <?= json_encode(array_values($serieCreados)) ?>;
const respondidos = <?= json_encode(array_values($serieRespondidos)) ?>;
new Chart(document.getElementById('chartLinea'), {
type: 'line',
data: {
labels: meses,
datasets: [
{
label: 'Activos',
data: creados,
borderColor: '#2e7d32',
backgroundColor: 'rgba(46, 125, 125, .1)',
fill: true,
tension: .4,
pointRadius: 4,
pointBackgroundColor: '#2e7d32',
},
{
label: 'Respondidos',
data: respondidos,
borderColor: '#0288d1',
backgroundColor: 'rgba(2, 136, 209, .08)',
fill: true,
tension: .4,
pointRadius: 4,
pointBackgroundColor: '#0288d1',
}
]
},
options: {
responsive: true,
plugins: { legend: { position: 'bottom' } },
scales: {
x: { grid: { color: 'rgba(0,0,0,.04)' } },
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,.04)' }, ticks: { precision: 0 } }
}
}
});
// ── Donut estados ────────────────────────────────────────────────────────────
const estadosLabels = <?= json_encode($estadosLabels) ?>;
const estadosData = <?= json_encode($estadosData) ?>;
const estadosColors = {
'recibido': '#2e7d32',
'en_proceso': '#fbc02d',
'respondido': '#0288d1',
'vencido': '#d32f2f',
'archivado': '#94a3b8'
};
new Chart(document.getElementById('chartDonut'), {
type: 'doughnut',
data: {
labels: estadosLabels.map(e => e.charAt(0).toUpperCase() + e.slice(1).replace('_',' ')),
datasets: [{
data: estadosData,
backgroundColor: estadosLabels.map(e => estadosColors[e] || '#94a3b8'),
borderWidth: 2,
borderColor: 'var(--bg-card)',
}]
},
options: {
cutout: '65%',
responsive: true,
plugins: { legend: { position: 'bottom' } }
}
});
// ── Barras prioridad ─────────────────────────────────────────────────────────
const prioLabels = <?= json_encode($prioLabels) ?>;
const prioData = <?= json_encode($prioData) ?>;
const prioColors = { alta: '#d32f2f', media: '#fbc02d', baja: '#388e3c' };
new Chart(document.getElementById('chartPrioridad'), {
type: 'bar',
data: {
labels: prioLabels.map(p => p.charAt(0).toUpperCase() + p.slice(1)),
datasets: [{
label: 'Oficios',
data: prioData,
backgroundColor: prioLabels.map(p => prioColors[p] || '#4f46e5'),
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, ticks: { precision: 0 } }
}
}
});
</script>
<?php include __DIR__ . '/views/layout/footer.php'; ?>

View File

@ -0,0 +1,361 @@
-- ============================================================
-- SISTEMA DE CONTROL DE GESTIÓN DE DOCUMENTACIÓN
-- Oficios Jurídicos Institucionales
-- Versión: 1.0 | Fecha: 2026-04-22
-- ============================================================
SET FOREIGN_KEY_CHECKS = 0;
SET SQL_MODE = "NO_AUTO_VALUE_ON_ZERO";
SET time_zone = "+00:00";
CREATE DATABASE IF NOT EXISTS `gestion_documentos`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE `gestion_documentos`;
-- -----------------------------------------------------------
-- TABLA: roles
-- -----------------------------------------------------------
CREATE TABLE `roles` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`nombre` VARCHAR(50) NOT NULL,
`descripcion` VARCHAR(255) DEFAULT NULL,
`permisos` JSON DEFAULT NULL COMMENT 'Permisos en formato JSON para RBAC',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_rol_nombre` (`nombre`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `roles` (`nombre`, `descripcion`, `permisos`) VALUES
('administrador', 'Acceso total al sistema', '{"oficios":"CRUD","usuarios":"CRUD","reportes":true,"respaldo":true,"papelera_fisica":true,"config_alertas":true}'),
('supervisor', 'Supervisa usuarios y recibe escalaciones', '{"oficios":"CRUD","usuarios":"read","reportes":true,"respaldo":false,"papelera_fisica":false,"config_alertas":false}'),
('estandar', 'Gestión de oficios propios', '{"oficios":"CRUD_own","usuarios":"none","reportes":"own","respaldo":false,"papelera_fisica":false,"config_alertas":false}');
-- -----------------------------------------------------------
-- TABLA: usuarios
-- -----------------------------------------------------------
CREATE TABLE `usuarios` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`rol_id` INT UNSIGNED NOT NULL DEFAULT 3,
`nombre` VARCHAR(100) NOT NULL,
`apellido` VARCHAR(100) NOT NULL,
`email` VARCHAR(150) NOT NULL,
`username` VARCHAR(60) NOT NULL,
`password_hash` VARCHAR(255) NOT NULL,
`cargo` VARCHAR(100) DEFAULT NULL,
`area` VARCHAR(100) DEFAULT NULL,
`supervisor_id` INT UNSIGNED DEFAULT NULL COMMENT 'Usuario supervisor para escalaciones',
`token_recuperacion` VARCHAR(100) DEFAULT NULL,
`token_expira` DATETIME DEFAULT NULL,
`ultimo_login` DATETIME DEFAULT NULL,
`activo` TINYINT(1) NOT NULL DEFAULT 1,
`deleted_at` DATETIME DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_email` (`email`),
UNIQUE KEY `uk_username` (`username`),
KEY `fk_usuario_rol` (`rol_id`),
KEY `fk_usuario_supervisor` (`supervisor_id`),
CONSTRAINT `fk_usuario_rol` FOREIGN KEY (`rol_id`) REFERENCES `roles` (`id`) ON UPDATE CASCADE,
CONSTRAINT `fk_usuario_supervisor` FOREIGN KEY (`supervisor_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Usuario administrador por defecto: admin / Admin@2026
INSERT INTO `usuarios` (`rol_id`, `nombre`, `apellido`, `email`, `username`, `password_hash`, `cargo`, `area`, `activo`) VALUES
(1, 'Administrador', 'Sistema', 'admin@sistema.local', 'admin', '$2y$12$hF3W4J5K9L2M7N8P1Q6R4OQKvVbFa.Np/GzDzFH8fRv1sXkLmEpWu', 'Administrador TI', 'Sistemas', 1);
-- -----------------------------------------------------------
-- TABLA: etiquetas
-- -----------------------------------------------------------
CREATE TABLE `etiquetas` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`nombre` VARCHAR(80) NOT NULL,
`color` VARCHAR(7) NOT NULL DEFAULT '#6c757d' COMMENT 'Hex color',
`icono` VARCHAR(50) DEFAULT 'fa-tag',
`creado_por` INT UNSIGNED DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_etiqueta_nombre` (`nombre`),
KEY `fk_etiqueta_usuario` (`creado_por`),
CONSTRAINT `fk_etiqueta_usuario` FOREIGN KEY (`creado_por`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `etiquetas` (`nombre`, `color`, `icono`) VALUES
('Contrato', '#0d6efd', 'fa-file-contract'),
('Demanda', '#dc3545', 'fa-gavel'),
('Recurso', '#fd7e14', 'fa-folder-open'),
('Solicitud', '#198754', 'fa-envelope-open'),
('Circular', '#6f42c1', 'fa-circle-info'),
('Convenio', '#20c997', 'fa-handshake'),
('Resolución', '#0dcaf0', 'fa-stamp'),
('Decreto', '#e91e63', 'fa-scroll');
-- -----------------------------------------------------------
-- TABLA: plantillas_respuesta
-- -----------------------------------------------------------
CREATE TABLE `plantillas_respuesta` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`titulo` VARCHAR(150) NOT NULL,
`contenido` TEXT NOT NULL,
`tipo` ENUM('recibido','enviado','general') NOT NULL DEFAULT 'general',
`creado_por` INT UNSIGNED DEFAULT NULL,
`activo` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_plantilla_usuario` (`creado_por`),
CONSTRAINT `fk_plantilla_usuario` FOREIGN KEY (`creado_por`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `plantillas_respuesta` (`titulo`, `contenido`, `tipo`) VALUES
('Acuse de recibo', 'En respuesta a su oficio N° {numero_oficio} de fecha {fecha_recepcion}, le informamos que hemos recibido correctamente su comunicación y procederemos a darle el trámite correspondiente.', 'recibido'),
('Solicitud de información adicional', 'En atención a su oficio N° {numero_oficio}, nos permitimos solicitarle amablemente información complementaria sobre el asunto referido, a fin de darle una respuesta adecuada y oportuna.', 'recibido'),
('Respuesta favorable', 'En atención a lo solicitado en el oficio N° {numero_oficio}, nos complace comunicarle que su petición ha sido evaluada favorablemente por esta instancia.', 'enviado'),
('Respuesta negativa', 'Lamentamos comunicarle que, tras la revisión del oficio N° {numero_oficio}, esta instancia no puede dar respuesta favorable a lo solicitado por las razones que se detallan a continuación.', 'enviado');
-- -----------------------------------------------------------
-- TABLA: oficios
-- -----------------------------------------------------------
CREATE TABLE `oficios` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`numero_oficio` VARCHAR(50) NOT NULL,
`tipo` ENUM('recibido','enviado') NOT NULL DEFAULT 'recibido',
`remitente` VARCHAR(200) NOT NULL,
`destinatario` VARCHAR(200) NOT NULL,
`asunto` TEXT NOT NULL,
`descripcion` TEXT DEFAULT NULL,
`fecha_recepcion` DATE NOT NULL,
`fecha_vencimiento` DATE DEFAULT NULL,
`prioridad` ENUM('alta','media','baja') NOT NULL DEFAULT 'media',
`estado` ENUM('recibido','en_proceso','respondido','vencido','archivado') NOT NULL DEFAULT 'recibido',
`responsable_id` INT UNSIGNED DEFAULT NULL,
`creado_por` INT UNSIGNED DEFAULT NULL,
`derivado_de_id` INT UNSIGNED DEFAULT NULL COMMENT 'Oficio del que fue derivado',
`derivado_a_id` INT UNSIGNED DEFAULT NULL COMMENT 'Usuario al que se derivó',
`comentario_derivacion` TEXT DEFAULT NULL,
`es_confidencial` TINYINT(1) NOT NULL DEFAULT 0,
`deleted_at` DATETIME DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_numero_oficio` (`numero_oficio`),
KEY `idx_tipo` (`tipo`),
KEY `idx_estado` (`estado`),
KEY `idx_prioridad` (`prioridad`),
KEY `idx_fecha_vencimiento` (`fecha_vencimiento`),
KEY `idx_deleted_at` (`deleted_at`),
KEY `fk_oficio_responsable` (`responsable_id`),
KEY `fk_oficio_creador` (`creado_por`),
KEY `fk_oficio_derivado_a` (`derivado_a_id`),
FULLTEXT KEY `ft_busqueda` (`numero_oficio`, `remitente`, `destinatario`, `asunto`),
CONSTRAINT `fk_oficio_responsable` FOREIGN KEY (`responsable_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `fk_oficio_creador` FOREIGN KEY (`creado_por`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT `fk_oficio_derivado_a` FOREIGN KEY (`derivado_a_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: oficio_etiquetas (N:M)
-- -----------------------------------------------------------
CREATE TABLE `oficio_etiquetas` (
`oficio_id` INT UNSIGNED NOT NULL,
`etiqueta_id` INT UNSIGNED NOT NULL,
PRIMARY KEY (`oficio_id`, `etiqueta_id`),
KEY `fk_oe_etiqueta` (`etiqueta_id`),
CONSTRAINT `fk_oe_oficio` FOREIGN KEY (`oficio_id`) REFERENCES `oficios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_oe_etiqueta` FOREIGN KEY (`etiqueta_id`) REFERENCES `etiquetas` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: documentos_adjuntos
-- -----------------------------------------------------------
CREATE TABLE `documentos_adjuntos` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`oficio_id` INT UNSIGNED NOT NULL,
`nombre_original` VARCHAR(255) NOT NULL,
`nombre_archivo` VARCHAR(255) NOT NULL COMMENT 'Nombre hasheado en servidor',
`ruta` VARCHAR(500) NOT NULL,
`tipo_mime` VARCHAR(100) NOT NULL,
`tamanio` INT UNSIGNED NOT NULL COMMENT 'Tamaño en bytes',
`subido_por` INT UNSIGNED DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_doc_oficio` (`oficio_id`),
KEY `fk_doc_usuario` (`subido_por`),
CONSTRAINT `fk_doc_oficio` FOREIGN KEY (`oficio_id`) REFERENCES `oficios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_doc_usuario` FOREIGN KEY (`subido_por`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: tareas
-- -----------------------------------------------------------
CREATE TABLE `tareas` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`oficio_id` INT UNSIGNED NOT NULL,
`asignado_a` INT UNSIGNED DEFAULT NULL,
`creado_por` INT UNSIGNED DEFAULT NULL,
`titulo` VARCHAR(200) NOT NULL,
`descripcion` TEXT DEFAULT NULL,
`fecha_limite` DATE DEFAULT NULL,
`estado` ENUM('pendiente','en_proceso','completada','cancelada') NOT NULL DEFAULT 'pendiente',
`prioridad` ENUM('alta','media','baja') NOT NULL DEFAULT 'media',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_tarea_oficio` (`oficio_id`),
KEY `fk_tarea_asignado` (`asignado_a`),
CONSTRAINT `fk_tarea_oficio` FOREIGN KEY (`oficio_id`) REFERENCES `oficios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_tarea_asignado` FOREIGN KEY (`asignado_a`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: historial_cambios (Auditoría)
-- -----------------------------------------------------------
CREATE TABLE `historial_cambios` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`tabla` VARCHAR(60) NOT NULL,
`registro_id` INT UNSIGNED NOT NULL,
`campo_modificado` VARCHAR(100) DEFAULT NULL,
`valor_anterior` TEXT DEFAULT NULL,
`valor_nuevo` TEXT DEFAULT NULL,
`accion` ENUM('crear','editar','eliminar','restaurar','derivar','escalacion') NOT NULL,
`usuario_id` INT UNSIGNED DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`detalle` TEXT DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_historial_tabla_registro` (`tabla`, `registro_id`),
KEY `fk_historial_usuario` (`usuario_id`),
CONSTRAINT `fk_historial_usuario` FOREIGN KEY (`usuario_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: alertas_config
-- -----------------------------------------------------------
CREATE TABLE `alertas_config` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`nombre` VARCHAR(100) NOT NULL,
`dias_antes` INT NOT NULL DEFAULT 3 COMMENT 'Días antes del vencimiento para alertar',
`enviar_email` TINYINT(1) NOT NULL DEFAULT 1,
`enviar_notificacion` TINYINT(1) NOT NULL DEFAULT 1,
`activo` TINYINT(1) NOT NULL DEFAULT 1,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO `alertas_config` (`nombre`, `dias_antes`, `enviar_email`, `enviar_notificacion`, `activo`) VALUES
('Alerta 3 días antes', 3, 1, 1, 1),
('Alerta 1 día antes', 1, 1, 1, 1),
('Alerta día de vencimiento', 0, 1, 1, 1),
('Alerta día después (vencido)', -1, 1, 1, 1);
-- -----------------------------------------------------------
-- TABLA: notificaciones
-- -----------------------------------------------------------
CREATE TABLE `notificaciones` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`usuario_id` INT UNSIGNED NOT NULL,
`oficio_id` INT UNSIGNED DEFAULT NULL,
`tipo` ENUM('vencimiento','derivacion','escalacion','sistema','tarea') NOT NULL DEFAULT 'sistema',
`titulo` VARCHAR(200) NOT NULL,
`mensaje` TEXT NOT NULL,
`leida` TINYINT(1) NOT NULL DEFAULT 0,
`leida_at` DATETIME DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_notif_usuario_leida` (`usuario_id`, `leida`),
KEY `fk_notif_oficio` (`oficio_id`),
CONSTRAINT `fk_notif_usuario` FOREIGN KEY (`usuario_id`) REFERENCES `usuarios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_notif_oficio` FOREIGN KEY (`oficio_id`) REFERENCES `oficios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: comentarios
-- -----------------------------------------------------------
CREATE TABLE `comentarios` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`oficio_id` INT UNSIGNED NOT NULL,
`usuario_id` INT UNSIGNED NOT NULL,
`comentario` TEXT NOT NULL,
`es_privado` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '1=solo admins y supervisores',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `fk_coment_oficio` (`oficio_id`),
KEY `fk_coment_usuario` (`usuario_id`),
CONSTRAINT `fk_coment_oficio` FOREIGN KEY (`oficio_id`) REFERENCES `oficios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT `fk_coment_usuario` FOREIGN KEY (`usuario_id`) REFERENCES `usuarios` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- -----------------------------------------------------------
-- TABLA: log_actividad
-- -----------------------------------------------------------
CREATE TABLE `log_actividad` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`usuario_id` INT UNSIGNED DEFAULT NULL,
`accion` VARCHAR(100) NOT NULL,
`modulo` VARCHAR(60) NOT NULL,
`descripcion` TEXT DEFAULT NULL,
`ip_address` VARCHAR(45) DEFAULT NULL,
`user_agent` VARCHAR(500) DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_log_usuario` (`usuario_id`),
KEY `idx_log_modulo` (`modulo`),
KEY `idx_log_created` (`created_at`),
CONSTRAINT `fk_log_usuario` FOREIGN KEY (`usuario_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;
-- ============================================================
-- VISTA: v_oficios_completo (para reportes y listados)
-- ============================================================
CREATE OR REPLACE VIEW `v_oficios_completo` AS
SELECT
o.id,
o.numero_oficio,
o.tipo,
o.remitente,
o.destinatario,
o.asunto,
o.fecha_recepcion,
o.fecha_vencimiento,
o.prioridad,
o.estado,
o.es_confidencial,
o.deleted_at,
o.created_at,
o.updated_at,
CONCAT(u_resp.nombre, ' ', u_resp.apellido) AS responsable_nombre,
u_resp.email AS responsable_email,
CONCAT(u_crea.nombre, ' ', u_crea.apellido) AS creado_por_nombre,
DATEDIFF(o.fecha_vencimiento, CURDATE()) AS dias_para_vencer,
CASE
WHEN o.estado = 'respondido' OR o.estado = 'archivado' THEN 'completado'
WHEN o.fecha_vencimiento IS NULL THEN 'sin_vencimiento'
WHEN DATEDIFF(o.fecha_vencimiento, CURDATE()) < 0 THEN 'vencido'
WHEN DATEDIFF(o.fecha_vencimiento, CURDATE()) <= 3 THEN 'proximo'
ELSE 'vigente'
END AS semaforo,
(SELECT COUNT(*) FROM documentos_adjuntos da WHERE da.oficio_id = o.id) AS total_adjuntos,
(SELECT COUNT(*) FROM comentarios c WHERE c.oficio_id = o.id) AS total_comentarios,
(SELECT GROUP_CONCAT(e.nombre ORDER BY e.nombre SEPARATOR ', ')
FROM oficio_etiquetas oe
JOIN etiquetas e ON e.id = oe.etiqueta_id
WHERE oe.oficio_id = o.id) AS etiquetas
FROM oficios o
LEFT JOIN usuarios u_resp ON u_resp.id = o.responsable_id
LEFT JOIN usuarios u_crea ON u_crea.id = o.creado_por
WHERE o.deleted_at IS NULL;
-- ============================================================
-- VISTA: v_dashboard_kpis
-- ============================================================
CREATE OR REPLACE VIEW `v_dashboard_kpis` AS
SELECT
(SELECT COUNT(*) FROM oficios WHERE deleted_at IS NULL) AS total_oficios,
(SELECT COUNT(*) FROM oficios WHERE deleted_at IS NULL AND estado NOT IN ('respondido','archivado') AND fecha_vencimiento < CURDATE()) AS total_vencidos,
(SELECT COUNT(*) FROM oficios WHERE deleted_at IS NULL AND estado NOT IN ('respondido','archivado') AND fecha_vencimiento BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 3 DAY)) AS total_por_vencer,
(SELECT COUNT(*) FROM oficios WHERE deleted_at IS NULL AND estado IN ('respondido','archivado') AND MONTH(updated_at) = MONTH(CURDATE()) AND YEAR(updated_at) = YEAR(CURDATE())) AS completados_este_mes,
(SELECT COUNT(*) FROM oficios WHERE deleted_at IS NULL AND estado = 'en_proceso') AS en_proceso,
(SELECT COUNT(*) FROM usuarios WHERE activo = 1 AND deleted_at IS NULL) AS total_usuarios;

View File

@ -0,0 +1,37 @@
-- ============================================================
-- MIGRACIÓN: Transformación a Gestión ICT DTIC-EB
-- ============================================================
USE `gestion_documentos`;
-- Actualizar Etiquetas (Legal -> ICT)
UPDATE `etiquetas` SET `nombre` = 'Soporte Técnico', `color` = '#2e7d32', `icono` = 'fa-screwdriver-wrench' WHERE `nombre` = 'Contrato';
UPDATE `etiquetas` SET `nombre` = 'Ciberseguridad', `color` = '#c62828', `icono` = 'fa-shield-virus' WHERE `nombre` = 'Demanda';
UPDATE `etiquetas` SET `nombre` = 'Redes y Conectividad', `color` = '#0277bd', `icono` = 'fa-network-wired' WHERE `nombre` = 'Recurso';
UPDATE `etiquetas` SET `nombre` = 'Hardware / Equipos', `color` = '#f57c00', `icono` = 'fa-laptop' WHERE `nombre` = 'Solicitud';
UPDATE `etiquetas` SET `nombre` = 'Directivas Militares', `color` = '#4527a0', `icono` = 'fa-file-shield' WHERE `nombre` = 'Circular';
UPDATE `etiquetas` SET `nombre` = 'Convenios Interinstitucionales', `color` = '#00695c', `icono` = 'fa-handshake-angle' WHERE `nombre` = 'Convenio';
UPDATE `etiquetas` SET `nombre` = 'Desarrollo de Software', `color` = '#00838f', `icono` = 'fa-code' WHERE `nombre` = 'Resolución';
UPDATE `etiquetas` SET `nombre` = 'Telecomunicaciones', `color` = '#ad1457', `icono` = 'fa-tower-broadcast' WHERE `nombre` = 'Decreto';
-- Agregar nuevas etiquetas específicas de DTIC
INSERT IGNORE INTO `etiquetas` (`nombre`, `color`, `icono`) VALUES
('Inteligencia de Datos', '#283593', 'fa-brain'),
('Mantenimiento Preventivo', '#388e3c', 'fa-tools'),
('Infraestructura Crítica', '#d32f2f', 'fa-building-shield');
-- Actualizar roles (descripciones más militares/institucionales)
UPDATE `roles` SET `descripcion` = 'Comandante / Director TIC - Acceso total' WHERE `nombre` = 'administrador';
UPDATE `roles` SET `descripcion` = 'Jefe de División / Supervisor de Gestión' WHERE `nombre` = 'supervisor';
UPDATE `roles` SET `descripcion` = 'Personal Operativo de Informática' WHERE `nombre` = 'estandar';
-- Actualizar plantillas de respuesta
UPDATE `plantillas_respuesta` SET
`titulo` = 'Acuse de Recibo DTIC',
`contenido` = 'En atención a su comunicación N° {numero_oficio}, la Dirección de Tecnología de la Información y las Comunicaciones (DTIC) cumple con informar que se ha recibido la misma. Se procederá con el análisis técnico y el trámite correspondiente conforme a los protocolos institucionales.'
WHERE `titulo` = 'Acuse de recibo';
UPDATE `plantillas_respuesta` SET
`titulo` = 'Requerimiento de Factibilidad Técnica',
`contenido` = 'En relación al oficio N° {numero_oficio}, se requiere que la unidad solicitante proporcione mayores detalles técnicos sobre el requerimiento de infraestructura/software para realizar el informe de factibilidad correspondiente.'
WHERE `titulo` = 'Solicitud de información adicional';

View File

@ -0,0 +1,46 @@
-- ============================================================
-- MIGRACIÓN DTIC MESA DE PARTE - VERSIÓN 2.0
-- ============================================================
USE `gestion_documentos`;
-- 1. Crear tabla de Unidades DTIC
CREATE TABLE IF NOT EXISTS `unidades_dtic` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`nombre` VARCHAR(100) NOT NULL,
`siglas` VARCHAR(20) DEFAULT NULL,
`icono` VARCHAR(50) DEFAULT 'fa-building',
`responsable_id` INT UNSIGNED DEFAULT NULL,
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_unidad_nombre` (`nombre`),
CONSTRAINT `fk_unidad_responsable` FOREIGN KEY (`responsable_id`) REFERENCES `usuarios` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. Insertar Unidades DTIC solicitadas
INSERT IGNORE INTO `unidades_dtic` (`nombre`, `siglas`, `icono`) VALUES
('Programación', 'PROG', 'fa-code'),
('Administración', 'ADM', 'fa-file-invoice-dollar'),
('Personal', 'PERS', 'fa-users'),
('Adiestramiento', 'ADIE', 'fa-user-graduate'),
('Centro de Datos', 'DATA', 'fa-server'),
('Auditoría', 'AUDI', 'fa-magnifying-glass-chart'),
('Desarrollo', 'DES', 'fa-laptop-code'),
('Agraz', 'AGRAZ', 'fa-shield-halved');
-- 3. Actualizar tabla usuarios para incluir unidad_id
ALTER TABLE `usuarios` ADD COLUMN IF NOT EXISTS `unidad_id` INT UNSIGNED DEFAULT NULL AFTER `rol_id`;
ALTER TABLE `usuarios` ADD CONSTRAINT `fk_usuario_unidad` FOREIGN KEY (`unidad_id`) REFERENCES `unidades_dtic` (`id`) ON DELETE SET NULL;
-- 4. Actualizar tabla oficios para flujo de Mesa de Parte
ALTER TABLE `oficios` ADD COLUMN IF NOT EXISTS `unidad_destino_id` INT UNSIGNED DEFAULT NULL AFTER `destinatario`;
ALTER TABLE `oficios` ADD CONSTRAINT `fk_oficio_unidad_destino` FOREIGN KEY (`unidad_destino_id`) REFERENCES `unidades_dtic` (`id`) ON DELETE SET NULL;
-- 5. Expandir ENUM de estado en oficios (Asegurar que existan los nuevos estados)
-- Nota: En MySQL no se puede usar ADD COLUMN IF NOT EXISTS para modificar un ENUM fácilmente de forma segura sin recrear o usar ALTER.
-- Usaremos un ALTER directo asumiendo el esquema base.
ALTER TABLE `oficios` MODIFY COLUMN `estado` ENUM('recibido','en_proceso','respondido','vencido','archivado','cumplido','verificado') NOT NULL DEFAULT 'recibido';
-- 6. Insertar Rol "Super Administrator" si no existe (con acceso total++)
INSERT IGNORE INTO `roles` (`nombre`, `descripcion`, `permisos`) VALUES
('superadmin', 'Control absoluto del sistema y registros protegidos', '{"oficios":"CRUD","usuarios":"CRUD","reportes":true,"respaldo":true,"papelera_fisica":true,"config_alertas":true,"maestros":true}');

11
index.php Normal file
View File

@ -0,0 +1,11 @@
<?php
/**
* index.php Redirige a login o dashboard según sesión
*/
require_once __DIR__ . '/config/config.php';
iniciarSesion();
if (!empty($_SESSION['usuario_id'])) {
redirect(APP_URL . '/dashboard.php');
} else {
redirect(APP_URL . '/login.php');
}

254
login.php Normal file
View File

@ -0,0 +1,254 @@
<?php
/**
* login.php Página de inicio de sesión
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/controllers/AuthController.php';
$auth = new AuthController();
// Si ya está autenticado, redirigir
if ($auth->estaAutenticado()) {
redirect(APP_URL . '/dashboard.php');
}
// Procesar POST
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$auth->login();
}
$msgParam = $_GET['msg'] ?? '';
$errorMsg = $_SESSION['error_login'] ?? null;
$succMsg = $_SESSION['success_login'] ?? null;
unset($_SESSION['error_login'], $_SESSION['success_login']);
?>
<!DOCTYPE html>
<html lang="es" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Iniciar Sesión <?= APP_NAME ?></title>
<meta name="description" content="Sistema de Control de Gestión de Operaciones ICT">
<link rel="stylesheet" href="<?= APP_URL ?>/assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<style>
body { background: #0f172a; margin: 0; padding: 0; height: 100vh; overflow: hidden; display: flex; font-family: 'Inter', sans-serif; }
.split-layout { display: flex; width: 100%; height: 100%; }
.split-left {
flex: 1.3;
background: url('<?= APP_URL ?>/assets/img/login_bg.png') center/cover no-repeat;
position: relative;
display: flex;
flex-direction: column;
justify-content: flex-end;
color: white;
}
.split-overlay {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(15,23,42,0.1) 0%, rgba(15,23,42,0.95) 100%);
z-index: 1;
}
.split-content {
position: relative;
z-index: 2;
padding: 4rem;
max-width: 800px;
animation: fadeUp 1s ease forwards;
}
.split-content h2 {
font-size: 2.5rem;
font-weight: 800;
margin-bottom: 1rem;
background: linear-gradient(90deg, #fff, var(--primary));
-webkit-background-clip: text;
color: transparent;
letter-spacing: -0.5px;
}
.split-content p {
font-size: 1.1rem;
line-height: 1.8;
color: rgba(255,255,255,0.75);
margin-bottom: 2rem;
font-weight: 300;
}
.split-right {
width: 450px;
background: #0f172a;
display: flex;
flex-direction: column;
justify-content: center;
padding: 2.5rem;
box-shadow: -20px 0 50px rgba(0,0,0,0.5);
position: relative;
z-index: 10;
}
.login-container {
width: 100%;
max-width: 360px;
margin: 0 auto;
}
.login-header { text-align: center; margin-bottom: 2.5rem; }
.login-icon {
width: 54px; height: 54px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, var(--secondary), var(--primary));
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 1.5rem; color: #0f172a;
box-shadow: 0 4px 20px rgba(0,229,255,0.4);
}
.login-header h1 { font-size: 1.8rem; font-weight: 700; color: #fff; margin-bottom: 0.2rem; }
.login-header p { font-size: 0.85rem; color: var(--text-muted); }
.form-control {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 8px;
}
.form-control:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0,229,255,0.1);
}
.btn-submit {
background: linear-gradient(90deg, var(--primary), #00b8cc);
color: #000;
border: none;
font-weight: 600;
letter-spacing: 0.5px;
padding: 0.85rem;
border-radius: 8px;
margin-top: 1rem;
transition: all 0.3s ease;
width: 100%;
}
.btn-submit:hover {
box-shadow: 0 5px 20px rgba(0,229,255,0.4);
transform: translateY(-2px);
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 900px) {
.split-left { display: none; }
.split-right { width: 100%; }
}
</style>
</head>
<body>
<div class="split-layout">
<!-- Panel Izquierdo: Gráfico Informativo -->
<div class="split-left">
<div class="split-overlay"></div>
<div class="split-content">
<h2>Transformación Digital Operacional</h2>
<p>Bienvenido a la plataforma centralizada de la Dirección de Tecnología de la Información y las Comunicaciones (DTIC). Este sistema de comando ciber-militar garantiza la eficiencia, seguridad y trazabilidad de todas las operaciones tecnológicas del Ejército Bolivariano.</p>
<div style="display:flex; gap: 2rem;">
<div style="display:flex; align-items:center; gap: 0.8rem;">
<i class="fa-solid fa-microchip" style="color:var(--primary); font-size: 1.5rem;"></i>
<span style="font-size:0.9rem; font-weight:500;">Innovación Activa</span>
</div>
<div style="display:flex; align-items:center; gap: 0.8rem;">
<i class="fa-solid fa-shield-virus" style="color:var(--secondary); font-size: 1.5rem;"></i>
<span style="font-size:0.9rem; font-weight:500;">Ciberdefensa</span>
</div>
</div>
</div>
</div>
<!-- Panel Derecho: Formulario Moderno -->
<div class="split-right">
<div class="login-container">
<div class="login-header">
<div class="login-icon">
<i class="fa-solid fa-server"></i>
</div>
<h1>DTIC-EB</h1>
<p>Acceso seguro al sistema de gestión</p>
</div>
<?php if ($errorMsg): ?>
<div class="alert alert-danger" style="font-size: 0.85rem; padding: 0.8rem; border-radius: 8px;">
<i class="fa-solid fa-circle-exclamation"></i> <?= htmlspecialchars($errorMsg) ?>
</div>
<?php endif; ?>
<?php if ($succMsg): ?>
<div class="alert alert-success" style="font-size: 0.85rem; padding: 0.8rem; border-radius: 8px;">
<i class="fa-solid fa-circle-check"></i> <?= htmlspecialchars($succMsg) ?>
</div>
<?php endif; ?>
<form method="POST" action="<?= APP_URL ?>/login.php" novalidate>
<?= csrfField() ?>
<div class="form-group" style="margin-bottom: 1.25rem;">
<label class="form-label" for="cedula" style="font-size: 0.85rem; letter-spacing: 0.5px; opacity: 0.8;">CÉDULA DE IDENTIDAD</label>
<div style="position:relative">
<i class="fa-regular fa-id-card" style="position:absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,0.4);"></i>
<input type="text" class="form-control" id="cedula" name="cedula" placeholder="V-00000000" style="padding-left: 2.8rem; height: 3rem;" required>
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label class="form-label" for="password" style="font-size: 0.85rem; letter-spacing: 0.5px; opacity: 0.8;">CONTRASEÑA</label>
<div style="position:relative">
<i class="fa-solid fa-lock" style="position:absolute; left: 1rem; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,0.4);"></i>
<input type="password" class="form-control" id="password" name="password" placeholder="••••••••" style="padding-left: 2.8rem; padding-right: 2.8rem; height: 3rem;" autocomplete="current-password" required>
<button type="button" id="togglePass" style="position:absolute; right: 1rem; top: 50%; transform: translateY(-50%); background:none; border:none; color: var(--primary); cursor: pointer;">
<i class="fa-solid fa-eye" id="togglePassIcon"></i>
</button>
</div>
</div>
<button type="submit" class="btn-submit">
<i class="fa-solid fa-fingerprint" style="margin-right: 0.4rem;"></i> Autenticar
</button>
</form>
<div style="text-align:center; margin-top:2rem; display:flex; flex-direction:column; gap:0.8rem;">
<a href="<?= APP_URL ?>/recuperar_password.php" style="color:rgba(255,255,255,0.5); font-size: 0.8rem; text-decoration: none; transition: color 0.3s;" onmouseover="this.style.color='var(--primary)'" onmouseout="this.style.color='rgba(255,255,255,0.5)'">
¿Olvidaste tu contraseña?
</a>
<a href="<?= APP_URL ?>/register.php" style="color:var(--primary); font-size: 0.8rem; font-weight: 600; text-decoration: none; transition: all 0.3s;">
Solicitar acceso especial
</a>
</div>
</div>
</div>
</div>
<script>
document.getElementById('togglePass').addEventListener('click', function() {
const pass = document.getElementById('password');
const icon = document.getElementById('togglePassIcon');
if (pass.type === 'password') {
pass.type = 'text';
icon.classList.replace('fa-eye', 'fa-eye-slash');
} else {
pass.type = 'password';
icon.classList.replace('fa-eye-slash', 'fa-eye');
}
});
</script>
</body>
</html>

8
logout.php Normal file
View File

@ -0,0 +1,8 @@
<?php
/**
* logout.php Cierre de sesión
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/controllers/AuthController.php';
$auth = new AuthController();
$auth->logout();

0
logs/.gitkeep Normal file
View File

369
models/Oficio.php Normal file
View File

@ -0,0 +1,369 @@
<?php
/**
* Oficio.php Modelo de oficios
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
class OficioModel {
private PDO $db;
public function __construct() {
$this->db = getDB();
}
// ── Generar número de oficio ──────────────────────────────────────────────
public function generarNumero(string $tipo = 'REC'): string {
$anio = date('Y');
$prefijo = strtoupper($tipo === 'enviado' ? 'ENV' : 'REC');
$stmt = $this->db->prepare(
"SELECT numero_oficio FROM oficios WHERE numero_oficio LIKE ? ORDER BY id DESC LIMIT 1"
);
$stmt->execute(["$prefijo-$anio-%"]);
$ultimo = $stmt->fetchColumn();
$secuencia = 1;
if ($ultimo) {
$partes = explode('-', $ultimo);
$secuencia = (int)end($partes) + 1;
}
return sprintf('%s-%s-%04d', $prefijo, $anio, $secuencia);
}
// ── Listar oficios con filtros ────────────────────────────────────────────
public function listar(array $filtros = [], bool $soloPropio = false, int $usuarioId = 0): array {
$sql = "SELECT * FROM v_oficios_completo WHERE 1=1";
$params = [];
if ($soloPropio && $usuarioId) {
$sql .= " AND responsable_id = ?"; $params[] = $usuarioId;
}
if (!empty($filtros['tipo'])) {
$sql .= " AND tipo = ?"; $params[] = $filtros['tipo'];
}
if (!empty($filtros['estado'])) {
$sql .= " AND estado = ?"; $params[] = $filtros['estado'];
}
if (!empty($filtros['prioridad'])) {
$sql .= " AND prioridad = ?"; $params[] = $filtros['prioridad'];
}
if (!empty($filtros['responsable_id'])) {
$sql .= " AND responsable_id = ?"; $params[] = $filtros['responsable_id'];
}
if (!empty($filtros['fecha_desde'])) {
$sql .= " AND fecha_recepcion >= ?"; $params[] = $filtros['fecha_desde'];
}
if (!empty($filtros['fecha_hasta'])) {
$sql .= " AND fecha_recepcion <= ?"; $params[] = $filtros['fecha_hasta'];
}
if (!empty($filtros['semaforo'])) {
$sql .= " AND semaforo = ?"; $params[] = $filtros['semaforo'];
}
if (!empty($filtros['etiqueta_id'])) {
$sql .= " AND id IN (SELECT oficio_id FROM oficio_etiquetas WHERE etiqueta_id = ?)";
$params[] = $filtros['etiqueta_id'];
}
if (!empty($filtros['busqueda'])) {
$sql .= " AND MATCH(numero_oficio, remitente, destinatario, asunto) AGAINST(? IN BOOLEAN MODE)";
$params[] = $filtros['busqueda'] . '*';
}
$sql .= " ORDER BY fecha_vencimiento ASC, prioridad DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
// ── Papelera de reciclaje ─────────────────────────────────────────────────
public function papelera(): array {
$stmt = $this->db->query(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre
FROM oficios o LEFT JOIN usuarios u ON u.id = o.responsable_id
WHERE o.deleted_at IS NOT NULL ORDER BY o.deleted_at DESC"
);
return $stmt->fetchAll();
}
// ── Buscar por ID ─────────────────────────────────────────────────────────
public function buscarPorId(int $id): ?array {
$stmt = $this->db->prepare("SELECT * FROM v_oficios_completo WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
// ── Verificar duplicado ───────────────────────────────────────────────────
public function existeNumero(string $numero, ?int $excepto = null): bool {
$sql = "SELECT id FROM oficios WHERE numero_oficio = ? AND deleted_at IS NULL";
$params = [$numero];
if ($excepto) { $sql .= " AND id != ?"; $params[] = $excepto; }
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return (bool)$stmt->fetchColumn();
}
// ── Crear ─────────────────────────────────────────────────────────────────
public function crear(array $datos, int $creadoPor): int {
$stmt = $this->db->prepare(
"INSERT INTO oficios
(numero_oficio, tipo, remitente, destinatario, asunto, descripcion,
fecha_recepcion, fecha_vencimiento, prioridad, estado,
responsable_id, creado_por, es_confidencial)
VALUES
(:numero_oficio, :tipo, :remitente, :destinatario, :asunto, :descripcion,
:fecha_recepcion, :fecha_vencimiento, :prioridad, :estado,
:responsable_id, :creado_por, :es_confidencial)"
);
$stmt->execute([
':numero_oficio' => $datos['numero_oficio'],
':tipo' => $datos['tipo'],
':remitente' => $datos['remitente'],
':destinatario' => $datos['destinatario'],
':asunto' => $datos['asunto'],
':descripcion' => $datos['descripcion'] ?? null,
':fecha_recepcion' => $datos['fecha_recepcion'],
':fecha_vencimiento'=> $datos['fecha_vencimiento'] ?: null,
':prioridad' => $datos['prioridad'],
':estado' => $datos['estado'] ?? 'recibido',
':responsable_id' => $datos['responsable_id'] ?: null,
':creado_por' => $creadoPor,
':es_confidencial' => $datos['es_confidencial'] ?? 0,
]);
$id = (int)$this->db->lastInsertId();
// Guardar etiquetas
if (!empty($datos['etiquetas'])) {
$this->sincronizarEtiquetas($id, $datos['etiquetas']);
}
$this->registrarHistorial($id, 'crear', $creadoPor, 'Oficio creado.');
return $id;
}
// ── Actualizar ────────────────────────────────────────────────────────────
public function actualizar(int $id, array $datos, int $usuarioId): bool {
// Capturar valor anterior para auditoría
$anterior = $this->buscarPorId($id);
$stmt = $this->db->prepare(
"UPDATE oficios SET
numero_oficio=:numero_oficio, tipo=:tipo, remitente=:remitente,
destinatario=:destinatario, asunto=:asunto, descripcion=:descripcion,
fecha_recepcion=:fecha_recepcion, fecha_vencimiento=:fecha_vencimiento,
prioridad=:prioridad, estado=:estado, responsable_id=:responsable_id,
es_confidencial=:es_confidencial
WHERE id=:id AND deleted_at IS NULL"
);
$ok = $stmt->execute([
':numero_oficio' => $datos['numero_oficio'],
':tipo' => $datos['tipo'],
':remitente' => $datos['remitente'],
':destinatario' => $datos['destinatario'],
':asunto' => $datos['asunto'],
':descripcion' => $datos['descripcion'] ?? null,
':fecha_recepcion' => $datos['fecha_recepcion'],
':fecha_vencimiento'=> $datos['fecha_vencimiento'] ?: null,
':prioridad' => $datos['prioridad'],
':estado' => $datos['estado'],
':responsable_id' => $datos['responsable_id'] ?: null,
':es_confidencial' => $datos['es_confidencial'] ?? 0,
':id' => $id,
]);
if (!empty($datos['etiquetas'])) {
$this->sincronizarEtiquetas($id, $datos['etiquetas']);
}
if ($ok && $anterior) {
$cambios = [];
$campos = ['estado', 'prioridad', 'responsable_id', 'fecha_vencimiento', 'asunto'];
foreach ($campos as $campo) {
if (($anterior[$campo] ?? '') != ($datos[$campo] ?? '')) {
$cambios[] = "$campo: '{$anterior[$campo]}' → '{$datos[$campo]}'";
}
}
$detalle = implode(' | ', $cambios) ?: 'Sin cambios relevantes';
$this->registrarHistorial($id, 'editar', $usuarioId, $detalle);
}
return $ok;
}
// ── Eliminación lógica ────────────────────────────────────────────────────
public function eliminarLogico(int $id, int $usuarioId): bool {
$stmt = $this->db->prepare("UPDATE oficios SET deleted_at=NOW() WHERE id=?");
$ok = $stmt->execute([$id]);
if ($ok) $this->registrarHistorial($id, 'eliminar', $usuarioId, 'Movido a papelera.');
return $ok;
}
// ── Eliminación física (solo admin) ───────────────────────────────────────
public function eliminarFisico(int $id): bool {
$stmt = $this->db->prepare("DELETE FROM oficios WHERE id=?");
return $stmt->execute([$id]);
}
// ── Restaurar desde papelera ──────────────────────────────────────────────
public function restaurar(int $id, int $usuarioId): bool {
$stmt = $this->db->prepare("UPDATE oficios SET deleted_at=NULL WHERE id=?");
$ok = $stmt->execute([$id]);
if ($ok) $this->registrarHistorial($id, 'restaurar', $usuarioId, 'Restaurado desde papelera.');
return $ok;
}
// ── Derivar oficio ────────────────────────────────────────────────────────
public function derivar(int $id, int $nuevoResponsable, int $usuarioId, ?string $comentario = null): bool {
$stmt = $this->db->prepare(
"UPDATE oficios SET responsable_id=?, derivado_a_id=?, comentario_derivacion=?, estado='en_proceso'
WHERE id=? AND deleted_at IS NULL"
);
$ok = $stmt->execute([$nuevoResponsable, $nuevoResponsable, $comentario, $id]);
if ($ok) {
$this->registrarHistorial($id, 'derivar', $usuarioId, "Derivado a usuario #$nuevoResponsable. Comentario: $comentario");
}
return $ok;
}
// ── Historial de cambios ──────────────────────────────────────────────────
public function historial(int $id): array {
$stmt = $this->db->prepare(
"SELECT h.*, CONCAT(u.nombre,' ',u.apellido) AS usuario_nombre
FROM historial_cambios h
LEFT JOIN usuarios u ON u.id = h.usuario_id
WHERE h.tabla='oficios' AND h.registro_id=?
ORDER BY h.created_at DESC"
);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
// ── KPIs para dashboard ───────────────────────────────────────────────────
public function kpis(?int $usuarioId = null): array {
if ($usuarioId) {
$where = "AND o.responsable_id = $usuarioId";
} else {
$where = '';
}
$row = $this->db->query("SELECT * FROM v_dashboard_kpis")->fetch();
$stmt = $this->db->prepare(
"SELECT estado, COUNT(*) as total FROM oficios WHERE deleted_at IS NULL $where GROUP BY estado"
);
$stmt->execute();
$estados = [];
foreach ($stmt->fetchAll() as $r) {
$estados[$r['estado']] = (int)$r['total'];
}
$stmt2 = $this->db->prepare(
"SELECT prioridad, COUNT(*) as total FROM oficios WHERE deleted_at IS NULL $where GROUP BY prioridad"
);
$stmt2->execute();
$prioridades = [];
foreach ($stmt2->fetchAll() as $r) {
$prioridades[$r['prioridad']] = (int)$r['total'];
}
return ['global' => $row, 'estados' => $estados, 'prioridades' => $prioridades];
}
// ── Próximos a vencer (para cron y dashboard) ─────────────────────────────
public function proximosAVencer(int $dias = 3): array {
$stmt = $this->db->prepare(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre, u.email AS responsable_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
ORDER BY o.fecha_vencimiento ASC"
);
$stmt->execute([$dias]);
return $stmt->fetchAll();
}
// ── Vencidos sin respuesta (para escalación) ──────────────────────────────
public function vencidosSinRespuesta(): array {
$stmt = $this->db->query(
"SELECT o.*, CONCAT(u.nombre,' ',u.apellido) AS responsable_nombre,
u.email AS responsable_email, u.supervisor_id,
CONCAT(s.nombre,' ',s.apellido) AS supervisor_nombre, s.email AS supervisor_email
FROM oficios o
LEFT JOIN usuarios u ON u.id = o.responsable_id
LEFT JOIN usuarios s ON s.id = u.supervisor_id
WHERE o.deleted_at IS NULL
AND o.estado NOT IN ('respondido','archivado')
AND o.fecha_vencimiento < CURDATE()
ORDER BY o.fecha_vencimiento ASC"
);
return $stmt->fetchAll();
}
// ── Sincronizar etiquetas ─────────────────────────────────────────────────
private function sincronizarEtiquetas(int $oficioId, array $etiquetaIds): void {
$this->db->prepare("DELETE FROM oficio_etiquetas WHERE oficio_id = ?")->execute([$oficioId]);
$stmt = $this->db->prepare("INSERT INTO oficio_etiquetas (oficio_id, etiqueta_id) VALUES (?, ?)");
foreach ($etiquetaIds as $eId) {
if ((int)$eId > 0) $stmt->execute([$oficioId, (int)$eId]);
}
}
// ── Registrar historial ───────────────────────────────────────────────────
private function registrarHistorial(int $registroId, string $accion, int $usuarioId, string $detalle = ''): void {
$stmt = $this->db->prepare(
"INSERT INTO historial_cambios (tabla, registro_id, accion, usuario_id, ip_address, detalle)
VALUES ('oficios', ?, ?, ?, ?, ?)"
);
$stmt->execute([$registroId, $accion, $usuarioId, $_SERVER['REMOTE_ADDR'] ?? null, $detalle]);
}
// ── Etiquetas disponibles ─────────────────────────────────────────────────
public function etiquetas(): array {
return $this->db->query("SELECT * FROM etiquetas ORDER BY nombre")->fetchAll();
}
// ── Etiquetas de un oficio ────────────────────────────────────────────────
public function etiquetasDeOficio(int $id): array {
$stmt = $this->db->prepare(
"SELECT e.* FROM etiquetas e
JOIN oficio_etiquetas oe ON oe.etiqueta_id = e.id
WHERE oe.oficio_id = ?"
);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
// ── Comentarios ───────────────────────────────────────────────────────────
public function comentarios(int $id, bool $soloPublicos = false): array {
$sql = "SELECT c.*, CONCAT(u.nombre,' ',u.apellido) AS autor_nombre
FROM comentarios c JOIN usuarios u ON u.id = c.usuario_id
WHERE c.oficio_id = ?";
if ($soloPublicos) $sql .= " AND c.es_privado = 0";
$sql .= " ORDER BY c.created_at DESC";
$stmt = $this->db->prepare($sql);
$stmt->execute([$id]);
return $stmt->fetchAll();
}
public function agregarComentario(int $oficioId, int $usuarioId, string $texto, bool $privado = false): int {
$stmt = $this->db->prepare(
"INSERT INTO comentarios (oficio_id, usuario_id, comentario, es_privado) VALUES (?,?,?,?)"
);
$stmt->execute([$oficioId, $usuarioId, $texto, (int)$privado]);
return (int)$this->db->lastInsertId();
}
// ── Estadísticas mensuales (para gráfico de línea) ───────────────────────
public function estadisticasMensuales(int $meses = 6): array {
$stmt = $this->db->prepare(
"SELECT DATE_FORMAT(created_at,'%Y-%m') AS mes,
estado, COUNT(*) AS total
FROM oficios
WHERE deleted_at IS NULL
AND created_at >= DATE_SUB(NOW(), INTERVAL ? MONTH)
GROUP BY mes, estado
ORDER BY mes"
);
$stmt->execute([$meses]);
return $stmt->fetchAll();
}
}

150
models/Usuario.php Normal file
View File

@ -0,0 +1,150 @@
<?php
/**
* Usuario.php Modelo de usuarios
*/
declare(strict_types=1);
require_once __DIR__ . '/../config/config.php';
class UsuarioModel {
private PDO $db;
public function __construct() {
$this->db = getDB();
}
public function todos(bool $incluirEliminados = false): array {
$sql = "SELECT u.*, r.nombre AS rol_nombre
FROM usuarios u
JOIN roles r ON r.id = u.rol_id
WHERE u.activo = 1";
if (!$incluirEliminados) $sql .= " AND u.deleted_at IS NULL";
$sql .= " ORDER BY u.nombre, u.apellido";
return $this->db->query($sql)->fetchAll();
}
public function buscarPorId(int $id): ?array {
$stmt = $this->db->prepare(
"SELECT u.*, r.nombre AS rol_nombre, r.permisos
FROM usuarios u JOIN roles r ON r.id = u.rol_id
WHERE u.id = ? AND u.deleted_at IS NULL"
);
$stmt->execute([$id]);
return $stmt->fetch() ?: null;
}
public function buscarPorUsername(string $username): ?array {
$stmt = $this->db->prepare(
"SELECT u.*, r.nombre AS rol_nombre, r.permisos
FROM usuarios u JOIN roles r ON r.id = u.rol_id
WHERE u.username = ? AND u.deleted_at IS NULL"
);
$stmt->execute([$username]);
return $stmt->fetch() ?: null;
}
public function buscarPorEmail(string $email): ?array {
$stmt = $this->db->prepare(
"SELECT * FROM usuarios WHERE email = ? AND deleted_at IS NULL"
);
$stmt->execute([$email]);
return $stmt->fetch() ?: null;
}
public function buscarPorToken(string $token): ?array {
$stmt = $this->db->prepare(
"SELECT * FROM usuarios WHERE token_recuperacion = ? AND deleted_at IS NULL"
);
$stmt->execute([$token]);
return $stmt->fetch() ?: null;
}
public function crear(array $datos): int {
$stmt = $this->db->prepare(
"INSERT INTO usuarios (rol_id, nombre, apellido, email, username, password_hash, cargo, area, supervisor_id)
VALUES (:rol_id, :nombre, :apellido, :email, :username, :password_hash, :cargo, :area, :supervisor_id)"
);
$stmt->execute([
':rol_id' => $datos['rol_id'],
':nombre' => $datos['nombre'],
':apellido' => $datos['apellido'],
':email' => $datos['email'],
':username' => $datos['username'],
':password_hash' => password_hash($datos['password'], PASSWORD_BCRYPT, ['cost' => 12]),
':cargo' => $datos['cargo'] ?? null,
':area' => $datos['area'] ?? null,
':supervisor_id' => $datos['supervisor_id'] ?? null,
]);
return (int)$this->db->lastInsertId();
}
public function actualizar(int $id, array $datos): bool {
$stmt = $this->db->prepare(
"UPDATE usuarios SET rol_id=:rol_id, nombre=:nombre, apellido=:apellido,
email=:email, username=:username, cargo=:cargo, area=:area,
supervisor_id=:supervisor_id, activo=:activo
WHERE id=:id"
);
return $stmt->execute([
':rol_id' => $datos['rol_id'],
':nombre' => $datos['nombre'],
':apellido' => $datos['apellido'],
':email' => $datos['email'],
':username' => $datos['username'],
':cargo' => $datos['cargo'] ?? null,
':area' => $datos['area'] ?? null,
':supervisor_id'=> $datos['supervisor_id'] ?? null,
':activo' => $datos['activo'] ?? 1,
':id' => $id,
]);
}
public function actualizarPassword(int $id, string $hash): bool {
$stmt = $this->db->prepare(
"UPDATE usuarios SET password_hash=?, token_recuperacion=NULL, token_expira=NULL WHERE id=?"
);
return $stmt->execute([$hash, $id]);
}
public function actualizarUltimoLogin(int $id): void {
$stmt = $this->db->prepare("UPDATE usuarios SET ultimo_login=NOW() WHERE id=?");
$stmt->execute([$id]);
}
public function guardarTokenRecuperacion(int $id, string $token, string $expira): void {
$stmt = $this->db->prepare(
"UPDATE usuarios SET token_recuperacion=?, token_expira=? WHERE id=?"
);
$stmt->execute([$token, $expira, $id]);
}
public function eliminarLogico(int $id): bool {
$stmt = $this->db->prepare("UPDATE usuarios SET deleted_at=NOW(), activo=0 WHERE id=?");
return $stmt->execute([$id]);
}
public function restaurar(int $id): bool {
$stmt = $this->db->prepare("UPDATE usuarios SET deleted_at=NULL, activo=1 WHERE id=?");
return $stmt->execute([$id]);
}
public function emailExiste(string $email, ?int $excepto = null): bool {
$sql = "SELECT id FROM usuarios WHERE email=? AND deleted_at IS NULL";
$params = [$email];
if ($excepto) { $sql .= " AND id != ?"; $params[] = $excepto; }
return (bool)$this->db->prepare($sql)->execute($params)
&& $this->db->query("SELECT FOUND_ROWS()")->fetchColumn();
}
public function roles(): array {
return $this->db->query("SELECT * FROM roles ORDER BY id")->fetchAll();
}
public function usuariosParaSelector(): array {
return $this->db->query(
"SELECT id, CONCAT(nombre,' ',apellido) AS nombre_completo, email, area
FROM usuarios WHERE activo=1 AND deleted_at IS NULL ORDER BY nombre"
)->fetchAll();
}
}

90
recuperar_password.php Normal file
View File

@ -0,0 +1,90 @@
<?php
/**
* recuperar_password.php Formulario recuperación de contraseña
*/
require_once __DIR__ . '/config/config.php';
require_once __DIR__ . '/controllers/AuthController.php';
$auth = new AuthController();
$token = clean($_GET['token'] ?? '');
$fase = $token ? 'restablecer' : 'solicitar';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($fase === 'solicitar') {
$auth->solicitarRecuperacion();
} else {
$auth->restablecerPassword();
}
}
$errorMsg = $_SESSION['error_reset'] ?? null;
$infoMsg = $_SESSION['info_recuperacion'] ?? null;
unset($_SESSION['error_reset'], $_SESSION['info_recuperacion']);
?>
<!DOCTYPE html>
<html lang="es" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Recuperar Contraseña <?= APP_NAME ?></title>
<link rel="stylesheet" href="<?= APP_URL ?>/assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
<div class="login-page">
<div class="login-card">
<div class="login-logo">
<div class="login-logo-icon"><i class="fa-solid fa-key"></i></div>
<h1><?= APP_NAME ?></h1>
<p><?= $fase === 'solicitar' ? 'Recuperación de contraseña' : 'Establecer nueva contraseña' ?></p>
</div>
<?php if ($errorMsg): ?>
<div class="alert alert-danger"><i class="fa-solid fa-circle-exclamation"></i> <?= htmlspecialchars($errorMsg) ?></div>
<?php endif; ?>
<?php if ($infoMsg): ?>
<div class="alert alert-info"><i class="fa-solid fa-circle-info"></i> <?= htmlspecialchars($infoMsg) ?></div>
<?php endif; ?>
<?php if ($fase === 'solicitar'): ?>
<!-- Fase 1: Solicitar email -->
<form method="POST">
<?= csrfField() ?>
<div class="form-group">
<label class="form-label">Email registrado</label>
<input type="email" class="form-control" name="email" placeholder="tu@email.com" required autofocus>
</div>
<button type="submit" class="btn btn-primary w-100 btn-lg">
<i class="fa-solid fa-paper-plane"></i> Enviar enlace de recuperación
</button>
</form>
<?php else: ?>
<!-- Fase 2: Nueva contraseña -->
<form method="POST">
<?= csrfField() ?>
<input type="hidden" name="token" value="<?= htmlspecialchars($token) ?>">
<div class="form-group">
<label class="form-label">Nueva contraseña</label>
<input type="password" class="form-control" name="password" minlength="8" required placeholder="Mínimo 8 caracteres">
</div>
<div class="form-group">
<label class="form-label">Confirmar contraseña</label>
<input type="password" class="form-control" name="password_confirm" minlength="8" required placeholder="Repetir contraseña">
</div>
<button type="submit" class="btn btn-primary w-100 btn-lg">
<i class="fa-solid fa-lock"></i> Establecer nueva contraseña
</button>
</form>
<?php endif; ?>
<div style="text-align:center;margin-top:1.25rem">
<a href="<?= APP_URL ?>/login.php" style="color:rgba(255,255,255,.55);font-size:.8rem;text-decoration:none">
<i class="fa-solid fa-arrow-left"></i> Volver al login
</a>
</div>
</div>
</div>
</body>
</html>

6
uploads/.htaccess Normal file
View File

@ -0,0 +1,6 @@
# Uploads folder block direct script execution
<FilesMatch "\.(php|php3|php4|php5|phtml|pl|py|cgi|sh)$">
Order Deny,Allow
Deny from all
</FilesMatch>
Options -Indexes

View File

@ -0,0 +1,156 @@
<?php
/**
* configuracion.php Configuración de alertas (solo admin)
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
AuthController::requerirAdmin();
$db = getDB();
// Guardar cambios
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verificarCsrf();
$alertas = $_POST['alertas'] ?? [];
foreach ($alertas as $id => $a) {
$db->prepare(
"UPDATE alertas_config SET nombre=?, dias_antes=?, enviar_email=?, enviar_notificacion=?, activo=? WHERE id=?"
)->execute([
clean($a['nombre'] ?? ''),
(int)($a['dias_antes'] ?? 0),
isset($a['enviar_email']) ? 1 : 0,
isset($a['enviar_notificacion']) ? 1 : 0,
isset($a['activo']) ? 1 : 0,
(int)$id
]);
}
// Nueva alerta
if (!empty($_POST['nueva_nombre'])) {
$db->prepare(
"INSERT INTO alertas_config(nombre,dias_antes,enviar_email,enviar_notificacion,activo) VALUES(?,?,?,?,1)"
)->execute([
clean($_POST['nueva_nombre']),
(int)($_POST['nueva_dias'] ?? 0),
isset($_POST['nueva_email']) ? 1 : 0,
isset($_POST['nueva_notif']) ? 1 : 0,
]);
}
redirect(APP_URL.'/views/alertas/configuracion.php?success='.urlencode('Configuración guardada.'));
}
$alertasConfig = $db->query("SELECT * FROM alertas_config ORDER BY dias_antes DESC")->fetchAll();
$pageTitle = 'Configuración de Alertas';
$activeNav = 'alertas';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Configuración de Alertas</span>
</div>
<?php $success = $_GET['success'] ?? ''; ?>
<?php if ($success): ?><div class="alert alert-success"><i class="fa-solid fa-circle-check"></i> <?= htmlspecialchars($success) ?></div><?php endif; ?>
<div class="page-header">
<div class="page-header-content">
<h1>Configuración de Alertas</h1>
<p>Define cuándo enviar notificaciones automáticas por vencimiento de oficios</p>
</div>
</div>
<div class="alert alert-info mb-4">
<i class="fa-solid fa-circle-info"></i>
El cron job <code>cron/enviar_alertas.php</code> debe ejecutarse diariamente para que las alertas funcionen automáticamente.
Configure en su servidor: <code>0 8 * * * php <?= BASE_PATH ?>/cron/enviar_alertas.php</code>
</div>
<form method="POST">
<?= csrfField() ?>
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-bell text-warning"></i>
<span class="card-title">Alertas Configuradas</span>
</div>
<div class="card-body" style="padding:0">
<table class="table">
<thead>
<tr><th>Nombre</th><th>Días antes<br><small style="font-weight:normal">(negativo = días después)</small></th><th>Email</th><th>Notificación</th><th>Activo</th></tr>
</thead>
<tbody>
<?php foreach ($alertasConfig as $a): ?>
<tr>
<td><input type="text" class="form-control" name="alertas[<?= $a['id'] ?>][nombre]" value="<?= htmlspecialchars($a['nombre']) ?>" style="min-width:220px"></td>
<td><input type="number" class="form-control" name="alertas[<?= $a['id'] ?>][dias_antes]" value="<?= $a['dias_antes'] ?>" style="width:80px"></td>
<td>
<label style="cursor:pointer">
<input type="checkbox" name="alertas[<?= $a['id'] ?>][enviar_email]" <?= $a['enviar_email']?'checked':'' ?>>
Email
</label>
</td>
<td>
<label style="cursor:pointer">
<input type="checkbox" name="alertas[<?= $a['id'] ?>][enviar_notificacion]" <?= $a['enviar_notificacion']?'checked':'' ?>>
Sistema
</label>
</td>
<td>
<label style="cursor:pointer">
<input type="checkbox" name="alertas[<?= $a['id'] ?>][activo]" <?= $a['activo']?'checked':'' ?>>
<span class="badge <?= $a['activo']?'badge-success':'badge-secondary' ?>"><?= $a['activo']?'Activa':'Inactiva' ?></span>
</label>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Nueva alerta -->
<div class="card mb-4">
<div class="card-header"><i class="fa-solid fa-plus text-success"></i><span class="card-title">Agregar Nueva Alerta</span></div>
<div class="card-body">
<div class="form-row" style="grid-template-columns:2fr 1fr 1fr 1fr">
<div class="form-group">
<label class="form-label">Nombre de la alerta</label>
<input type="text" class="form-control" name="nueva_nombre" placeholder="Ej. Alerta 5 días antes">
</div>
<div class="form-group">
<label class="form-label">Días antes</label>
<input type="number" class="form-control" name="nueva_dias" value="5">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input type="checkbox" name="nueva_email" checked style="margin-top:.5rem;width:20px;height:20px">
</div>
<div class="form-group">
<label class="form-label">Notificación</label>
<input type="checkbox" name="nueva_notif" checked style="margin-top:.5rem;width:20px;height:20px">
</div>
</div>
</div>
</div>
<div class="d-flex gap-3">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fa-solid fa-floppy-disk"></i> Guardar Configuración
</button>
<a href="<?= APP_URL ?>/cron/enviar_alertas.php?secret=<?= env('ALERT_CRON_SECRET') ?>"
class="btn btn-warning btn-lg"
target="_blank"
data-confirm="¿Ejecutar el proceso de alertas ahora?">
<i class="fa-solid fa-play"></i> Ejecutar Alertas Ahora (Test)
</a>
</div>
</form>
</div>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

18
views/errors/403.php Normal file
View File

@ -0,0 +1,18 @@
<?php
// views/errors/403.php
?>
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Acceso Denegado</title>
<link rel="stylesheet" href="<?= APP_URL ?>/assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body style="display:grid;place-items:center;min-height:100vh;background:var(--bg)">
<div style="text-align:center;padding:2rem">
<div style="font-size:5rem;margin-bottom:1rem;opacity:.3"><i class="fa-solid fa-lock"></i></div>
<h1 style="font-size:2rem;font-weight:800;margin-bottom:.5rem">403 Acceso Denegado</h1>
<p style="color:var(--text-muted);margin-bottom:1.5rem">No tienes permisos para acceder a esta sección.</p>
<a href="<?= APP_URL ?>/dashboard.php" class="btn btn-primary"><i class="fa-solid fa-house"></i> Volver al Dashboard</a>
</div>
</body>
</html>

19
views/layout/footer.php Normal file
View File

@ -0,0 +1,19 @@
<!-- Footer -->
<footer style="padding:.875rem 1.5rem;border-top:1px solid var(--border);color:var(--text-muted);font-size:.73rem;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem">
<span><?= INSTITUCION_NOMBRE ?> &copy; <?= date('Y') ?> — Sistema de Gestión de Documentación v1.0</span>
<span>Usuario: <strong><?= htmlspecialchars($_SESSION['usuario_nombre'] ?? '') ?></strong> | Rol: <?= ucfirst($_SESSION['usuario_rol'] ?? '') ?></span>
</footer>
</div><!-- /.main-content -->
</div><!-- /.app-wrapper -->
<!-- Toast container -->
<div class="toast-container" id="toastContainer"></div>
<!-- Scripts globales -->
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/jquery.dataTables.min.js"></script>
<script src="https://cdn.datatables.net/1.13.8/js/dataTables.bootstrap5.min.js"></script>
<script src="<?= APP_URL ?>/assets/js/app.js"></script>
</body>
</html>

20
views/layout/header.php Normal file
View File

@ -0,0 +1,20 @@
<?php
/**
* header.php Encabezado HTML global
* Variables esperadas: $pageTitle, $activeNav
*/
defined('APP_NAME') || require_once __DIR__ . '/../../config/config.php';
?>
<!DOCTYPE html>
<html lang="es" data-theme="<?= $_SESSION['tema'] ?? 'light' ?>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($pageTitle ?? 'Dashboard') ?> <?= APP_NAME ?></title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="<?= APP_URL ?>/assets/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.8/css/dataTables.bootstrap5.min.css">
</head>
<body>
<div class="app-wrapper">

89
views/layout/sidebar.php Normal file
View File

@ -0,0 +1,89 @@
<?php
/**
* sidebar.php Barra lateral de navegación
* Variables esperadas: $activeNav (string)
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../models/Oficio.php';
$esAdmin = ($_SESSION['usuario_rol'] ?? '') === 'administrador';
$esSupervisor = in_array($_SESSION['usuario_rol'] ?? '', ['administrador','supervisor']);
$usuarioNombre = $_SESSION['usuario_nombre'] ?? 'Usuario';
$usuarioRol = $_SESSION['usuario_rol'] ?? '';
// Contar notificaciones no leídas
$db = getDB();
$stmt = $db->prepare("SELECT COUNT(*) FROM notificaciones WHERE usuario_id=? AND leida=0");
$stmt->execute([$_SESSION['usuario_id'] ?? 0]);
$notifCount = (int)$stmt->fetchColumn();
// Iniciales del usuario
$iniciales = implode('', array_map(fn($p) => strtoupper($p[0]), array_slice(explode(' ', $usuarioNombre), 0, 2)));
function navItem(string $href, string $icon, string $label, string $active, string $badge = ''): string {
$cls = $active ? ' active' : '';
$b = $badge ? '<span class="nav-badge">'.$badge.'</span>' : '';
return "<a href=\"$href\" class=\"nav-item$cls\"><i class=\"$icon\"></i><span>$label</span>$b</a>";
}
$nav = $activeNav ?? '';
?>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<!-- Brand -->
<a href="<?= APP_URL ?>/dashboard.php" class="sidebar-brand">
<div class="sidebar-brand-icon">
<i class="fa-solid fa-shield-halved"></i>
</div>
<div class="sidebar-brand-text">
<?= APP_NAME ?>
<span>Gestión ICT - DTIC</span>
</div>
</a>
<!-- Navigation -->
<nav class="sidebar-nav">
<div class="nav-section-title">Principal</div>
<?= navItem(APP_URL.'/dashboard.php', 'fa-solid fa-chart-pie', 'Dashboard', $nav === 'dashboard') ?>
<div class="nav-section-title">Oficios</div>
<?= navItem(APP_URL.'/views/oficios/lista.php?tipo=recibido', 'fa-solid fa-inbox', 'Bandeja Entrada', $nav === 'entrada') ?>
<?= navItem(APP_URL.'/views/oficios/lista.php?tipo=enviado', 'fa-solid fa-paper-plane', 'Bandeja Salida', $nav === 'salida') ?>
<?= navItem(APP_URL.'/views/oficios/crear.php', 'fa-solid fa-plus-circle', 'Nuevo Oficio', $nav === 'nuevo') ?>
<?= navItem(APP_URL.'/views/oficios/lista.php', 'fa-solid fa-list', 'Todos los Oficios', $nav === 'lista') ?>
<div class="nav-section-title">Seguimiento</div>
<?= navItem(APP_URL.'/views/oficios/lista.php?semaforo=vencido', 'fa-solid fa-circle-xmark text-danger', 'Vencidos', $nav === 'vencidos') ?>
<?= navItem(APP_URL.'/views/oficios/lista.php?semaforo=proximo', 'fa-solid fa-clock text-warning', 'Por Vencer', $nav === 'porvencer') ?>
<?= navItem(APP_URL.'/views/oficios/papelera.php', 'fa-solid fa-trash', 'Papelera', $nav === 'papelera') ?>
<div class="nav-section-title">Reportes</div>
<?= navItem(APP_URL.'/views/reportes/index.php', 'fa-solid fa-chart-bar', 'Reportes', $nav === 'reportes') ?>
<?php if ($esAdmin || $esSupervisor): ?>
<div class="nav-section-title">Administración</div>
<?php if ($esAdmin): ?>
<?= navItem(APP_URL.'/views/usuarios/lista.php', 'fa-solid fa-users', 'Usuarios', $nav === 'usuarios') ?>
<?= navItem(APP_URL.'/views/alertas/configuracion.php','fa-solid fa-bell', 'Alertas', $nav === 'alertas', $notifCount ?: '') ?>
<?= navItem(APP_URL.'/views/reportes/respaldo.php', 'fa-solid fa-database', 'Respaldo BD', $nav === 'respaldo') ?>
<?= navItem(APP_URL.'/views/reportes/log.php', 'fa-solid fa-scroll', 'Log Actividad', $nav === 'log') ?>
<?php endif; ?>
<?php endif; ?>
</nav>
<!-- Footer del sidebar -->
<div class="sidebar-footer">
<div class="user-mini">
<div class="user-mini-avatar"><?= $iniciales ?></div>
<div class="user-mini-info">
<div class="user-mini-name"><?= htmlspecialchars($usuarioNombre) ?></div>
<div class="user-mini-role"><?= ucfirst($usuarioRol) ?></div>
</div>
<a href="<?= APP_URL ?>/logout.php" class="btn-icon" title="Cerrar sesión" style="color:rgba(255,255,255,.4);font-size:.9rem">
<i class="fa-solid fa-right-from-bracket"></i>
</a>
</div>
</div>
</aside>

117
views/layout/topbar.php Normal file
View File

@ -0,0 +1,117 @@
<?php
/**
* topbar.php Barra superior del sistema
* Variables esperadas: $pageTitle, $breadcrumbs (array)
*/
$db = getDB();
$stmt = $db->prepare("SELECT COUNT(*) FROM notificaciones WHERE usuario_id=? AND leida=0");
$stmt->execute([$_SESSION['usuario_id'] ?? 0]);
$notifCount = (int)$stmt->fetchColumn();
$stmt2 = $db->prepare(
"SELECT n.*, o.numero_oficio FROM notificaciones n
LEFT JOIN oficios o ON o.id = n.oficio_id
WHERE n.usuario_id = ? AND n.leida = 0
ORDER BY n.created_at DESC LIMIT 8"
);
$stmt2->execute([$_SESSION['usuario_id'] ?? 0]);
$notifList = $stmt2->fetchAll();
$tipoIconos = [
'vencimiento' => ['icon'=>'fa-clock', 'color'=>'#ef4444'],
'derivacion' => ['icon'=>'fa-reply', 'color'=>'#3b82f6'],
'escalacion' => ['icon'=>'fa-arrow-up', 'color'=>'#f59e0b'],
'sistema' => ['icon'=>'fa-bell', 'color'=>'#6f42c1'],
'tarea' => ['icon'=>'fa-check-circle', 'color'=>'#10b981'],
];
?>
<div class="main-content" id="mainContent">
<!-- Topbar -->
<header class="topbar">
<!-- Hamburger (móvil) -->
<button class="btn-icon" id="sidebarToggle" aria-label="Menú">
<i class="fa-solid fa-bars"></i>
</button>
<!-- Title -->
<div class="topbar-title" id="topbarTitle">
<?= htmlspecialchars($pageTitle ?? 'Dashboard') ?>
</div>
<!-- Actions -->
<div class="topbar-actions">
<!-- Búsqueda global -->
<div class="search-bar" style="display:none;" id="globalSearchWrap">
<i class="fa-solid fa-search"></i>
<input type="text" class="form-control" placeholder="Buscar oficios…" id="globalSearch" style="width:220px">
</div>
<button class="btn-icon" id="toggleSearch" title="Buscar">
<i class="fa-solid fa-search"></i>
</button>
<!-- Toggle modo oscuro -->
<button class="btn-icon" id="themeToggle" title="Cambiar tema">
<i class="fa-solid fa-moon" id="themeIcon"></i>
</button>
<!-- Notificaciones -->
<div style="position:relative">
<button class="btn-icon" id="notifToggle" title="Notificaciones">
<i class="fa-solid fa-bell"></i>
<?php if ($notifCount): ?>
<span class="notif-dot"></span>
<?php endif; ?>
</button>
<!-- Dropdown notificaciones -->
<div class="notif-dropdown" id="notifDropdown">
<div style="padding:.875rem 1rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
<strong style="font-size:.88rem">Notificaciones</strong>
<?php if ($notifCount): ?>
<span class="badge badge-danger"><?= $notifCount ?> nuevas</span>
<?php endif; ?>
</div>
<?php if (empty($notifList)): ?>
<div style="padding:1.5rem;text-align:center;color:var(--text-muted);font-size:.83rem">
<i class="fa-solid fa-bell-slash" style="font-size:1.5rem;margin-bottom:.5rem;display:block;opacity:.4"></i>
Sin notificaciones pendientes
</div>
<?php else: ?>
<?php foreach ($notifList as $n):
$ic = $tipoIconos[$n['tipo']] ?? $tipoIconos['sistema'];
?>
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $n['oficio_id'] ?>&notif=<?= $n['id'] ?>"
class="notif-item <?= $n['leida'] ? '' : 'unread' ?>">
<div class="notif-icon" style="background:<?= $ic['color'] ?>20;color:<?= $ic['color'] ?>">
<i class="fa-solid <?= $ic['icon'] ?>"></i>
</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:.8rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?= htmlspecialchars($n['titulo']) ?>
</div>
<div style="font-size:.73rem;color:var(--text-muted);margin-top:.15rem">
<?= htmlspecialchars(mb_strimwidth($n['mensaje'], 0, 70, '…')) ?>
</div>
<div style="font-size:.7rem;color:var(--text-muted);margin-top:.2rem">
<?= date('d/m/Y H:i', strtotime($n['created_at'])) ?>
</div>
</div>
</a>
<?php endforeach; ?>
<a href="<?= APP_URL ?>/views/notificaciones.php" style="display:block;padding:.75rem;text-align:center;font-size:.8rem;color:var(--primary);text-decoration:none;border-top:1px solid var(--border)">
Ver todas las notificaciones
</a>
<?php endif; ?>
</div>
</div>
<!-- Avatar usuario -->
<a href="<?= APP_URL ?>/views/usuarios/perfil.php" class="btn-icon" title="Mi perfil">
<div style="width:34px;height:34px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--secondary));display:grid;place-items:center;color:#fff;font-weight:700;font-size:.75rem">
<?= implode('', array_map(fn($p) => strtoupper($p[0]), array_slice(explode(' ', $_SESSION['usuario_nombre'] ?? 'U'), 0, 2))) ?>
</div>
</a>
</div>
</header>

324
views/oficios/crear.php Normal file
View File

@ -0,0 +1,324 @@
<?php
/**
* crear.php Formulario para crear un nuevo oficio
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
require_once __DIR__ . '/../../models/Usuario.php';
AuthController::requerirAuth();
$oficio = new OficioModel();
$usuario = new UsuarioModel();
$etiquetas = $oficio->etiquetas();
$usuarios = $usuario->usuariosParaSelector();
$errores = $_SESSION['errores_form'] ?? [];
$datos = $_SESSION['datos_form'] ?? [];
unset($_SESSION['errores_form'], $_SESSION['datos_form']);
// Número sugerido
$tipoDefault = $_GET['tipo'] ?? 'recibido';
$numSugerido = $oficio->generarNumero($tipoDefault);
$pageTitle = 'Nuevo Oficio';
$activeNav = 'nuevo';
// Plantillas de respuesta
$db = getDB();
$plantillas = $db->query("SELECT * FROM plantillas_respuesta WHERE activo=1 ORDER BY titulo")->fetchAll();
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<a href="<?= APP_URL ?>/views/oficios/lista.php">Oficios</a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Nuevo Oficio</span>
</div>
<div class="page-header">
<div class="page-header-content">
<h1>Crear Nuevo Oficio</h1>
<p>Complete todos los campos requeridos para registrar el oficio</p>
</div>
</div>
<?php if (!empty($errores)): ?>
<div class="alert alert-danger">
<i class="fa-solid fa-circle-exclamation"></i>
<div>
<strong>Por favor corrija los siguientes errores:</strong>
<ul style="margin:.3rem 0 0 1.2rem">
<?php foreach ($errores as $e): ?>
<li><?= htmlspecialchars($e) ?></li>
<?php endforeach; ?>
</ul>
</div>
</div>
<?php endif; ?>
<form method="POST" action="<?= APP_URL ?>/controllers/OficioController.php?action=crear" enctype="multipart/form-data" novalidate id="formOficio">
<?= csrfField() ?>
<div class="grid-2" style="gap:1.5rem;align-items:start">
<!-- Columna 1: Datos principales -->
<div>
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-file-lines text-primary"></i>
<span class="card-title">Datos del Oficio</span>
</div>
<div class="card-body">
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label" for="numero_oficio">
Número de Oficio <span class="required">*</span>
</label>
<div class="d-flex gap-2">
<input type="text" class="form-control <?= isset($errores['numero_oficio'])?'is-invalid':'' ?>"
id="numero_oficio" name="numero_oficio"
value="<?= htmlspecialchars($datos['numero_oficio'] ?? $numSugerido) ?>"
placeholder="Ej. REC-2026-0001"
required>
<button type="button" class="btn btn-secondary btn-sm" id="autoGenNumero" title="Autogenerar número" style="flex-shrink:0">
<i class="fa-solid fa-wand-magic-sparkles"></i>
</button>
</div>
<div class="form-text">El número debe ser único en el sistema</div>
</div>
<div class="form-group">
<label class="form-label" for="tipo">
Tipo <span class="required">*</span>
</label>
<select class="form-control" id="tipo" name="tipo" required>
<option value="recibido" <?= ($datos['tipo']??$tipoDefault)==='recibido'?'selected':'' ?>>📥 Recibido</option>
<option value="enviado" <?= ($datos['tipo']??$tipoDefault)==='enviado' ?'selected':'' ?>>📤 Enviado</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label" for="remitente">
Remitente <span class="required">*</span>
</label>
<input type="text" class="form-control" id="remitente" name="remitente"
value="<?= htmlspecialchars($datos['remitente'] ?? '') ?>"
placeholder="Nombre del remitente o institución"
required>
</div>
<div class="form-group">
<label class="form-label" for="destinatario">
Destinatario <span class="required">*</span>
</label>
<input type="text" class="form-control" id="destinatario" name="destinatario"
value="<?= htmlspecialchars($datos['destinatario'] ?? '') ?>"
placeholder="Nombre del destinatario o institución"
required>
</div>
<div class="form-group">
<label class="form-label" for="asunto">
Asunto <span class="required">*</span>
</label>
<textarea class="form-control" id="asunto" name="asunto" rows="3"
placeholder="Describa brevemente el asunto del oficio"
data-maxlen="500"
required><?= htmlspecialchars($datos['asunto'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label" for="descripcion">Descripción detallada</label>
<textarea class="form-control" id="descripcion" name="descripcion" rows="4"
placeholder="Información adicional o contexto del oficio"
data-maxlen="2000"><?= htmlspecialchars($datos['descripcion'] ?? '') ?></textarea>
<div style="margin-top:.5rem">
<label style="font-size:.75rem;font-weight:600;color:var(--text-muted)">Plantillas rápidas:</label>
<div class="d-flex gap-2 flex-wrap" style="margin-top:.3rem">
<?php foreach ($plantillas as $p): ?>
<button type="button" class="btn btn-sm btn-secondary plantilla-btn"
data-texto="<?= htmlspecialchars($p['contenido']) ?>">
<i class="fa-solid fa-file-lines"></i>
<?= htmlspecialchars(mb_strimwidth($p['titulo'], 0, 25, '…')) ?>
</button>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
</div>
<!-- Adjuntos -->
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-paperclip text-primary"></i>
<span class="card-title">Documentos Adjuntos</span>
</div>
<div class="card-body">
<div class="dropzone" id="dropzoneArea">
<i class="fa-solid fa-cloud-arrow-up" style="font-size:2rem;margin-bottom:.75rem;opacity:.5;display:block"></i>
<p class="dropzone-label" style="font-weight:600;margin-bottom:.25rem">
Arrastra archivos aquí o haz clic para seleccionar
</p>
<p style="font-size:.78rem">PDF, Word, Excel, imágenes · Máximo 10 MB por archivo</p>
<input type="file" name="adjuntos[]" id="adjuntos" style="display:none" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif,.xls,.xlsx">
</div>
<div id="filesPreview" style="margin-top:.75rem;display:flex;flex-wrap:wrap;gap:.5rem"></div>
</div>
</div>
</div>
<!-- Columna 2: Metadatos -->
<div>
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-sliders text-primary"></i>
<span class="card-title">Control y Seguimiento</span>
</div>
<div class="card-body">
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label" for="fecha_recepcion">
Fecha Recepción <span class="required">*</span>
</label>
<input type="date" class="form-control" id="fecha_recepcion" name="fecha_recepcion"
value="<?= htmlspecialchars($datos['fecha_recepcion'] ?? date('Y-m-d')) ?>"
required>
</div>
<div class="form-group">
<label class="form-label" for="fecha_vencimiento">Fecha Vencimiento</label>
<input type="date" class="form-control" id="fecha_vencimiento" name="fecha_vencimiento"
value="<?= htmlspecialchars($datos['fecha_vencimiento'] ?? '') ?>"
min="<?= date('Y-m-d') ?>">
</div>
</div>
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label" for="prioridad">Prioridad <span class="required">*</span></label>
<select class="form-control" id="prioridad" name="prioridad" required>
<option value="alta" <?= ($datos['prioridad']??'')==='alta' ?'selected':'' ?>>🔴 Alta</option>
<option value="media" <?= ($datos['prioridad']??'media')==='media'?'selected':'' ?>>🟡 Media</option>
<option value="baja" <?= ($datos['prioridad']??'')==='baja' ?'selected':'' ?>>🟢 Baja</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="estado">Estado <span class="required">*</span></label>
<select class="form-control" id="estado" name="estado" required>
<option value="recibido" <?= ($datos['estado']??'recibido')==='recibido' ?'selected':'' ?>>Recibido</option>
<option value="en_proceso" <?= ($datos['estado']??'')==='en_proceso'?'selected':'' ?>>En Proceso</option>
<option value="respondido" <?= ($datos['estado']??'')==='respondido'?'selected':'' ?>>Respondido</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label" for="responsable_id">Responsable Asignado</label>
<select class="form-control" id="responsable_id" name="responsable_id">
<option value=""> Sin asignar </option>
<?php foreach ($usuarios as $u): ?>
<option value="<?= $u['id'] ?>" <?= ($datos['responsable_id']??'')==$u['id']?'selected':'' ?>>
<?= htmlspecialchars($u['nombre_completo']) ?>
<?php if ($u['area']): ?>(<?= htmlspecialchars($u['area']) ?>)<?php endif; ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Etiquetas</label>
<div style="display:flex;flex-wrap:wrap;gap:.45rem;padding:.5rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)">
<?php
$selectedEtiquetas = $datos['etiquetas'] ?? [];
foreach ($etiquetas as $et): ?>
<label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:.8rem">
<input type="checkbox" name="etiquetas[]" value="<?= $et['id'] ?>"
<?= in_array($et['id'], $selectedEtiquetas)?'checked':'' ?>>
<span class="badge" style="background:<?= $et['color'] ?>30;color:<?= $et['color'] ?>">
<?= htmlspecialchars($et['nombre']) ?>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.85rem;font-weight:500">
<input type="checkbox" name="es_confidencial" value="1"
<?= !empty($datos['es_confidencial'])?'checked':'' ?>>
<i class="fa-solid fa-lock text-warning"></i>
Oficio Confidencial
</label>
<div class="form-text">Los oficios confidenciales solo son visibles para el responsable y administradores</div>
</div>
</div>
</div>
<!-- Botones de acción -->
<div style="display:flex;gap:.75rem;flex-direction:column">
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="fa-solid fa-floppy-disk"></i>
Guardar Oficio
</button>
<a href="<?= APP_URL ?>/views/oficios/lista.php" class="btn btn-secondary btn-lg w-100">
<i class="fa-solid fa-xmark"></i>
Cancelar
</a>
</div>
</div>
</div>
</form>
</div><!-- /.page-content -->
<script>
// ── Plantillas de respuesta ──────────────────────────────────────────────────
document.querySelectorAll('.plantilla-btn').forEach(btn => {
btn.addEventListener('click', function() {
const descripcion = document.getElementById('descripcion');
if (descripcion) {
descripcion.value = this.dataset.texto;
descripcion.dispatchEvent(new Event('input'));
}
});
});
// ── Preview de archivos seleccionados ────────────────────────────────────────
document.getElementById('adjuntos').addEventListener('change', function() {
const preview = document.getElementById('filesPreview');
preview.innerHTML = '';
[...this.files].forEach(f => {
const tag = document.createElement('div');
tag.style.cssText = 'background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);padding:.3rem .7rem;font-size:.78rem;display:flex;align-items:center;gap:.4rem;';
const ext = f.name.split('.').pop().toLowerCase();
const icons = { pdf:'fa-file-pdf', doc:'fa-file-word', docx:'fa-file-word', xls:'fa-file-excel', xlsx:'fa-file-excel', jpg:'fa-file-image', jpeg:'fa-file-image', png:'fa-file-image' };
tag.innerHTML = `<i class="fa-solid ${icons[ext]||'fa-file'} text-primary"></i>${f.name} <span style="color:var(--text-muted)">(${(f.size/1024).toFixed(0)}KB)</span>`;
preview.appendChild(tag);
});
});
// ── Dropzone conectar con input ──────────────────────────────────────────────
document.getElementById('dropzoneArea').addEventListener('click', (e) => {
if (e.target.closest('#autoGenNumero')) return;
document.getElementById('adjuntos').click();
});
</script>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

382
views/oficios/detalle.php Normal file
View File

@ -0,0 +1,382 @@
<?php
/**
* detalle.php Vista detalle de un oficio con historial, adjuntos y comentarios
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
require_once __DIR__ . '/../../models/Usuario.php';
AuthController::requerirAuth();
$id = (int)($_GET['id'] ?? 0);
if (!$id) redirect(APP_URL . '/views/oficios/lista.php');
$model = new OficioModel();
$oficio = $model->buscarPorId($id);
if (!$oficio) {
http_response_code(404);
redirect(APP_URL . '/views/oficios/lista.php?error='.urlencode('Oficio no encontrado.'));
}
// Marcar notificación si viene de ella
if (isset($_GET['notif'])) {
$db = getDB();
$db->prepare("UPDATE notificaciones SET leida=1,leida_at=NOW() WHERE id=? AND usuario_id=?")
->execute([(int)$_GET['notif'], $_SESSION['usuario_id']]);
}
$historial = $model->historial($id);
$comentarios = $model->comentarios($id, !AuthController::esAdmin());
$etiquetas = $model->etiquetasDeOficio($id);
$adjuntos = getDB()->prepare("SELECT * FROM documentos_adjuntos WHERE oficio_id=? ORDER BY created_at DESC");
$adjuntos->execute([$id]);
$adjuntos = $adjuntos->fetchAll();
$usuarios = (new UsuarioModel())->usuariosParaSelector();
$esAdmin = AuthController::esAdmin();
$userId = $_SESSION['usuario_id'];
// Calcular semáforo
$semaforo = $oficio['semaforo'] ?? 'vigente';
$semaforoColors = ['vigente'=>'success','proximo'=>'warning','vencido'=>'danger','completado'=>'info'];
$semaColor = $semaforoColors[$semaforo] ?? 'secondary';
$pageTitle = 'Oficio: '.$oficio['numero_oficio'];
$activeNav = $oficio['tipo'] === 'recibido' ? 'entrada' : 'salida';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<a href="<?= APP_URL ?>/views/oficios/lista.php?tipo=<?= $oficio['tipo'] ?>">
<?= $oficio['tipo']==='recibido' ? 'Entrada' : 'Salida' ?>
</a>
<i class="fa-solid fa-chevron-right sep"></i>
<span><?= htmlspecialchars($oficio['numero_oficio']) ?></span>
</div>
<!-- Flash messages -->
<?php
$success = $_GET['success'] ?? null;
$error = $_GET['error'] ?? null;
?>
<?php if ($success): ?>
<div class="alert alert-success"><i class="fa-solid fa-circle-check"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<?php if ($error): ?>
<div class="alert alert-danger"><i class="fa-solid fa-circle-exclamation"></i> <?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<!-- Encabezado del oficio -->
<div class="page-header">
<div class="page-header-content">
<div class="d-flex align-items-center gap-3 flex-wrap">
<h1><?= htmlspecialchars($oficio['numero_oficio']) ?></h1>
<span class="badge badge-<?= $semaColor ?> semaforo-<?= $semaforo ?>" style="font-size:.85rem;padding:.3rem .8rem">
<?= ucfirst($semaforo) ?>
</span>
<?php if ($oficio['es_confidencial']): ?>
<span class="badge badge-warning"><i class="fa-solid fa-lock"></i> Confidencial</span>
<?php endif; ?>
</div>
<p style="margin-top:.4rem;font-size:.9rem;color:var(--text-muted)">
<?= htmlspecialchars($oficio['asunto']) ?>
</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="<?= APP_URL ?>/views/oficios/editar.php?id=<?= $id ?>" class="btn btn-warning">
<i class="fa-solid fa-pen"></i> Editar
</a>
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=pdf&id=<?= $id ?>" class="btn btn-primary" target="_blank">
<i class="fa-solid fa-file-pdf"></i> PDF
</a>
<button class="btn btn-secondary" data-modal="modalDerivar">
<i class="fa-solid fa-reply-all"></i> Derivar
</button>
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=eliminar&id=<?= $id ?>"
class="btn btn-danger"
data-confirm="¿Mover este oficio a la papelera?">
<i class="fa-solid fa-trash"></i>
</a>
</div>
</div>
<div class="grid-2" style="grid-template-columns:1.4fr 1fr;gap:1.5rem;align-items:start">
<!-- Columna izquierda: Datos + Adjuntos -->
<div>
<!-- Datos principales -->
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-file-lines text-primary"></i>
<span class="card-title">Datos del Oficio</span>
</div>
<div class="card-body">
<div class="grid-2" style="gap:1rem;margin-bottom:1rem">
<div>
<div class="fs-sm text-muted fw-600">TIPO</div>
<div style="margin-top:.2rem">
<?= $oficio['tipo']==='recibido'
? '<span class="badge badge-info"><i class="fa-solid fa-inbox"></i> Recibido</span>'
: '<span class="badge badge-secondary"><i class="fa-solid fa-paper-plane"></i> Enviado</span>' ?>
</div>
</div>
<div>
<div class="fs-sm text-muted fw-600">PRIORIDAD</div>
<div style="margin-top:.2rem">
<span class="badge badge-<?= ['alta'=>'danger','media'=>'warning','baja'=>'success'][$oficio['prioridad']]??'secondary' ?>">
<?= ucfirst($oficio['prioridad']) ?>
</span>
</div>
</div>
<div>
<div class="fs-sm text-muted fw-600">ESTADO</div>
<div style="margin-top:.2rem">
<span class="badge badge-<?= ['recibido'=>'primary','en_proceso'=>'warning','respondido'=>'success','vencido'=>'danger','archivado'=>'secondary'][$oficio['estado']]??'secondary' ?>">
<?= ucfirst(str_replace('_',' ',$oficio['estado'])) ?>
</span>
</div>
</div>
<div>
<div class="fs-sm text-muted fw-600">RESPONSABLE</div>
<div style="margin-top:.2rem;font-weight:600"><?= htmlspecialchars($oficio['responsable_nombre'] ?? '—') ?></div>
</div>
<div>
<div class="fs-sm text-muted fw-600">FECHA RECEPCIÓN</div>
<div style="margin-top:.2rem"><?= date('d/m/Y', strtotime($oficio['fecha_recepcion'])) ?></div>
</div>
<div>
<div class="fs-sm text-muted fw-600">FECHA VENCIMIENTO</div>
<div style="margin-top:.2rem">
<?php if ($oficio['fecha_vencimiento']): ?>
<strong class="<?= $semaforo==='vencido'?'text-danger':($semaforo==='proximo'?'text-warning':'') ?>">
<?= date('d/m/Y', strtotime($oficio['fecha_vencimiento'])) ?>
</strong>
<?php if ($oficio['dias_para_vencer'] !== null): ?>
<div class="fs-sm text-muted">
<?= $oficio['dias_para_vencer'] < 0
? abs($oficio['dias_para_vencer']).' día(s) vencido'
: ($oficio['dias_para_vencer'] === '0' ? 'Vence HOY' : $oficio['dias_para_vencer'].' día(s) restante(s)') ?>
</div>
<?php endif; ?>
<?php else: ?>—<?php endif; ?>
</div>
</div>
</div>
<div style="margin-bottom:1rem">
<div class="fs-sm text-muted fw-600">REMITENTE</div>
<div style="margin-top:.2rem"><?= htmlspecialchars($oficio['remitente']) ?></div>
</div>
<div style="margin-bottom:1rem">
<div class="fs-sm text-muted fw-600">DESTINATARIO</div>
<div style="margin-top:.2rem"><?= htmlspecialchars($oficio['destinatario']) ?></div>
</div>
<div style="margin-bottom:1rem">
<div class="fs-sm text-muted fw-600">ASUNTO</div>
<div style="margin-top:.2rem"><?= htmlspecialchars($oficio['asunto']) ?></div>
</div>
<?php if ($oficio['descripcion']): ?>
<div style="margin-bottom:.5rem">
<div class="fs-sm text-muted fw-600">DESCRIPCIÓN</div>
<div style="margin-top:.2rem;font-size:.85rem;line-height:1.7"><?= nl2br(htmlspecialchars($oficio['descripcion'])) ?></div>
</div>
<?php endif; ?>
<?php if ($etiquetas): ?>
<div style="margin-top:1rem">
<div class="fs-sm text-muted fw-600 mb-2">ETIQUETAS</div>
<div class="d-flex gap-2 flex-wrap">
<?php foreach ($etiquetas as $et): ?>
<span class="badge" style="background:<?= $et['color'] ?>25;color:<?= $et['color'] ?>">
<i class="fa-solid <?= $et['icono'] ?>"></i>
<?= htmlspecialchars($et['nombre']) ?>
</span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
</div>
<!-- Documentos adjuntos -->
<?php if (!empty($adjuntos)): ?>
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-paperclip text-primary"></i>
<span class="card-title">Documentos Adjuntos (<?= count($adjuntos) ?>)</span>
</div>
<div class="card-body" style="display:flex;flex-wrap:wrap;gap:.75rem">
<?php foreach ($adjuntos as $adj):
$ext = strtolower(pathinfo($adj['nombre_original'], PATHINFO_EXTENSION));
$iconos = ['pdf'=>'fa-file-pdf','doc'=>'fa-file-word','docx'=>'fa-file-word','xls'=>'fa-file-excel','xlsx'=>'fa-file-excel','jpg'=>'fa-file-image','jpeg'=>'fa-file-image','png'=>'fa-file-image'];
$iconColor = ['pdf'=>'#ef4444','doc'=>'#3b82f6','docx'=>'#3b82f6','xls'=>'#10b981','xlsx'=>'#10b981','jpg'=>'#f59e0b','jpeg'=>'#f59e0b','png'=>'#f59e0b'];
?>
<a href="<?= APP_URL ?>/uploads/<?= $adj['ruta'] ?>"
target="_blank"
style="display:flex;align-items:center;gap:.5rem;padding:.5rem .875rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);text-decoration:none;color:var(--text);font-size:.8rem;transition:all .2s"
onmouseover="this.style.borderColor='var(--primary)'"
onmouseout="this.style.borderColor='var(--border)'">
<i class="fa-solid <?= $iconos[$ext]??'fa-file' ?>" style="color:<?= $iconColor[$ext]??'var(--primary)' ?>;font-size:1.1rem"></i>
<div>
<div style="font-weight:600;max-width:180px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
<?= htmlspecialchars($adj['nombre_original']) ?>
</div>
<div style="color:var(--text-muted);font-size:.7rem">
<?= number_format($adj['tamanio']/1024, 1) ?> KB
</div>
</div>
<i class="fa-solid fa-download" style="color:var(--text-muted);margin-left:.25rem"></i>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
</div>
<!-- Columna derecha: Comentarios + Historial -->
<div>
<!-- Tabs -->
<div data-tabs>
<div class="tabs">
<button class="tab-btn active" data-tab="tabComentarios">
<i class="fa-solid fa-comments"></i> Comentarios (<?= count($comentarios) ?>)
</button>
<button class="tab-btn" data-tab="tabHistorial">
<i class="fa-solid fa-clock-rotate-left"></i> Historial (<?= count($historial) ?>)
</button>
</div>
<!-- Tab comentarios -->
<div id="tabComentarios" class="tab-content active">
<!-- Nuevo comentario -->
<form method="POST" action="<?= APP_URL ?>/controllers/OficioController.php?action=comentar" class="card mb-3">
<?= csrfField() ?>
<input type="hidden" name="oficio_id" value="<?= $id ?>">
<div class="card-body">
<textarea name="comentario" class="form-control" rows="3" placeholder="Escribe un comentario…" required></textarea>
<?php if ($esAdmin): ?>
<label style="display:flex;align-items:center;gap:.4rem;margin-top:.5rem;font-size:.8rem;cursor:pointer">
<input type="checkbox" name="es_privado" value="1">
<i class="fa-solid fa-eye-slash text-warning"></i> Comentario privado (solo admins)
</label>
<?php endif; ?>
</div>
<div class="card-footer">
<button type="submit" class="btn btn-primary btn-sm">
<i class="fa-solid fa-paper-plane"></i> Comentar
</button>
</div>
</form>
<!-- Lista de comentarios -->
<div class="comment-thread">
<?php if (empty($comentarios)): ?>
<div style="text-align:center;padding:1.5rem;color:var(--text-muted);font-size:.83rem">
<i class="fa-solid fa-comment-slash" style="font-size:1.5rem;display:block;margin-bottom:.5rem;opacity:.3"></i>
Sin comentarios aún
</div>
<?php else: ?>
<?php foreach ($comentarios as $c):
$iniciales = implode('', array_map(fn($p)=>strtoupper($p[0]), array_slice(explode(' ', $c['autor_nombre']), 0, 2)));
?>
<div class="comment-item">
<div class="comment-avatar"><?= $iniciales ?></div>
<div class="comment-bubble" style="<?= $c['es_privado'] ? 'border-color:var(--warning);background:rgba(245,158,11,.05)' : '' ?>">
<div class="comment-meta">
<strong><?= htmlspecialchars($c['autor_nombre']) ?></strong>
&middot; <?= date('d/m/Y H:i', strtotime($c['created_at'])) ?>
<?php if ($c['es_privado']): ?>
<span class="badge badge-warning" style="font-size:.65rem;margin-left:.3rem"><i class="fa-solid fa-eye-slash"></i> Privado</span>
<?php endif; ?>
</div>
<div class="comment-text"><?= nl2br(htmlspecialchars($c['comentario'])) ?></div>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<!-- Tab historial -->
<div id="tabHistorial" class="tab-content">
<div class="timeline">
<?php if (empty($historial)): ?>
<p class="text-muted fs-sm">Sin historial registrado.</p>
<?php else: ?>
<?php foreach ($historial as $h): ?>
<div class="timeline-item">
<div class="timeline-date">
<?= date('d/m/Y H:i', strtotime($h['created_at'])) ?>
</div>
<div class="timeline-body">
<span class="timeline-author"><?= htmlspecialchars($h['usuario_nombre'] ?? 'Sistema') ?></span>
· <span class="badge badge-<?= ['crear'=>'success','editar'=>'info','eliminar'=>'danger','restaurar'=>'warning','derivar'=>'primary','escalacion'=>'secondary'][$h['accion']]??'secondary' ?>">
<?= ucfirst($h['accion']) ?>
</span>
<?php if ($h['detalle']): ?>
<div style="margin-top:.2rem;font-size:.78rem;color:var(--text-muted)">
<?= htmlspecialchars($h['detalle']) ?>
</div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</div><!-- /.page-content -->
<!-- Modal: Derivar oficio -->
<div class="modal-overlay" id="modalDerivar">
<div class="modal-box">
<div class="modal-header">
<i class="fa-solid fa-reply-all text-primary"></i>
<span class="modal-title">Derivar Oficio</span>
<button class="btn-icon" data-modal-close><i class="fa-solid fa-xmark"></i></button>
</div>
<form method="POST" action="<?= APP_URL ?>/controllers/OficioController.php?action=derivar">
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= $id ?>">
<div class="modal-body">
<div class="form-group">
<label class="form-label">Nuevo Responsable <span class="required">*</span></label>
<select class="form-control" name="nuevo_responsable" required>
<option value=""> Seleccionar usuario </option>
<?php foreach ($usuarios as $u): ?>
<option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['nombre_completo']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Comentario de Derivación <span class="required">*</span></label>
<textarea class="form-control" name="comentario_derivacion" rows="3"
placeholder="Explica el motivo de la derivación…" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-modal-close>Cancelar</button>
<button type="submit" class="btn btn-primary">
<i class="fa-solid fa-reply-all"></i> Derivar
</button>
</div>
</form>
</div>
</div>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

212
views/oficios/editar.php Normal file
View File

@ -0,0 +1,212 @@
<?php
/**
* editar.php Formulario de edición de oficio
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
require_once __DIR__ . '/../../models/Usuario.php';
AuthController::requerirAuth();
$id = (int)($_GET['id'] ?? 0);
if (!$id) redirect(APP_URL . '/views/oficios/lista.php');
$model = new OficioModel();
$oficio = $model->buscarPorId($id);
if (!$oficio) redirect(APP_URL . '/views/oficios/lista.php?error='.urlencode('Oficio no encontrado.'));
$usuario = new UsuarioModel();
$etiquetas = $model->etiquetas();
$usuarios = $usuario->usuariosParaSelector();
$selEtiq = array_column($model->etiquetasDeOficio($id), 'id');
$errores = $_SESSION['errores_form'] ?? [];
$datos = $_SESSION['datos_form'] ?? $oficio;
unset($_SESSION['errores_form'], $_SESSION['datos_form']);
$pageTitle = 'Editar Oficio: '.$oficio['numero_oficio'];
$activeNav = 'lista';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<a href="<?= APP_URL ?>/views/oficios/lista.php">Oficios</a>
<i class="fa-solid fa-chevron-right sep"></i>
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $id ?>"><?= htmlspecialchars($oficio['numero_oficio']) ?></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Editar</span>
</div>
<div class="page-header">
<div class="page-header-content">
<h1>Editar Oficio</h1>
<p><?= htmlspecialchars($oficio['numero_oficio']) ?> · <?= htmlspecialchars(mb_strimwidth($oficio['asunto'], 0, 80, '…')) ?></p>
</div>
</div>
<?php if (!empty($errores)): ?>
<div class="alert alert-danger">
<i class="fa-solid fa-circle-exclamation"></i>
<ul style="margin:.3rem 0 0 1rem">
<?php foreach ($errores as $e): ?><li><?= htmlspecialchars($e) ?></li><?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form method="POST" action="<?= APP_URL ?>/controllers/OficioController.php?action=actualizar" enctype="multipart/form-data" novalidate>
<?= csrfField() ?>
<input type="hidden" name="id" value="<?= $id ?>">
<div class="grid-2" style="gap:1.5rem;align-items:start">
<div>
<div class="card mb-4">
<div class="card-header">
<i class="fa-solid fa-pen text-warning"></i>
<span class="card-title">Datos del Oficio</span>
</div>
<div class="card-body">
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label">Número de Oficio <span class="required">*</span></label>
<input type="text" class="form-control" name="numero_oficio"
value="<?= htmlspecialchars($datos['numero_oficio'] ?? '') ?>" required>
</div>
<div class="form-group">
<label class="form-label">Tipo <span class="required">*</span></label>
<select class="form-control" name="tipo" required>
<option value="recibido" <?= ($datos['tipo']??'')==='recibido'?'selected':'' ?>>📥 Recibido</option>
<option value="enviado" <?= ($datos['tipo']??'')==='enviado'?'selected':'' ?>>📤 Enviado</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Remitente <span class="required">*</span></label>
<input type="text" class="form-control" name="remitente" value="<?= htmlspecialchars($datos['remitente'] ?? '') ?>" required>
</div>
<div class="form-group">
<label class="form-label">Destinatario <span class="required">*</span></label>
<input type="text" class="form-control" name="destinatario" value="<?= htmlspecialchars($datos['destinatario'] ?? '') ?>" required>
</div>
<div class="form-group">
<label class="form-label">Asunto <span class="required">*</span></label>
<textarea class="form-control" name="asunto" rows="3" data-maxlen="500" required><?= htmlspecialchars($datos['asunto'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">Descripción</label>
<textarea class="form-control" name="descripcion" rows="4" data-maxlen="2000"><?= htmlspecialchars($datos['descripcion'] ?? '') ?></textarea>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header"><i class="fa-solid fa-paperclip text-primary"></i><span class="card-title">Agregar Adjuntos</span></div>
<div class="card-body">
<div class="dropzone">
<i class="fa-solid fa-cloud-arrow-up" style="font-size:1.5rem;margin-bottom:.5rem;opacity:.5;display:block"></i>
<p class="dropzone-label" style="font-weight:600;">Arrastra archivos o haz clic</p>
<p style="font-size:.78rem">PDF, Word, Excel, imágenes · Máximo 10 MB</p>
<input type="file" name="adjuntos[]" style="display:none" multiple accept=".pdf,.doc,.docx,.jpg,.jpeg,.png,.gif,.xls,.xlsx">
</div>
</div>
</div>
</div>
<div>
<div class="card mb-4">
<div class="card-header"><i class="fa-solid fa-sliders text-primary"></i><span class="card-title">Control y Seguimiento</span></div>
<div class="card-body">
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label">Fecha Recepción <span class="required">*</span></label>
<input type="date" class="form-control" name="fecha_recepcion" value="<?= $datos['fecha_recepcion']??'' ?>" required>
</div>
<div class="form-group">
<label class="form-label">Fecha Vencimiento</label>
<input type="date" class="form-control" name="fecha_vencimiento" value="<?= $datos['fecha_vencimiento']??'' ?>">
</div>
</div>
<div class="form-row" style="grid-template-columns:1fr 1fr">
<div class="form-group">
<label class="form-label">Prioridad</label>
<select class="form-control" name="prioridad">
<option value="alta" <?= ($datos['prioridad']??'')==='alta' ?'selected':'' ?>>🔴 Alta</option>
<option value="media" <?= ($datos['prioridad']??'')==='media' ?'selected':'' ?>>🟡 Media</option>
<option value="baja" <?= ($datos['prioridad']??'')==='baja' ?'selected':'' ?>>🟢 Baja</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Estado</label>
<select class="form-control" name="estado">
<option value="recibido" <?= ($datos['estado']??'')==='recibido' ?'selected':'' ?>>Recibido</option>
<option value="en_proceso" <?= ($datos['estado']??'')==='en_proceso' ?'selected':'' ?>>En Proceso</option>
<option value="respondido" <?= ($datos['estado']??'')==='respondido' ?'selected':'' ?>>Respondido</option>
<option value="vencido" <?= ($datos['estado']??'')==='vencido' ?'selected':'' ?>>Vencido</option>
<option value="archivado" <?= ($datos['estado']??'')==='archivado' ?'selected':'' ?>>Archivado</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Responsable</label>
<select class="form-control" name="responsable_id">
<option value=""> Sin asignar </option>
<?php foreach ($usuarios as $u): ?>
<option value="<?= $u['id'] ?>" <?= ($datos['responsable_id']??'')==$u['id']?'selected':'' ?>>
<?= htmlspecialchars($u['nombre_completo']) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">Etiquetas</label>
<div style="display:flex;flex-wrap:wrap;gap:.45rem;padding:.5rem;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm)">
<?php foreach ($etiquetas as $et): ?>
<label style="display:flex;align-items:center;gap:.3rem;cursor:pointer;font-size:.8rem">
<input type="checkbox" name="etiquetas[]" value="<?= $et['id'] ?>"
<?= in_array($et['id'], $selEtiq)?'checked':'' ?>>
<span class="badge" style="background:<?= $et['color'] ?>30;color:<?= $et['color'] ?>">
<?= htmlspecialchars($et['nombre']) ?>
</span>
</label>
<?php endforeach; ?>
</div>
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.85rem;font-weight:500">
<input type="checkbox" name="es_confidencial" value="1" <?= !empty($datos['es_confidencial'])?'checked':'' ?>>
<i class="fa-solid fa-lock text-warning"></i> Confidencial
</label>
</div>
</div>
</div>
<div style="display:flex;gap:.75rem;flex-direction:column">
<button type="submit" class="btn btn-warning btn-lg w-100">
<i class="fa-solid fa-floppy-disk"></i> Guardar Cambios
</button>
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $id ?>" class="btn btn-secondary btn-lg w-100">
<i class="fa-solid fa-xmark"></i> Cancelar
</a>
</div>
</div>
</div>
</form>
</div>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

280
views/oficios/lista.php Normal file
View File

@ -0,0 +1,280 @@
<?php
/**
* lista.php Tabla dinámica de oficios con filtros
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
AuthController::requerirAuth();
$oficio = new OficioModel();
$esAdmin = AuthController::esAdmin();
$soloPropio = !$esAdmin && !AuthController::tienePermiso('oficios') !== 'CRUD';
// Filtros desde GET
$filtros = [
'tipo' => $_GET['tipo'] ?? '',
'estado' => $_GET['estado'] ?? '',
'prioridad' => $_GET['prioridad'] ?? '',
'responsable_id'=> $_GET['responsable_id']?? '',
'fecha_desde' => $_GET['fecha_desde'] ?? '',
'fecha_hasta' => $_GET['fecha_hasta'] ?? '',
'semaforo' => $_GET['semaforo'] ?? '',
'etiqueta_id' => $_GET['etiqueta_id'] ?? '',
'busqueda' => $_GET['busqueda'] ?? '',
];
$oficios = $oficio->listar($filtros, $soloPropio, $_SESSION['usuario_id']);
$etiquetas = $oficio->etiquetas();
// Título dinámico según tipo
$titulos = [
'recibido' => 'Bandeja de Entrada',
'enviado' => 'Bandeja de Salida',
'' => 'Todos los Oficios',
];
$pageTitle = $titulos[$filtros['tipo']] ?? 'Oficios';
$activeNav = $filtros['tipo'] === 'recibido' ? 'entrada' : ($filtros['tipo'] === 'enviado' ? 'salida' : 'lista');
$badgeEstado = [
'recibido' => 'badge-primary',
'en_proceso' => 'badge-warning',
'respondido' => 'badge-success',
'vencido' => 'badge-danger',
'archivado' => 'badge-secondary',
];
$badgePrioridad = [
'alta' => 'badge-danger',
'media' => 'badge-warning',
'baja' => 'badge-success',
];
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<!-- Breadcrumb -->
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span><?= htmlspecialchars($pageTitle) ?></span>
</div>
<!-- Page Header -->
<div class="page-header">
<div class="page-header-content">
<h1><?= htmlspecialchars($pageTitle) ?></h1>
<p><?= count($oficios) ?> oficio(s) encontrado(s) con los filtros actuales</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<?php if ($esAdmin): ?>
<a href="<?= APP_URL ?>/views/reportes/index.php" class="btn btn-secondary">
<i class="fa-solid fa-file-pdf"></i> Reporte PDF
</a>
<a href="<?= APP_URL ?>/views/reportes/carga_masiva.php" class="btn btn-secondary">
<i class="fa-solid fa-file-excel"></i> Carga Masiva
</a>
<?php endif; ?>
<a href="<?= APP_URL ?>/views/oficios/crear.php" class="btn btn-primary">
<i class="fa-solid fa-plus"></i> Nuevo Oficio
</a>
</div>
</div>
<!-- Filtros -->
<form method="GET" action="" id="filtrosForm">
<div class="filter-strip mb-3">
<div class="search-bar" style="max-width:280px;flex:2">
<i class="fa-solid fa-search"></i>
<input type="text" class="form-control" name="busqueda" placeholder="Buscar por asunto, número, remitente…" value="<?= htmlspecialchars($filtros['busqueda']) ?>">
</div>
<select name="tipo" class="form-control" onchange="this.form.submit()">
<option value="">Todos los tipos</option>
<option value="recibido" <?= $filtros['tipo']==='recibido' ? 'selected':'' ?>>Recibidos</option>
<option value="enviado" <?= $filtros['tipo']==='enviado' ? 'selected':'' ?>>Enviados</option>
</select>
<select name="estado" class="form-control" onchange="this.form.submit()">
<option value="">Todos los estados</option>
<option value="recibido" <?= $filtros['estado']==='recibido' ? 'selected':'' ?>>Recibido</option>
<option value="en_proceso" <?= $filtros['estado']==='en_proceso' ? 'selected':'' ?>>En Proceso</option>
<option value="respondido" <?= $filtros['estado']==='respondido' ? 'selected':'' ?>>Respondido</option>
<option value="vencido" <?= $filtros['estado']==='vencido' ? 'selected':'' ?>>Vencido</option>
<option value="archivado" <?= $filtros['estado']==='archivado' ? 'selected':'' ?>>Archivado</option>
</select>
<select name="prioridad" class="form-control" onchange="this.form.submit()">
<option value="">Todas las prioridades</option>
<option value="alta" <?= $filtros['prioridad']==='alta' ? 'selected':'' ?>>🔴 Alta</option>
<option value="media" <?= $filtros['prioridad']==='media' ? 'selected':'' ?>>🟡 Media</option>
<option value="baja" <?= $filtros['prioridad']==='baja' ? 'selected':'' ?>>🟢 Baja</option>
</select>
<select name="semaforo" class="form-control" onchange="this.form.submit()">
<option value="">Semáforo</option>
<option value="vencido" <?= $filtros['semaforo']==='vencido' ? 'selected':'' ?>>🔴 Vencido</option>
<option value="proximo" <?= $filtros['semaforo']==='proximo' ? 'selected':'' ?>>🟡 Próximo</option>
<option value="vigente" <?= $filtros['semaforo']==='vigente' ? 'selected':'' ?>>🟢 Vigente</option>
<option value="completado"<?= $filtros['semaforo']==='completado'? 'selected':'' ?>>🔵 Completado</option>
</select>
<input type="date" class="form-control" name="fecha_desde" value="<?= $filtros['fecha_desde'] ?>" title="Desde">
<input type="date" class="form-control" name="fecha_hasta" value="<?= $filtros['fecha_hasta'] ?>" title="Hasta">
<select name="etiqueta_id" class="form-control" onchange="this.form.submit()">
<option value="">Todas las etiquetas</option>
<?php foreach ($etiquetas as $et): ?>
<option value="<?= $et['id'] ?>" <?= $filtros['etiqueta_id']==$et['id']?'selected':'' ?>>
<?= htmlspecialchars($et['nombre']) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn-primary btn-sm"><i class="fa-solid fa-filter"></i> Filtrar</button>
<a href="?" class="btn btn-secondary btn-sm"><i class="fa-solid fa-rotate"></i> Limpiar</a>
</div>
</form>
<!-- Tabla de oficios -->
<div class="card">
<div class="card-body" style="padding:0">
<div class="table-responsive">
<table class="table" id="tablaOficios">
<thead>
<tr>
<th> Oficio</th>
<th>Tipo</th>
<th>Asunto</th>
<th>Remitente / Destinatario</th>
<th>Responsable</th>
<th>Vence</th>
<th>Prioridad</th>
<th>Estado</th>
<th>Semáforo</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<?php if (empty($oficios)): ?>
<tr>
<td colspan="10" class="text-center" style="padding:2rem;color:var(--text-muted)">
<i class="fa-solid fa-folder-open" style="font-size:2rem;display:block;margin-bottom:.5rem;opacity:.3"></i>
No se encontraron oficios con los filtros aplicados
</td>
</tr>
<?php else: ?>
<?php foreach ($oficios as $o):
$semaforoClass = 'badge semaforo-' . ($o['semaforo'] ?? 'vigente');
$semaforoLabel = [
'vigente'=>'Vigente','proximo'=>'Próximo','vencido'=>'Vencido',
'completado'=>'Completado','sin_vencimiento'=>'Sin fecha'
][$o['semaforo'] ?? ''] ?? ($o['semaforo'] ?? '');
$diasLabel = '';
if ($o['fecha_vencimiento']) {
$d = (int)$o['dias_para_vencer'];
$diasLabel = $d < 0 ? abs($d).' días vencido' : ($d === 0 ? 'Hoy' : $d.' días');
}
?>
<tr>
<td>
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $o['id'] ?>" class="fw-600" style="color:var(--primary);text-decoration:none;">
<?= htmlspecialchars($o['numero_oficio']) ?>
</a>
<?php if ($o['total_adjuntos'] > 0): ?>
<span title="<?= $o['total_adjuntos'] ?> adjunto(s)" style="color:var(--text-muted);font-size:.75rem;margin-left:.3rem">
<i class="fa-solid fa-paperclip"></i>
</span>
<?php endif; ?>
</td>
<td>
<?php if ($o['tipo'] === 'recibido'): ?>
<span class="badge badge-info"><i class="fa-solid fa-inbox"></i> Entrada</span>
<?php else: ?>
<span class="badge badge-secondary"><i class="fa-solid fa-paper-plane"></i> Salida</span>
<?php endif; ?>
</td>
<td style="max-width:220px">
<div style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:220px" title="<?= htmlspecialchars($o['asunto']) ?>">
<?= htmlspecialchars(mb_strimwidth($o['asunto'], 0, 60, '…')) ?>
</div>
<?php if ($o['etiquetas']): ?>
<div style="margin-top:.2rem;font-size:.7rem;color:var(--text-muted)">
<i class="fa-solid fa-tag"></i> <?= htmlspecialchars($o['etiquetas']) ?>
</div>
<?php endif; ?>
</td>
<td style="font-size:.8rem">
<div><?= htmlspecialchars(mb_strimwidth($o['tipo']==='recibido' ? $o['remitente'] : $o['destinatario'], 0, 35, '…')) ?></div>
</td>
<td style="font-size:.8rem">
<?= htmlspecialchars($o['responsable_nombre'] ?? '—') ?>
</td>
<td style="font-size:.8rem;white-space:nowrap">
<?php if ($o['fecha_vencimiento']): ?>
<div><?= date('d/m/Y', strtotime($o['fecha_vencimiento'])) ?></div>
<div class="fs-sm text-muted"><?= $diasLabel ?></div>
<?php else: ?>
<span class="text-muted"></span>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $badgePrioridad[$o['prioridad']] ?? 'badge-secondary' ?>">
<?= ucfirst($o['prioridad']) ?>
</span>
</td>
<td>
<span class="badge <?= $badgeEstado[$o['estado']] ?? 'badge-secondary' ?>">
<?= ucfirst(str_replace('_',' ', $o['estado'])) ?>
</span>
</td>
<td>
<span class="badge <?= $semaforoClass ?>">
<?= $semaforoLabel ?>
</span>
</td>
<td>
<div class="d-flex gap-1">
<a href="<?= APP_URL ?>/views/oficios/detalle.php?id=<?= $o['id'] ?>" class="btn btn-sm btn-secondary" title="Ver detalle">
<i class="fa-solid fa-eye"></i>
</a>
<a href="<?= APP_URL ?>/views/oficios/editar.php?id=<?= $o['id'] ?>" class="btn btn-sm btn-warning" title="Editar">
<i class="fa-solid fa-pen"></i>
</a>
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=pdf&id=<?= $o['id'] ?>" class="btn btn-sm btn-info" title="Exportar PDF" target="_blank">
<i class="fa-solid fa-file-pdf"></i>
</a>
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=eliminar&id=<?= $o['id'] ?>"
class="btn btn-sm btn-danger"
title="Eliminar"
data-confirm="¿Mover este oficio a la papelera?">
<i class="fa-solid fa-trash"></i>
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /.page-content -->
<script>
$(document).ready(function() {
initDataTable('#tablaOficios', {
searching: false, // búsqueda ya manejada por el form
columnDefs: [{ orderable: false, targets: [9] }],
order: [[5, 'asc']], // ordenar por vencimiento
});
});
</script>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

View File

@ -0,0 +1,94 @@
<?php
/**
* papelera.php Papelera de reciclaje de oficios
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
AuthController::requerirAdmin();
$model = new OficioModel();
$oficios = $model->papelera();
$pageTitle = 'Papelera de Reciclaje';
$activeNav = 'papelera';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Papelera de Reciclaje</span>
</div>
<?php $success = $_GET['success'] ?? ''; ?>
<?php if ($success): ?>
<div class="alert alert-success"><i class="fa-solid fa-circle-check"></i> <?= htmlspecialchars($success) ?></div>
<?php endif; ?>
<div class="page-header">
<div class="page-header-content">
<h1>Papelera de Reciclaje</h1>
<p><?= count($oficios) ?> oficio(s) en papelera · Solo administradores pueden restaurar o eliminar permanentemente</p>
</div>
</div>
<div class="card">
<div class="card-body" style="padding:0">
<div class="table-responsive">
<table class="table" id="tablaPapelera">
<thead>
<tr>
<th> Oficio</th>
<th>Asunto</th>
<th>Responsable</th>
<th>Eliminado el</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
<?php if (empty($oficios)): ?>
<tr>
<td colspan="5" class="text-center" style="padding:2rem;color:var(--text-muted)">
<i class="fa-solid fa-trash-can" style="font-size:2rem;display:block;margin-bottom:.5rem;opacity:.3"></i>
La papelera está vacía
</td>
</tr>
<?php else: ?>
<?php foreach ($oficios as $o): ?>
<tr>
<td class="fw-600"><?= htmlspecialchars($o['numero_oficio']) ?></td>
<td><?= htmlspecialchars(mb_strimwidth($o['asunto'], 0, 60, '…')) ?></td>
<td><?= htmlspecialchars($o['responsable_nombre'] ?? '—') ?></td>
<td><?= date('d/m/Y H:i', strtotime($o['deleted_at'])) ?></td>
<td>
<div class="d-flex gap-1">
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=restaurar&id=<?= $o['id'] ?>"
class="btn btn-sm btn-success"
data-confirm="¿Restaurar este oficio?">
<i class="fa-solid fa-rotate-left"></i> Restaurar
</a>
<a href="<?= APP_URL ?>/controllers/OficioController.php?action=eliminar_fisico&id=<?= $o['id'] ?>"
class="btn btn-sm btn-danger"
data-confirm="⚠️ ¿Eliminar PERMANENTEMENTE? Esta acción no se puede deshacer.">
<i class="fa-solid fa-trash-can"></i> Eliminar
</a>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>$(document).ready(()=>initDataTable('#tablaPapelera'))</script>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

199
views/reportes/index.php Normal file
View File

@ -0,0 +1,199 @@
<?php
/**
* index.php Página de reportes y respaldo
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Oficio.php';
AuthController::requerirAuth();
$esAdmin = AuthController::esAdmin();
$model = new OficioModel();
// Datos para el reporte
$db = getDB();
$resumenEstados = $db->query(
"SELECT estado, COUNT(*) as total FROM oficios WHERE deleted_at IS NULL GROUP BY estado"
)->fetchAll();
$resumenResponsables = $db->query(
"SELECT CONCAT(u.nombre,' ',u.apellido) AS nombre, COUNT(o.id) AS total,
SUM(CASE WHEN o.estado='respondido' THEN 1 ELSE 0 END) AS respondidos,
SUM(CASE WHEN o.estado NOT IN('respondido','archivado') AND o.fecha_vencimiento < CURDATE() THEN 1 ELSE 0 END) AS vencidos
FROM oficios o JOIN usuarios u ON u.id=o.responsable_id
WHERE o.deleted_at IS NULL GROUP BY o.responsable_id
ORDER BY total DESC LIMIT 10"
)->fetchAll();
$pageTitle = 'Reportes y Respaldo';
$activeNav = 'reportes';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Reportes</span>
</div>
<div class="page-header">
<div class="page-header-content">
<h1>Reportes y Respaldo</h1>
<p>Exportación de datos, informes en PDF y respaldo de base de datos</p>
</div>
</div>
<div class="grid-3 mb-4">
<!-- Reporte PDF general -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-file-pdf text-danger"></i>
<span class="card-title">Reporte de Oficios (PDF)</span>
</div>
<div class="card-body">
<p style="font-size:.83rem;color:var(--text-muted);margin-bottom:1rem">
Genera un informe completo de todos los oficios con filtros aplicados, en formato PDF.
</p>
<form method="GET" action="<?= APP_URL ?>/controllers/ReporteController.php" target="_blank">
<input type="hidden" name="action" value="pdf_listado">
<div class="form-group">
<label class="form-label">Mes</label>
<input type="month" class="form-control" name="mes" value="<?= date('Y-m') ?>">
</div>
<div class="form-group">
<label class="form-label">Estado</label>
<select class="form-control" name="estado">
<option value="">Todos</option>
<option value="recibido">Recibido</option>
<option value="en_proceso">En Proceso</option>
<option value="respondido">Respondido</option>
<option value="vencido">Vencido</option>
</select>
</div>
<button type="submit" class="btn btn-danger w-100">
<i class="fa-solid fa-file-pdf"></i> Generar PDF
</button>
</form>
</div>
</div>
<!-- Exportar Excel/CSV -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-file-excel text-success"></i>
<span class="card-title">Exportar a Excel/CSV</span>
</div>
<div class="card-body">
<p style="font-size:.83rem;color:var(--text-muted);margin-bottom:1rem">
Descarga los oficios en formato Excel o CSV para análisis externo.
</p>
<div class="d-flex gap-2 flex-wrap">
<a href="<?= APP_URL ?>/controllers/ReporteController.php?action=export_csv" class="btn btn-success w-100 mb-2">
<i class="fa-solid fa-file-csv"></i> Descargar CSV
</a>
<a href="<?= APP_URL ?>/controllers/ReporteController.php?action=export_excel" class="btn btn-success w-100">
<i class="fa-solid fa-file-excel"></i> Descargar Excel
</a>
</div>
<p style="font-size:.75rem;color:var(--text-muted);margin-top:.75rem">
<i class="fa-solid fa-upload"></i>
<a href="<?= APP_URL ?>/views/reportes/carga_masiva.php" style="color:var(--primary)">
Cargar masiva de oficios (Excel/CSV)
</a>
</p>
</div>
</div>
<!-- Respaldo de BD (solo admin) -->
<?php if ($esAdmin): ?>
<div class="card">
<div class="card-header">
<i class="fa-solid fa-database text-primary"></i>
<span class="card-title">Respaldo Base de Datos</span>
</div>
<div class="card-body">
<p style="font-size:.83rem;color:var(--text-muted);margin-bottom:1rem">
Descarga un respaldo completo de la base de datos en formato SQL.
</p>
<a href="<?= APP_URL ?>/controllers/ReporteController.php?action=backup_sql"
class="btn btn-primary w-100"
data-confirm="¿Generar respaldo de la base de datos? Puede tomar unos segundos.">
<i class="fa-solid fa-download"></i> Descargar Respaldo SQL
</a>
<div class="alert alert-warning" style="margin-top:1rem;font-size:.78rem;padding:.625rem">
<i class="fa-solid fa-triangle-exclamation"></i>
Guarde el respaldo en un lugar seguro. Contiene datos sensibles.
</div>
</div>
</div>
<?php endif; ?>
</div>
<!-- Resumen por estado -->
<div class="grid-2 mb-4">
<div class="card">
<div class="card-header">
<i class="fa-solid fa-chart-pie text-primary"></i>
<span class="card-title">Resumen por Estado</span>
</div>
<div class="card-body" style="padding:0">
<table class="table">
<thead><tr><th>Estado</th><th>Total</th><th>%</th></tr></thead>
<tbody>
<?php
$total_gral = array_sum(array_column($resumenEstados, 'total'));
$badgeClass = ['recibido'=>'badge-primary','en_proceso'=>'badge-warning','respondido'=>'badge-success','vencido'=>'badge-danger','archivado'=>'badge-secondary'];
foreach ($resumenEstados as $r):
$pct = $total_gral > 0 ? round($r['total']/$total_gral*100,1) : 0;
?>
<tr>
<td><span class="badge <?= $badgeClass[$r['estado']]??'badge-secondary' ?>"><?= ucfirst(str_replace('_',' ',$r['estado'])) ?></span></td>
<td class="fw-600"><?= $r['total'] ?></td>
<td>
<div style="display:flex;align-items:center;gap:.5rem">
<div style="flex:1;height:6px;background:var(--border);border-radius:99px;overflow:hidden">
<div style="width:<?= $pct ?>%;height:100%;background:var(--primary);border-radius:99px"></div>
</div>
<?= $pct ?>%
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<!-- Rendimiento por responsable -->
<div class="card">
<div class="card-header">
<i class="fa-solid fa-users text-primary"></i>
<span class="card-title">Rendimiento por Responsable</span>
</div>
<div class="card-body" style="padding:0">
<table class="table">
<thead><tr><th>Responsable</th><th>Total</th><th>Respondidos</th><th>Vencidos</th></tr></thead>
<tbody>
<?php foreach ($resumenResponsables as $r): ?>
<tr>
<td class="fw-600"><?= htmlspecialchars($r['nombre']) ?></td>
<td><?= $r['total'] ?></td>
<td><span class="badge badge-success"><?= $r['respondidos'] ?></span></td>
<td><?= $r['vencidos'] > 0 ? "<span class='badge badge-danger'>{$r['vencidos']}</span>" : '<span class="badge badge-secondary">0</span>' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>

179
views/usuarios/lista.php Normal file
View File

@ -0,0 +1,179 @@
<?php
/**
* lista.php Gestión de usuarios (admin)
*/
require_once __DIR__ . '/../../config/config.php';
require_once __DIR__ . '/../../controllers/AuthController.php';
require_once __DIR__ . '/../../models/Usuario.php';
AuthController::requerirAdmin();
$model = new UsuarioModel();
$usuarios = $model->todos();
$pageTitle = 'Gestión de Usuarios';
$activeNav = 'usuarios';
include __DIR__ . '/../../views/layout/header.php';
include __DIR__ . '/../../views/layout/sidebar.php';
include __DIR__ . '/../../views/layout/topbar.php';
?>
<div class="page-content">
<div class="breadcrumb">
<a href="<?= APP_URL ?>/dashboard.php"><i class="fa-solid fa-house"></i></a>
<i class="fa-solid fa-chevron-right sep"></i>
<span>Usuarios</span>
</div>
<?php $success = $_GET['success'] ?? ''; $error = $_GET['error'] ?? ''; ?>
<?php if ($success): ?><div class="alert alert-success"><i class="fa-solid fa-circle-check"></i> <?= htmlspecialchars($success) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-danger"><i class="fa-solid fa-circle-exclamation"></i> <?= htmlspecialchars($error) ?></div><?php endif; ?>
<div class="page-header">
<div class="page-header-content">
<h1>Gestión de Usuarios</h1>
<p><?= count($usuarios) ?> usuario(s) registrado(s)</p>
</div>
<button class="btn btn-primary" data-modal="modalCrearUsuario">
<i class="fa-solid fa-user-plus"></i> Nuevo Usuario
</button>
</div>
<div class="card">
<div class="card-body" style="padding:0">
<div class="table-responsive">
<table class="table" id="tablaUsuarios">
<thead>
<tr><th>Nombre</th><th>Usuario</th><th>Email</th><th>Área</th><th>Rol</th><th>Último acceso</th><th>Estado</th><th>Acciones</th></tr>
</thead>
<tbody>
<?php foreach ($usuarios as $u): ?>
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<div style="width:32px;height:32px;border-radius:50%;background:linear-gradient(135deg,var(--primary),var(--secondary));display:grid;place-items:center;color:#fff;font-weight:700;font-size:.75rem;flex-shrink:0">
<?= strtoupper(substr($u['nombre'],0,1).substr($u['apellido'],0,1)) ?>
</div>
<div>
<div class="fw-600"><?= htmlspecialchars($u['nombre'].' '.$u['apellido']) ?></div>
<?php if ($u['cargo']): ?>
<div class="fs-sm text-muted"><?= htmlspecialchars($u['cargo']) ?></div>
<?php endif; ?>
</div>
</div>
</td>
<td><code style="background:var(--bg);padding:.15rem .4rem;border-radius:4px;font-size:.8rem"><?= htmlspecialchars($u['username']) ?></code></td>
<td><?= htmlspecialchars($u['email']) ?></td>
<td><?= htmlspecialchars($u['area'] ?? '—') ?></td>
<td>
<span class="badge badge-<?= ['administrador'=>'danger','supervisor'=>'warning','estandar'=>'primary'][$u['rol_nombre']]??'secondary' ?>">
<?= ucfirst($u['rol_nombre']) ?>
</span>
</td>
<td class="fs-sm">
<?= $u['ultimo_login'] ? date('d/m/Y H:i', strtotime($u['ultimo_login'])) : '<span class="text-muted">Nunca</span>' ?>
</td>
<td>
<span class="badge <?= $u['activo'] ? 'badge-success' : 'badge-danger' ?>">
<?= $u['activo'] ? 'Activo' : 'Inactivo' ?>
</span>
</td>
<td>
<div class="d-flex gap-1">
<a href="<?= APP_URL ?>/views/usuarios/editar.php?id=<?= $u['id'] ?>" class="btn btn-sm btn-warning" title="Editar">
<i class="fa-solid fa-pen"></i>
</a>
<?php if ($u['id'] != $_SESSION['usuario_id']): ?>
<a href="<?= APP_URL ?>/controllers/UsuarioController.php?action=eliminar&id=<?= $u['id'] ?>"
class="btn btn-sm btn-danger" title="Eliminar"
data-confirm="¿Desactivar al usuario <?= htmlspecialchars($u['nombre']) ?>?">
<i class="fa-solid fa-user-slash"></i>
</a>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Crear Usuario -->
<div class="modal-overlay" id="modalCrearUsuario">
<div class="modal-box" style="width:min(620px,95vw)">
<div class="modal-header">
<i class="fa-solid fa-user-plus text-primary"></i>
<span class="modal-title">Crear Nuevo Usuario</span>
<button class="btn-icon" data-modal-close><i class="fa-solid fa-xmark"></i></button>
</div>
<form method="POST" action="<?= APP_URL ?>/controllers/UsuarioController.php?action=crear">
<?= csrfField() ?>
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label class="form-label">Nombre <span class="required">*</span></label>
<input type="text" class="form-control" name="nombre" required>
</div>
<div class="form-group">
<label class="form-label">Apellido <span class="required">*</span></label>
<input type="text" class="form-control" name="apellido" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Usuario <span class="required">*</span></label>
<input type="text" class="form-control" name="username" required autocomplete="off">
</div>
<div class="form-group">
<label class="form-label">Email <span class="required">*</span></label>
<input type="email" class="form-control" name="email" required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Contraseña <span class="required">*</span></label>
<input type="password" class="form-control" name="password" minlength="8" required autocomplete="new-password">
</div>
<div class="form-group">
<label class="form-label">Rol <span class="required">*</span></label>
<select class="form-control" name="rol_id" required>
<?php foreach ($model->roles() as $r): ?>
<option value="<?= $r['id'] ?>"><?= ucfirst($r['nombre']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Cargo</label>
<input type="text" class="form-control" name="cargo">
</div>
<div class="form-group">
<label class="form-label">Área</label>
<input type="text" class="form-control" name="area">
</div>
</div>
<div class="form-group">
<label class="form-label">Supervisor</label>
<select class="form-control" name="supervisor_id">
<option value=""> Sin supervisor </option>
<?php foreach ($model->usuariosParaSelector() as $u): ?>
<option value="<?= $u['id'] ?>"><?= htmlspecialchars($u['nombre_completo']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-modal-close>Cancelar</button>
<button type="submit" class="btn btn-primary"><i class="fa-solid fa-user-plus"></i> Crear Usuario</button>
</div>
</form>
</div>
</div>
<script>$(document).ready(()=>initDataTable('#tablaUsuarios'))</script>
<?php include __DIR__ . '/../../views/layout/footer.php'; ?>