comentario: sistema de gestion de archivos y transmites
This commit is contained in:
commit
217a605f2d
52
.env
Normal file
52
.env
Normal 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
52
.env.example
Normal 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
56
.htaccess
Normal 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
175
INSTALACION.md
Normal 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
184
README.md
Normal 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
102
api/oficios.php
Normal 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
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
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
216
assets/js/app.js
Normal 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
161
config/config.php
Normal 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();
|
||||||
|
}
|
||||||
223
controllers/AuthController.php
Normal file
223
controllers/AuthController.php
Normal 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) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
236
controllers/OficioController.php
Normal file
236
controllers/OficioController.php
Normal 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]);
|
||||||
|
}
|
||||||
199
controllers/ReporteController.php
Normal file
199
controllers/ReporteController.php
Normal 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']} · {$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 · {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');
|
||||||
|
}
|
||||||
84
controllers/UsuarioController.php
Normal file
84
controllers/UsuarioController.php
Normal 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
271
cron/enviar_alertas.php
Normal 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
292
dashboard.php
Normal 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>Nº 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'; ?>
|
||||||
361
database/gestion_documentos.sql
Normal file
361
database/gestion_documentos.sql
Normal 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;
|
||||||
37
database/update_to_army_ict.sql
Normal file
37
database/update_to_army_ict.sql
Normal 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';
|
||||||
46
database/update_workflow_v2.sql
Normal file
46
database/update_workflow_v2.sql
Normal 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
11
index.php
Normal 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
254
login.php
Normal 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
8
logout.php
Normal 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
0
logs/.gitkeep
Normal file
369
models/Oficio.php
Normal file
369
models/Oficio.php
Normal 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
150
models/Usuario.php
Normal 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
90
recuperar_password.php
Normal 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
6
uploads/.htaccess
Normal 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
|
||||||
156
views/alertas/configuracion.php
Normal file
156
views/alertas/configuracion.php
Normal 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
18
views/errors/403.php
Normal 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
19
views/layout/footer.php
Normal 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 ?> © <?= 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
20
views/layout/header.php
Normal 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
89
views/layout/sidebar.php
Normal 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
117
views/layout/topbar.php
Normal 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'] ?>¬if=<?= $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
324
views/oficios/crear.php
Normal 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
382
views/oficios/detalle.php
Normal 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>
|
||||||
|
· <?= 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
212
views/oficios/editar.php
Normal 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
280
views/oficios/lista.php
Normal 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>Nº 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'; ?>
|
||||||
94
views/oficios/papelera.php
Normal file
94
views/oficios/papelera.php
Normal 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>Nº 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
199
views/reportes/index.php
Normal 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
179
views/usuarios/lista.php
Normal 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'; ?>
|
||||||
Loading…
x
Reference in New Issue
Block a user