feat: inicializacion de Help Desk segun estandar Telcotronics con bitacora de errores e integracion MCP

This commit is contained in:
2026-10-16 23:29:32 -05:00
commit 0f2708462e
54 changed files with 7590 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# Ideas y Propuestas — Help Desk
## Ideas Pendientes de Evaluación
1. **API RESTful dedicada con JWT:**
- Exponer endpoints versionados `/api/v1/tickets` protegidos por tokens Bearer para integración con clientes móviles (ej. Flutter) o sistemas externos.
2. **Integración con bot de comunicaciones (sigma-robot):**
- Notificaciones inmediatas en canales de mensajería (Telegram / WhatsApp) cuando se registre un ticket con prioridad `critica` o cuando un agente responda.
3. **Subida real de adjuntos:**
- Implementar almacenamiento de capturas de pantalla y archivos adjuntos (usando `express-fileupload`) con persistencia en directorio dedicado o servicio de almacenamiento compartido del ecosistema.
4. **SLA y métricas de resolución:**
- Panel visual con tiempos medios de respuesta por departamento y alertas cuando un ticket supere 24/48 horas sin respuesta.
---
## Borradores y Exploraciones
- *Borrador:* Integración de IA para categorización automática del departamento según el asunto y mensaje del ticket.
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
# Objetivos del Proyecto — Help Desk
## Objetivos en Curso
*(Ninguno actualmente en curso)*
---
## Objetivos Pendientes
| ID | Objetivo | Origen | Prioridad | Criterio de Aceptación |
|---|---|---|---|---|
| **OBJ-001** | Script `init.sh` estandarizado del ecosistema | Estándar Telcotronics | Alta | Menú interactivo/flags para arranque local, validación de variables de entorno y conexión con MariaDB |
| **OBJ-002** | Script `desplegar.sh` para producción | Estándar Telcotronics | Media | Automatización de sincronización y preparación hacia servidor de producción Proxmox |
| **OBJ-003** | Ficha de servicio registrada en el MCP de Infraestructura | Requerimiento ecosistema | Alta | Reintentar registro vía `crear_servicio` tras resolución de Feedback #20 (Error 1114: table 'services' is full) |
| **OBJ-004** | Depuración o activación de API REST JWT | Memoria técnica | Media | Decidir entre implementar endpoints `/api/v1` para clientes externos o retirar paquete `jsonwebtoken` |
| **OBJ-005** | Adaptación de vistas UI al SIGMA Design System | Guía de diseño del ecosistema | Baja | Colores corporativos, tipografía y componentes alineados con `design-system.md` |
---
## Objetivos Completados
| Fecha | Objetivo | Evidencia |
|---|---|---|
| 2026-10-16 | **OBJ-006:** Integración de bitácora centralizada de errores y remoto Git | Módulo `config/errorReporter.js`, middleware global en `app.js`, script `scripts/consultar_errores.js` (`npm run check:errors`) y remoto `git.telcotronics.net/pablinux/help_desk.git` configurado |
| 2026-10-16 | **OBJ-000:** Inicialización documental estandarizada según estándar Telcotronics | Estructura `.agente/` completa, espejo `infraestructura.md` sincronizado al 100%, `id_agente.md`, `agents.md` y `README.md` alineados |
| 2026-05-16 | Implementación del backend base con Express 5 y MariaDB | Modelos `User`, `Department`, `Ticket`, rutas funcionales y conexión a `192.168.10.149` |
| 2026-05-16 | Implementación de vistas EJS y roles client, agent, admin | Plantillas en `views/` con Bootstrap 5, autenticación con bcrypt y session |
| 2026-05-16 | Evolución multi-tenant SaaS y modelo Company | Migración en `config/setup.js`, modelo `Company.js`, rutas `/saas` y `/company` |
+38
View File
@@ -0,0 +1,38 @@
# Bitácora de Errores e Incidentes — Help Desk
## Incidente #1: Comportamiento inestable de `node --watch`
- **Fecha:** 2026-05-16
- **Síntoma:** El comando `node --watch app.js` (`npm run dev`) no detecta recargas o falla en ciertos entornos Linux / terminales virtuales.
- **Causa confirmada:** Limitación del soporte experimental de `--watch` en ciertas versiones y sistemas de archivos bajo Linux.
- **Solución / Alternativa:** Utilizar `nodemon` para desarrollo o arrancar con `npm start` estándar.
- **Verificación:** Ejecución directa con `node app.js` funciona de forma estable.
- **Estado:** Resuelto (con workaround documentado).
---
## Incidente #2: Dependencia no utilizada en código activo (`jsonwebtoken`)
- **Fecha:** 2026-05-16
- **Síntoma:** `jsonwebtoken` figura en las dependencias de `package.json`, pero no es importado en ninguna ruta activa (`routes/`).
- **Causa confirmada:** Se planificó inicialmente una API REST para clientes móviles, pero se implementó interfaz web monousuario/multi-tenant con `express-session`.
- **Solución:** Mantener identificada la dependencia; evaluar si se implementará la API REST para la app móvil o desinstalarla para limpiar el árbol de dependencias.
- **Estado:** Pendiente de decisión técnica.
---
## Incidente #3: Error 1114 al registrar servicio en MCP de Infraestructura
- **Fecha:** 2026-10-16
- **Síntoma:** Al invocar `crear_servicio` en el MCP de Infraestructura (`192.168.10.160:3006`), MariaDB devolvió: `MysqlError 1114 (HY000): The table 'services' is full`.
- **Causa confirmada:** La tabla `services` en la base de datos `mcp_infraestructura` (MariaDB `192.168.10.149:3306`) alcanzó su límite de tamaño o memoria asignada.
- **Solución / Seguimiento:** Se reportó como **Feedback #20** (`helpdesk-services-full-20261016`, severidad `CRITICAL`, estado `OPEN`) en el sistema de feedback del MCP. Reintentar el registro una vez resuelto en MariaDB.
- **Estado:** Reportado / Pendiente de resolución externa.
---
## Integración con Bitácora Centralizada (API-SIGMA-WEBCONTROL)
- **Endpoint de Reporte:** `POST https://api.siax-system.net/api/errores`
- **Módulo implementado:** `config/errorReporter.js` + middleware global en `app.js`.
- **Filtro de consulta:** `GET https://api.siax-system.net/api/errores?app_origen=Help Desk`
- **Comando de verificación:** `npm run check:errors` (o `node scripts/consultar_errores.js`).
- **Consola visual humana:** `https://siax-system.net` (WebControlSigma).
+127
View File
@@ -0,0 +1,127 @@
# Estructura del Proyecto — Help Desk
## Árbol de Archivos
```text
help_desk/
├── app.js # Punto de entrada de la aplicación Express
├── package.json # Metadatos del proyecto y dependencias npm
├── package-lock.json # Bloqueo de versiones de dependencias
├── .env.example # Plantilla de variables de entorno (sin secretos)
├── .gitignore # Ignorados por Git (node_modules, .env)
├── id_agente.md # Identidad del agente en sigma-robot (estado local)
├── README.md # Documentación general para humanos
├── agents.md # Punto de entrada principal para agentes IA
├── .agente/ # Documentación interna del proyecto
│ ├── infraestructura.md # Espejo del inventario global de infraestructura
│ ├── proyecto_memoria.md # Historial de decisiones y arquitectura
│ ├── proyecto_estructura.md # Este archivo: árbol, rutas y dependencias
│ ├── proyecto_errores.md # Bitácora de incidentes y problemas conocidos
│ ├── objetivos.md # Objetivos pendientes, en curso y completados
│ ├── reglas.md # Restricciones técnicas y de seguridad
│ └── ideas.md # Ideas y propuestas pendientes
├── config/
│ ├── db.js # Pool de conexión a MariaDB con mysql2/promise
│ ├── errorReporter.js # Cliente y reporter de errores hacia API-SIGMA-WEBCONTROL
│ ├── init.sql # Esquema SQL relacional base
│ └── setup.js # Script de inicialización y migración multi-tenant
├── scripts/
│ └── consultar_errores.js # Script CLI para consultar la bitácora centralizada de errores
├── middleware/
│ └── auth.js # Middlewares: isAuthenticated, roles y loadCompany
├── models/
│ ├── Company.js # Consultas y operaciones para empresas/tenants
│ ├── Department.js # Operaciones CRUD para departamentos
│ ├── Ticket.js # Consultas, creación y respuestas de tickets
│ └── User.js # Autenticación, creación y gestión de usuarios
├── routes/
│ ├── auth.js # Rutas de autenticación (login, register, logout)
│ ├── client.js # Rutas para clientes (creación y seguimiento de tickets)
│ ├── agent.js # Rutas para agentes (gestión, respuesta y asignación)
│ ├── admin.js # Rutas para administradores (usuarios, departamentos)
│ ├── saas.js # Rutas para superadmin (empresas y tenants)
│ └── company.js # Rutas de ajustes de la empresa
├── views/
│ ├── partials/
│ │ ├── header.ejs # Encabezado HTML y carga de CSS/CDN
│ │ ├── footer.ejs # Scripts de Bootstrap y cierre HTML
│ │ ├── navbar.ejs # Barra de navegación superior con perfil
│ │ └── sidebar.ejs # Menú lateral dinámico según rol
│ ├── auth/
│ │ ├── login.ejs # Vista de inicio de sesión
│ │ └── register.ejs # Vista de registro de usuario
│ ├── client/
│ │ ├── dashboard.ejs # Panel principal de cliente con sus tickets
│ │ ├── new-ticket.ejs # Formulario para registrar nuevo ticket
│ │ └── view-ticket.ejs # Detalle del ticket y formulario de respuesta
│ ├── agent/
│ │ ├── dashboard.ejs # Panel general de tickets para agentes
│ │ └── view-ticket.ejs # Vista de resolución, reasignación y respuesta
│ ├── admin/
│ │ ├── dashboard.ejs # Panel de control de administrador con estadísticas
│ │ ├── users.ejs # Lista y gestión de usuarios
│ │ ├── create-user.ejs # Formulario para dar de alta usuarios
│ │ └── departments.ejs # Gestión de departamentos de soporte
│ ├── saas/
│ │ └── companies.ejs # Gestión de empresas para superadmin
│ └── company/
│ └── settings.ejs # Configuración general de la empresa
└── public/
├── css/
│ └── style.css # Estilos personalizados de la interfaz
└── js/
└── main.js # Lógica JavaScript en navegador
```
---
## Tabla de Rutas HTTP
| Método | Ruta | Middleware de Acceso | Descripción |
|---|---|---|---|
| **GET** | `/` | Sesión / Redirección | Redirige al dashboard correspondiente según el rol del usuario |
| **GET** | `/login` | Público | Renderiza formulario de login |
| **POST** | `/login` | Público | Valida credenciales e inicializa sesión |
| **GET** | `/register` | Público | Formulario de registro de cliente |
| **POST** | `/register` | Público | Crea usuario nuevo con rol `client` |
| **GET** | `/logout` | `isAuthenticated` | Cierra la sesión y redirige a login |
| **GET** | `/client/dashboard` | `isAuthenticated`, `isClient` | Listado de tickets creados por el cliente |
| **GET** | `/client/tickets/new` | `isAuthenticated`, `isClient` | Formulario de nuevo ticket |
| **POST** | `/client/tickets` | `isAuthenticated`, `isClient` | Registro de nuevo ticket |
| **GET** | `/client/tickets/:id` | `isAuthenticated`, `isClient` | Vista de detalle de un ticket y su conversación |
| **POST** | `/client/tickets/:id/reply` | `isAuthenticated`, `isClient` | Publica una respuesta a un ticket propio |
| **GET** | `/agent/dashboard` | `isAuthenticated`, `isAgent` | Bandeja de tickets de la empresa para agentes |
| **GET** | `/agent/tickets/:id` | `isAuthenticated`, `isAgent` | Detalle del ticket, cambio de estado y asignación |
| **POST** | `/agent/tickets/:id/reply` | `isAuthenticated`, `isAgent` | Envía respuesta oficial del agente |
| **POST** | `/agent/tickets/:id/status`| `isAuthenticated`, `isAgent` | Actualiza estado: `abierto`, `en_progreso`, `cerrado` |
| **POST** | `/agent/tickets/:id/assign`| `isAuthenticated`, `isAgent` | Asigna ticket a un agente específico |
| **GET** | `/admin/dashboard` | `isAuthenticated`, `isAdmin` | Métricas y estadísticas de la empresa |
| **GET** | `/admin/users` | `isAuthenticated`, `isAdmin` | Lista de usuarios del tenant |
| **GET** | `/admin/users/create` | `isAuthenticated`, `isAdmin` | Formulario para crear agentes o administradores |
| **POST** | `/admin/users` | `isAuthenticated`, `isAdmin` | Alta de usuario con rol específico |
| **POST** | `/admin/users/:id/toggle` | `isAuthenticated`, `isAdmin` | Activa o desactiva acceso de un usuario |
| **GET** | `/admin/departments` | `isAuthenticated`, `isAdmin` | Catálogo de departamentos |
| **POST** | `/admin/departments` | `isAuthenticated`, `isAdmin` | Creación de departamento |
| **POST** | `/admin/departments/:id/delete` | `isAuthenticated`, `isAdmin` | Eliminación de departamento |
| **GET** | `/saas/companies` | `isAuthenticated`, `isSuperAdmin` | Panel maestro de organizaciones para Superadmin |
| **POST** | `/saas/companies` | `isAuthenticated`, `isSuperAdmin` | Creación de nuevo tenant / empresa |
| **POST** | `/saas/companies/:id/toggle` | `isAuthenticated`, `isSuperAdmin` | Activa / suspende un tenant |
| **GET** | `/company/settings` | `isAuthenticated`, `isAdmin` | Configuración y estado de la empresa |
---
## Dependencias Principales
| Paquete | Versión | Rol |
|---|---|---|
| `express` | ^5.2.1 | Framework web principal |
| `mysql2` | ^3.22.3 | Conector a MariaDB / MySQL con soporte para promesas |
| `ejs` | ^5.0.2 | Motor de renderizado del lado del servidor |
| `express-session` | ^1.19.0 | Manejo de sesiones de usuario |
| `bcryptjs` | ^3.0.3 | Hashing y verificación de contraseñas |
| `connect-flash` | ^0.1.1 | Mensajes flash temporales en vistas |
| `cookie-parser` | ^1.4.7 | Parseo de cookies HTTP |
| `express-fileupload`| ^1.5.2 | Manejo de archivos subidos en formularios |
| `morgan` | ^1.10.1 | Logger HTTP en consola |
| `dotenv` | ^17.4.2 | Carga de variables de entorno desde `.env` |
| `jsonwebtoken` | ^9.0.3 | Instalado (reservado para endpoints de API REST) |
+58
View File
@@ -0,0 +1,58 @@
# Memoria Técnica del Proyecto — Help Desk
## Fecha de inicio
16 de Mayo, 2026
## Stack tecnológico
- **Backend / Runtime:** Node.js con Express 5.2
- **Motor de plantillas:** EJS 5 con partials modulares
- **Base de datos:** MariaDB / MySQL con `mysql2` (modo async/promises)
- **Autenticación y Seguridad:** `express-session`, `cookie-parser`, `bcryptjs`
- **Frontend / UI:** Bootstrap 5.x + Font Awesome 6 vía CDN
- **Middlewares complementarios:** `morgan` (logging HTTP), `connect-flash` (mensajes flash), `express-fileupload` (subida de archivos)
---
## Decisiones de Arquitectura
1. **MySQL/MariaDB vs MongoDB:**
- Decisión: Se utiliza MariaDB (servidor `192.168.10.149`), alineado al stack relacional habitual de servicios internos. El modelo relacional asegura integridad referencial estricta entre empresas, usuarios, departamentos, tickets y respuestas.
2. **express-session vs JWT:**
- Decisión: Se optó por sesiones del lado del servidor con `express-session` para la interfaz web renderizada con EJS. Esto permite la persistencia natural de la sesión en cookies de navegador y la integración directa con `connect-flash`. Aunque `jsonwebtoken` se incluyó en las dependencias iniciales, se mantiene en reserva para una futura API REST.
3. **Arquitectura Multi-tenant (SaaS):**
- Decisión: El esquema evolucionó desde una aplicación simple monousuario/monoempresa a un modelo multi-tenant basado en la entidad `companies`. Todas las entidades dependientes (`users`, `departments`, `tickets`) contienen `company_id` obligatorio.
- Existe una empresa por defecto (`Demo Company` con slug `demo`) creada por el script de setup.
4. **Roles y Permisos:**
- Se definieron cuatro niveles de autorización controlados por middleware (`middleware/auth.js`):
- `superadmin`: Gestión global de empresas y capacidades del SaaS.
- `admin`: Administrador de una empresa específica (gestiona agentes, clientes, departamentos y tickets de su empresa).
- `agent`: Agente de soporte técnico (gestiona tickets asignados o de su departamento, responde y cambia estados).
- `client`: Usuario final (crea tickets, visualiza sus tickets y añade respuestas).
5. **EJS con Partials:**
- Decisión: Se utilizan partials (`header.ejs`, `footer.ejs`, `navbar.ejs`, `sidebar.ejs`) sin frameworks de layout adicionales para mantener simplicidad y bajo acoplamiento.
6. **Estilos y Assets Livianos:**
- Decisión: Bootstrap 5 y Font Awesome se cargan desde CDN público, complementados con `public/css/style.css` y `public/js/main.js` para personalizaciones locales.
---
## Base de Datos
- **Servidor:** MariaDB en `192.168.10.149:3306` (Contenedor CT 114)
- **Usuario:** `helpdesk`
- **Base de datos:** `help_desk`
- **Tablas:**
- `companies`: Gestión de empresas/organizaciones (id, name, slug, plan, max_users, max_tickets, is_active, timestamps).
- `users`: Usuarios de cada empresa (id, company_id, nombre, email, password, rol, is_active, invite_token, invited_by, timestamps).
- `departments`: Departamentos de soporte por empresa (id, company_id, nombre, descripcion, created_at).
- `tickets`: Tickets de soporte (id, company_id, user_id, department_id, asunto, mensaje, status, prioridad, assigned_to, timestamps).
- `ticket_respuestas`: Mensajes e hilo de conversación de cada ticket (id, ticket_id, user_id, mensaje, created_at).
---
## Puerto del Servidor
- **Puerto configurado:** `3008` (por variable `PORT=3008` en `.env`).
+21
View File
@@ -0,0 +1,21 @@
# Reglas Técnicas y de Operación — Help Desk
## 1. Regla de Seguridad Crítica en Infraestructura (MCP)
- **Alcance:** Todas las operaciones con el servidor MCP de Infraestructura (`192.168.10.160:3006`).
- **Restricción:** El servidor MCP tiene bloqueada toda operación de eliminación de registros. La eliminación de cualquier servicio, hardware o registro SOLO puede realizarse desde la interfaz web bajo supervisión humana explícita.
- **Motivo:** Evitar borrados accidentales de elementos compartidos del ecosistema por parte de agentes autónomos.
## 2. Aislamiento Multi-Tenant Estricto
- **Alcance:** Modelos y consultas SQL a la base de datos (`models/*.js`).
- **Restricción:** Toda consulta, creación o actualización de tickets, departamentos o usuarios que pertenezcan a un tenant DEBE incluir la cláusula `WHERE company_id = ?` correspondiente a la sesión activa (`req.session.user.company_id`).
- **Motivo:** Garantizar la confidencialidad y aislamiento de datos entre empresas.
## 3. Manejo de Secretos y Repositorios
- **Alcance:** Archivo `.env` y configuración local.
- **Restricción:** Nunca comitear credenciales, passwords de base de datos ni `SESSION_SECRET` a Git. El archivo `.env` debe permanecer en `.gitignore`. Para repositorios se mantiene `.env.example` sin valores sensibles.
- **Motivo:** Seguridad de la red interna y cumplimiento de buenas prácticas.
## 4. Rutina de Actualización Documental
- **Alcance:** Mantenimiento y evolución del proyecto.
- **Restricción:** Al solicitar "actualizar proyecto", se debe revisar y sincronizar `agents.md`, `.agente/proyecto_estructura.md`, `.agente/proyecto_memoria.md`, `.agente/proyecto_errores.md`, `.agente/objetivos.md`, `README.md` y la ficha en el MCP de Infraestructura.
- **Motivo:** Preservar la fuente de verdad técnica y la coherencia en todo el ecosistema.
+11
View File
@@ -0,0 +1,11 @@
PORT=3008
DB_HOST=192.168.10.149
DB_USER=helpdesk
DB_PASSWORD=
DB_NAME=help_desk
SESSION_SECRET=secreto_helpdesk
JWT_SECRET=jwt_secreto_helpdesk
# Bitácora centralizada de errores (API-SIGMA-WEBCONTROL)
ERROR_REPORTING_URL=https://api.siax-system.net/api/errores
WEBCONTROL_API_KEY=
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.env
+96
View File
@@ -0,0 +1,96 @@
# Help Desk — Sistema Multi-Tenant de Gestión de Incidencias
Sistema web modular para gestión de tickets de soporte técnico, atención al cliente y administración multi-tenant (SaaS), construido sobre Node.js, Express 5 y MariaDB.
---
## 🎯 ¿Qué resuelve?
Centraliza la recepción, triaje, asignación y resolución de solicitudes de soporte técnico dentro de las organizaciones del ecosistema Telcotronics. Permite que múltiples empresas o departamentos gestionen sus tickets de forma completamente aislada a través de una arquitectura SaaS integrada.
---
## 👥 Roles de Usuario y Capacidades
| Rol | Dashboard / Acceso | Capacidades Principales |
|---|---|---|
| **Superadmin** | `/saas/companies` | Alta de empresas, activación/suspensión de tenants y control de cuotas |
| **Admin** | `/admin/dashboard` | Gestión de agentes y clientes, CRUD de departamentos y configuración del tenant |
| **Agente** | `/agent/dashboard` | Bandeja de tickets, cambio de estado (`abierto`, `en_progreso`, `cerrado`), asignación y respuestas |
| **Cliente** | `/client/dashboard` | Apertura de tickets con prioridad y departamento, consulta de historial y respuestas |
---
## 🛠️ Stack Tecnológico
- **Backend:** Node.js 18+ con Express 5.2.
- **Vistas:** EJS con partials modulares.
- **Base de Datos:** MariaDB / MySQL vía conector nativo `mysql2/promise`.
- **Autenticación:** Sesiones del lado del servidor con `express-session` y contraseñas cifradas con `bcryptjs`.
- **Frontend:** Bootstrap 5.3 + Font Awesome 6 (vía CDN) y CSS/JS personalizados.
---
## 📋 Requisitos Previos
- Node.js versión 18 o superior.
- MariaDB 10.x / MySQL 8.x con acceso a crear bases de datos y tablas.
---
## 🚀 Instalación y Puesta en Marcha
### 1. Clonar e instalar dependencias
```bash
git clone <url-repositorio> help_desk
cd help_desk
npm install
```
### 2. Configurar variables de entorno
Crea un archivo `.env` en la raíz basándote en la plantilla:
```ini
PORT=3008
DB_HOST=192.168.10.149
DB_USER=helpdesk
DB_PASSWORD=tu_password_segura
DB_NAME=help_desk
SESSION_SECRET=tu_secreto_de_sesion
JWT_SECRET=tu_jwt_secret
```
### 3. Inicializar y migrar la Base de Datos
Ejecuta el script de configuración automática para crear la base de datos, tablas, relaciones y datos semilla (empresa demo y usuarios iniciales):
```bash
node config/setup.js
```
### 4. Iniciar la Aplicación
```bash
# Modo producción
npm start
# Modo desarrollo
node app.js
```
Acceso en navegador: `http://localhost:3008`
---
## 🔑 Credenciales por Defecto (Seed)
| Rol | Correo Electrónico | Contraseña |
|---|---|---|
| **Superadmin** | `super@helpdesk.com` | `123456` |
| **Admin** | `admin@helpdesk.com` | `123456` |
| **Agente** | `agent@helpdesk.com` | `123456` |
| **Cliente** | `client@helpdesk.com` | `123456` |
---
## 📁 Documentación para Agentes e Infraestructura
- **Punto de Entrada de Agentes:** [agents.md](file:///home/pablinux/Projects/Node/help_desk/agents.md)
- **Documentación Interna del Proyecto:** Carpeta [.agente/](file:///home/pablinux/Projects/Node/help_desk/.agente/)
- **Inventario Global del Ecosistema:** [.agente/infraestructura.md](file:///home/pablinux/Projects/Node/help_desk/.agente/infraestructura.md)
- **Identidad del Agente:** [id_agente.md](file:///home/pablinux/Projects/Node/help_desk/id_agente.md)
+184
View File
@@ -0,0 +1,184 @@
# agents.md — Punto de Entrada del Proyecto Help Desk
> **Reglas Obligatorias:**
> - Este archivo es el eslabón más importante de la documentación. Leerlo siempre antes de cualquier tarea.
> - Cada cambio significativo debe reflejarse en este archivo y en los demás archivos del índice documental (`.agente/`).
> - Debe ser técnico, claro y detallado para que cualquier humano, agente o IA lo entienda sin explicaciones previas.
> - **Rutina al actualizar el proyecto:** Al actualizar el proyecto, revisar objetivos, errores, memoria, reglas, estructura y README; actualizar la ficha propia en MCP con datos verificados; registrar pruebas y pendientes. Hacer commit de los cambios revisados cuando se solicite actualizar proyecto. El push se realiza según la instrucción vigente del usuario y se confirma contra el remoto; un commit local no prueba publicación.
---
## Antecedentes
Help Desk es la aplicación de soporte técnico y gestión de incidencias del ecosistema Telcotronics. Nació de la necesidad de centralizar las solicitudes de asistencia, reportes de averías y consultas operativas de los usuarios y clientes de los servicios de la red. Evolucionó desde una solución básica monousuario a una arquitectura multi-tenant (SaaS) que permite segregar la atención por empresas manteniendo una única infraestructura backend.
---
## Objetivo General
Proveer un sistema web robusto, ágil y seguro para la gestión integral de tickets de soporte técnico, con separación de roles y aislamiento por empresa (multi-tenant).
### Responsabilidades Principales:
1. **Atención a clientes:** Registro simplificado de incidencias, categorización por departamento, priorización y seguimiento del hilo de conversación.
2. **Gestión técnica de agentes:** Panel operativo para triaje, respuesta, asignación y resolución de tickets.
3. **Administración por tenant:** Gestión de usuarios, departamentos y configuración propia de cada organización.
4. **Control SaaS global:** Creación, activación y control de cuotas de empresas cliente (tenants).
---
## Stack Técnico
| Componente | Tecnología | Versión |
|---|---|---|
| **Runtime / Backend** | Node.js | v18+ |
| **Framework HTTP** | Express | ^5.2.1 |
| **Motor de Vistas** | EJS | ^5.0.2 |
| **Base de Datos** | MariaDB / MySQL (`mysql2/promise`) | 10.11+ / 8.0+ |
| **Autenticación** | `express-session` + `bcryptjs` | ^1.19.0 / ^3.0.3 |
| **UI Framework** | Bootstrap 5 + Font Awesome 6 (CDN) | 5.3 / 6.4 |
| **Middlewares** | `morgan`, `connect-flash`, `cookie-parser`, `express-fileupload` | Estándar |
---
## Módulos del Sistema y Estado
### 1. Autenticación y Control de Acceso
**Estado: ✅ Operativo**
- Control de sesión servidor (`express-session`) con hashing `bcryptjs`.
- Protección por middleware (`middleware/auth.js`) para 4 roles: `superadmin`, `admin`, `agent`, `client`.
- Inyección de datos de contexto (`loadCompany`, `res.locals`).
### 2. Portal de Clientes (`/client`)
**Estado: ✅ Operativo**
- Dashboard de tickets propios filtrados por usuario y empresa.
- Formulario de creación de ticket (asunto, departamento, prioridad, mensaje).
- Hilo de discusión y respuestas en tiempo real por recarga/flash.
### 3. Consola de Agentes (`/agent`)
**Estado: ✅ Operativo**
- Bandeja central de tickets del tenant.
- Cambio de estado: `abierto` -> `en_progreso` -> `cerrado`.
- Reasignación de agente responsable y respuesta a incidencias.
### 4. Administración de Empresa (`/admin`)
**Estado: ✅ Operativo**
- Panel de estadísticas básicas.
- CRUD de departamentos (Soporte Técnico, Facturación, Ventas, etc.).
- Gestión de usuarios del tenant (activación/desactivación y roles).
### 5. Consola SaaS Superadmin (`/saas`)
**Estado: ✅ Operativo**
- Gestión global de empresas/tenants (`companies`).
- Asignación de planes (`free`, `pro`, `enterprise`) y cuotas máximas de usuarios y tickets.
---
## Infraestructura y Fuentes de Verdad
- **Repositorio Git Oficial:** `https://git.telcotronics.net/pablinux/help_desk.git` (remoto `origin` configurado localmente).
- **Servidor MCP de Infraestructura:** `http://192.168.10.160:3006/sse` (modo SSE). Panel web: `http://192.168.10.160:3007`.
- **Ficha MCP de Help Desk:**
- Categoría: `service` / `application`
- Estado en inventario: ⏳ Pendiente de registro remoto. Se intentó registrar vía `crear_servicio`, pero la base de datos del MCP arrojó `MysqlError 1114 (The table 'services' is full)`. Se registró el incidente formalmente en el sistema de feedback del MCP como **Feedback #20** (`helpdesk-services-full-20261016`, estado `OPEN`).
- **Base de Datos Central:**
- Host: `192.168.10.149:3306` (CT 114 en Proxmox).
- Base de datos: `help_desk` (Verificada: 5 tablas activas: `companies`, `departments`, `ticket_respuestas`, `tickets`, `users`).
- Usuario: `helpdesk`.
- **Puerto Local de Ejecución:** `3008`.
### Documentos Compartidos del Ecosistema
- `/home/pablinux/Projects/infraestructura.md`: Respaldado localmente en `.agente/infraestructura.md`.
- `/home/pablinux/Projects/design-system.md`: Estándar visual SIGMA.
- `/home/pablinux/Projects/roadmap.md`: Decisiones y prioridades estratégicas.
---
## Bitácora Centralizada de Errores (Ecosistema Telcotronics)
Este proyecto reporta sus errores de runtime a la bitácora centralizada de **API-SIGMA-WEBCONTROL** (`https://api.siax-system.net`). Esto tiene dos direcciones — implementar el reporte es solo la mitad: quien mantenga este proyecto (agente o humano) debe además **consultar** periódicamente qué se reportó, no asumir que reportar es "trabajo terminado".
**Fuente autoritativa del contrato:**
- Swagger en vivo: `https://api.siax-system.net/api-docs` → tag "Errores de Sistemas".
- `agents.md` de API-SIGMA-WEBCONTROL y MCP de Infraestructura (`infra://guias/bitacora-errores`).
**Separación de canales:**
- La bitácora humana está en `https://siax-system.net` (WebControlSigma).
- El único destino de reporte de runtime es `POST https://api.siax-system.net/api/errores`.
- El MCP interno distribuye guías e inventario; no transporta errores ni hace de proxy de la API de errores.
### Reportar — `POST https://api.siax-system.net/api/errores`
- Auth: `x-api-key` a nivel-app (`WEBCONTROL_API_KEY`).
- Implementado en Express vía middleware en [config/errorReporter.js](file:///home/pablinux/Projects/Node/help_desk/config/errorReporter.js).
- Parámetros: `app_origen: "Help Desk"`, `mensaje`, `detalle` (stack trace), `categoria` (`sistema`, `bd`, `auth`, `red`), `codigo`, `clase`, `metodo`, `usuario`, `cuenta`, `version`, `fecha_evento`.
- Deduplicación automática en memoria con throttle de 10 minutos por firma de error.
### Consultar — Descargar lo que ya se reportó de esta app
Al empezar una sesión de mantenimiento de este proyecto (o cuando se pida revisar errores/bugs reportados por usuarios reales), traer los propios antes de asumir que no hay nada nuevo:
- **CLI rápido:** `npm run check:errors` (ejecuta [scripts/consultar_errores.js](file:///home/pablinux/Projects/Node/help_desk/scripts/consultar_errores.js)).
- **HTTP directo:** `GET https://api.siax-system.net/api/errores?app_origen=Help Desk` (con header `x-api-key`).
- **Resumen:** `GET https://api.siax-system.net/api/errores/resumen?dias=7`.
- **Conversión a Objetivos:** Cualquier error nuevo se registra en [.agente/objetivos.md](file:///home/pablinux/Projects/Node/help_desk/.agente/objetivos.md) como objetivo verificable y en [.agente/proyecto_errores.md](file:///home/pablinux/Projects/Node/help_desk/.agente/proyecto_errores.md).
---
## Variables de Entorno (`.env`)
```ini
PORT=3008
DB_HOST=192.168.10.149
DB_USER=helpdesk
DB_PASSWORD=[PROTEGIDO_VER_ENV_LOCAL]
DB_NAME=help_desk
SESSION_SECRET=[SECRETO_SESION]
JWT_SECRET=[SECRETO_JWT]
# Bitácora de Errores (API-SIGMA-WEBCONTROL)
ERROR_REPORTING_URL=https://api.siax-system.net/api/errores
WEBCONTROL_API_KEY=[API_KEY_WEBCONTROL]
```
---
## Migraciones y Configuración de Base de Datos
Para inicializar las tablas y datos semilla de la base de datos:
```bash
node config/setup.js
```
El script verifica la conexión, crea las tablas si no existen, aplica migraciones de columnas multi-tenant y siembra la empresa `demo`, departamentos y los cuatro usuarios base:
- `super@helpdesk.com` / `123456` (superadmin)
- `admin@helpdesk.com` / `123456` (admin)
- `agent@helpdesk.com` / `123456` (agent)
- `client@helpdesk.com` / `123456` (client)
---
## Arranque Local
```bash
# Instalación de dependencias
npm install
# Inicializar/verificar BD
node config/setup.js
# Arrancar aplicación
npm start
# O desarrollo
node app.js
```
Acceso web en navegador: `http://localhost:3008`
---
## Índice de Archivos del Proyecto
- [id_agente.md](file:///home/pablinux/Projects/Node/help_desk/id_agente.md): Identidad del agente asignada por sigma-robot.
- [README.md](file:///home/pablinux/Projects/Node/help_desk/README.md): Manual y guía general para operadores humanos.
- [.agente/infraestructura.md](file:///home/pablinux/Projects/Node/help_desk/.agente/infraestructura.md): Espejo 100% sincronizado del inventario global.
- [.agente/proyecto_memoria.md](file:///home/pablinux/Projects/Node/help_desk/.agente/proyecto_memoria.md): Memoria técnica y decisiones de diseño.
- [.agente/proyecto_estructura.md](file:///home/pablinux/Projects/Node/help_desk/.agente/proyecto_estructura.md): Árbol de archivos y tabla completa de endpoints HTTP.
- [.agente/proyecto_errores.md](file:///home/pablinux/Projects/Node/help_desk/.agente/proyecto_errores.md): Bitácora de incidentes y problemas conocidos.
- [.agente/objetivos.md](file:///home/pablinux/Projects/Node/help_desk/.agente/objetivos.md): Registro de objetivos completados, en curso y pendientes.
- [.agente/reglas.md](file:///home/pablinux/Projects/Node/help_desk/.agente/reglas.md): Reglas de seguridad y buenas prácticas del proyecto.
- [.agente/ideas.md](file:///home/pablinux/Projects/Node/help_desk/.agente/ideas.md): Registro de propuestas e ideas en evaluación.
+91
View File
@@ -0,0 +1,91 @@
const express = require('express');
const session = require('express-session');
const flash = require('connect-flash');
const morgan = require('morgan');
const path = require('path');
const dotenv = require('dotenv');
const fileUpload = require('express-fileupload');
const cookieParser = require('cookie-parser');
const { loadCompany } = require('./middleware/auth');
dotenv.config();
const authRoutes = require('./routes/auth');
const clientRoutes = require('./routes/client');
const agentRoutes = require('./routes/agent');
const adminRoutes = require('./routes/admin');
const saasRoutes = require('./routes/saas');
const companyRoutes = require('./routes/company');
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(morgan('dev'));
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser());
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false
}));
app.use(flash());
app.use(fileUpload());
app.use(express.static(path.join(__dirname, 'public')));
app.use((req, res, next) => {
res.locals.success_msg = req.flash('success_msg');
res.locals.error_msg = req.flash('error_msg');
res.locals.error = req.flash('error');
res.locals.user = req.session.user || null;
next();
});
app.use(loadCompany);
app.use('/', authRoutes);
app.use('/client', clientRoutes);
app.use('/agent', agentRoutes);
app.use('/admin', adminRoutes);
app.use('/saas', saasRoutes);
app.use('/company', companyRoutes);
const { reportarError } = require('./config/errorReporter');
app.get('/', (req, res) => {
if (req.session.user) {
if (req.session.user.rol === 'superadmin') return res.redirect('/saas/companies');
if (req.session.user.rol === 'admin') return res.redirect('/admin/dashboard');
if (req.session.user.rol === 'agent') return res.redirect('/agent/dashboard');
return res.redirect('/client/dashboard');
}
res.redirect('/login');
});
// Middleware global de reporte de errores (API-SIGMA-WEBCONTROL)
app.use((err, req, res, next) => {
console.error('[App Error]:', err);
reportarError({
mensaje: err.message || 'Error no controlado en Express',
detalle: err.stack || String(err),
categoria: 'sistema',
nivel: 'error',
codigo: err.status || 500,
metodo: req.method,
clase: req.path,
usuario: req.session?.user?.email || null,
cuenta: req.session?.user?.company_id ? String(req.session.user.company_id) : null
});
if (res.headersSent) {
return next(err);
}
res.status(err.status || 500).send('Error interno del servidor. El incidente ha sido registrado en la bitácora.');
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Servidor corriendo en puerto ${PORT}`);
});
+14
View File
@@ -0,0 +1,14 @@
const mysql = require('mysql2');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
module.exports = pool.promise();
+160
View File
@@ -0,0 +1,160 @@
const https = require('https');
const http = require('http');
const ENDPOINT = process.env.ERROR_REPORTING_URL || 'https://api.siax-system.net/api/errores';
const API_KEY = process.env.WEBCONTROL_API_KEY || '';
const APP_ORIGEN = 'Help Desk';
const VERSION = '1.0.0';
// Deduplicación en memoria: evitar inundar el endpoint con el mismo error repetido
const cacheErrores = new Map();
const THROTTLE_MS = 10 * 60 * 1000; // 10 minutos
/**
* Reporta un error de runtime hacia API-SIGMA-WEBCONTROL (POST https://api.siax-system.net/api/errores)
*/
async function reportarError({
mensaje,
detalle = '',
categoria = 'sistema', // red, bd, auth, validacion, sistema, desconocido
nivel = 'error',
codigo = null,
clase = null,
metodo = null,
usuario = null,
cuenta = null
}) {
try {
if (!mensaje) return;
// Control de throttle por firma
const firma = `${categoria}:${mensaje}:${codigo || ''}`;
const ahora = Date.now();
const ultimoEnvio = cacheErrores.get(firma);
if (ultimoEnvio && (ahora - ultimoEnvio) < THROTTLE_MS) {
console.log(`[errorReporter] Error en ventana de throttle (${firma}), ignorando duplicado.`);
return;
}
cacheErrores.set(firma, ahora);
if (!API_KEY) {
console.warn(`[errorReporter] WEBCONTROL_API_KEY no configurada. Error no enviado a ${ENDPOINT}: ${mensaje}`);
return;
}
const payload = JSON.stringify({
app_origen: APP_ORIGEN,
mensaje: String(mensaje).substring(0, 500),
detalle: typeof detalle === 'object' ? JSON.stringify(detalle, null, 2) : String(detalle || ''),
categoria,
nivel,
codigo: codigo ? String(codigo) : null,
clase,
metodo,
usuario,
cuenta,
entorno: process.env.NODE_ENV || 'development',
version: VERSION,
fecha_evento: new Date().toISOString()
});
const url = new URL(ENDPOINT);
const client = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'x-api-key': API_KEY
},
timeout: 5000
};
const req = client.request(options, (res) => {
let body = '';
res.on('data', (d) => body += d);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log(`[errorReporter] Error reportado exitosamente (${res.statusCode}): ${mensaje}`);
} else {
console.warn(`[errorReporter] Fallo al reportar a ${ENDPOINT}: HTTP ${res.statusCode} - ${body}`);
}
});
});
req.on('error', (e) => {
console.warn(`[errorReporter] Error de conexión al reportar a la bitácora:`, e.message);
});
req.on('timeout', () => {
req.destroy();
console.warn(`[errorReporter] Timeout al conectar con ${ENDPOINT}`);
});
req.write(payload);
req.end();
} catch (err) {
console.error(`[errorReporter] Excepción en reporter:`, err.message);
}
}
/**
* Consulta errores reportados por esta app en API-SIGMA-WEBCONTROL (GET https://api.siax-system.net/api/errores)
*/
async function consultarErrores({ page = 1, limit = 20, categoria = null } = {}) {
return new Promise((resolve, reject) => {
if (!API_KEY) {
return reject(new Error('WEBCONTROL_API_KEY no configurada en las variables de entorno.'));
}
const url = new URL(ENDPOINT);
url.searchParams.set('app_origen', APP_ORIGEN);
url.searchParams.set('page', String(page));
url.searchParams.set('limit', String(limit));
if (categoria) url.searchParams.set('categoria', categoria);
const client = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
headers: {
'Accept': 'application/json',
'x-api-key': API_KEY
},
timeout: 10000
};
const req = client.request(options, (res) => {
let data = '';
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve({ statusCode: res.statusCode, data: json });
} catch (e) {
resolve({ statusCode: res.statusCode, raw: data });
}
});
});
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Timeout al consultar API de errores'));
});
req.end();
});
}
module.exports = {
reportarError,
consultarErrores,
APP_ORIGEN
};
+54
View File
@@ -0,0 +1,54 @@
CREATE DATABASE IF NOT EXISTS help_desk;
USE help_desk;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
email VARCHAR(100) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
rol ENUM('admin', 'agent', 'client') DEFAULT 'client',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS departments (
id INT AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
descripcion TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tickets (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
department_id INT,
asunto VARCHAR(200) NOT NULL,
mensaje TEXT NOT NULL,
status ENUM('abierto', 'en_progreso', 'cerrado') DEFAULT 'abierto',
prioridad ENUM('baja', 'media', 'alta', 'critica') DEFAULT 'media',
assigned_to INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (department_id) REFERENCES departments(id) ON DELETE SET NULL,
FOREIGN KEY (assigned_to) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS ticket_respuestas (
id INT AUTO_INCREMENT PRIMARY KEY,
ticket_id INT NOT NULL,
user_id INT NOT NULL,
mensaje TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (ticket_id) REFERENCES tickets(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO departments (nombre, descripcion) VALUES
('Soporte Técnico', 'Problemas técnicos y de software'),
('Facturación', 'Problemas de facturación y pagos'),
('Ventas', 'Consultas sobre ventas y productos'),
('General', 'Consultas generales');
INSERT INTO users (nombre, email, password, rol) VALUES
('Admin', 'admin@helpdesk.com', '$2b$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36PQm4sEPhMNPfFhpYN76eO', 'admin');
-- Password: 123456
+146
View File
@@ -0,0 +1,146 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function setup() {
const conn = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
multipleStatements: true
});
await conn.query(`CREATE DATABASE IF NOT EXISTS \`${process.env.DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
await conn.query(`USE \`${process.env.DB_NAME}\``);
// Create companies table
await conn.query(`
CREATE TABLE IF NOT EXISTS companies (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(200) NOT NULL,
slug VARCHAR(100) NOT NULL UNIQUE,
plan ENUM('free', 'pro', 'enterprise') DEFAULT 'free',
max_users INT DEFAULT 5,
max_tickets INT DEFAULT 100,
is_active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
)
`);
// Ensure demo company exists FIRST (before migrations reference it)
const [existing] = await conn.query('SELECT id FROM companies WHERE slug = ?', ['demo']);
let companyId;
if (existing.length === 0) {
await conn.query(
'INSERT INTO companies (name, slug, plan, max_users, max_tickets) VALUES (?, ?, ?, ?, ?)',
['Demo Company', 'demo', 'enterprise', 100, 10000]
);
companyId = 1;
console.log('+ Empresa demo creada');
} else {
companyId = existing[0].id;
console.log('~ Empresa demo ya existe (id=' + companyId + ')');
}
// Ensure company_id default value references existing company
const defCompanyId = companyId;
// Migrate existing tables
const migrateColumn = async (table, column, definition) => {
try {
await conn.query(`ALTER TABLE \`${table}\` ADD COLUMN ${definition}`);
console.log(` + columna ${column} agregada a ${table}`);
} catch (e) {
if (e.errno === 1060) console.log(` ~ ${table}.${column} ya existe`);
else throw e;
}
};
console.log('Migrando tablas...');
await migrateColumn('users', 'company_id', `company_id INT NOT NULL DEFAULT ${defCompanyId} AFTER id`);
await migrateColumn('users', 'is_active', 'is_active TINYINT(1) DEFAULT 1');
await migrateColumn('users', 'invite_token', 'invite_token VARCHAR(100) NULL');
await migrateColumn('users', 'invite_token_expires', 'invite_token_expires TIMESTAMP NULL');
await migrateColumn('users', 'invited_by', 'invited_by INT NULL');
// Alter users.rol to include superadmin
try {
await conn.query("ALTER TABLE users MODIFY COLUMN rol ENUM('superadmin','admin','agent','client') DEFAULT 'client'");
console.log(' + users.rol actualizado a superadmin');
} catch (e) {
if (e.errno === 1060 || e.errno === 1062 || e.errno === 1265) console.log(' ~ users.rol ya actualizado');
else throw e;
}
// Add FKs for users
for (const fk of [
'ALTER TABLE users ADD FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE',
'ALTER TABLE users ADD FOREIGN KEY (invited_by) REFERENCES users(id) ON DELETE SET NULL'
]) {
try { await conn.query(fk); } catch (e) { if (e.errno !== 1826 && e.errno !== 1005) { throw e; } }
}
await migrateColumn('departments', 'company_id', `company_id INT NOT NULL DEFAULT ${defCompanyId} AFTER id`);
try {
await conn.query('ALTER TABLE departments ADD FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE');
} catch (e) { if (e.errno !== 1826 && e.errno !== 1005) throw e; }
await migrateColumn('tickets', 'company_id', `company_id INT NOT NULL DEFAULT ${defCompanyId} AFTER id`);
try {
await conn.query('ALTER TABLE tickets ADD FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE');
} catch (e) { if (e.errno !== 1826 && e.errno !== 1005) throw e; }
// Seed departments
const [depts] = await conn.query('SELECT COUNT(*) as c FROM departments WHERE company_id = ?', [companyId]);
if (depts[0].c === 0) {
await conn.query(
'INSERT INTO departments (company_id, nombre, descripcion) VALUES ?',
[[
[companyId, 'Soporte Técnico', 'Problemas técnicos y de software'],
[companyId, 'Facturación', 'Problemas de facturación y pagos'],
[companyId, 'Ventas', 'Consultas sobre ventas y productos'],
[companyId, 'General', 'Consultas generales']
]]
);
console.log('+ Departamentos seed insertados');
} else {
console.log('~ Departamentos ya existen');
}
// Seed users
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash('123456', 10);
const users = [
['super@helpdesk.com', 'Super Admin', 'superadmin'],
['admin@helpdesk.com', 'Admin', 'admin'],
['agent@helpdesk.com', 'Agente', 'agent'],
['client@helpdesk.com', 'Cliente', 'client']
];
for (const [email, nombre, rol] of users) {
const [u] = await conn.query('SELECT id FROM users WHERE email = ?', [email]);
if (u.length === 0) {
await conn.query(
'INSERT INTO users (company_id, nombre, email, password, rol) VALUES (?, ?, ?, ?, ?)',
[companyId, nombre, email, hash, rol]
);
console.log(`+ Usuario ${email} creado`);
} else {
await conn.query('UPDATE users SET company_id = ?, rol = ? WHERE email = ? AND rol != ?', [companyId, rol, email, 'superadmin']);
console.log(`~ Usuario ${email} actualizado`);
}
}
// Ensure company_id is set for all orphan records
await conn.query('UPDATE users SET company_id = ? WHERE company_id IS NULL OR company_id = 0', [companyId]);
await conn.query('UPDATE departments SET company_id = ? WHERE company_id IS NULL OR company_id = 0', [companyId]);
await conn.query('UPDATE tickets SET company_id = ? WHERE company_id IS NULL OR company_id = 0', [companyId]);
console.log('\nBase de datos configurada correctamente');
await conn.end();
process.exit(0);
}
setup().catch(err => {
console.error('Error:', err.message);
process.exit(1);
});
+6
View File
@@ -0,0 +1,6 @@
# Identidad del agente
- Proyecto: Help Desk
- Nombre asignado en sigma-robot: pendiente
- ID de agente en sigma-robot: pendiente
- Estado: pendiente
+41
View File
@@ -0,0 +1,41 @@
# Memoria del Proyecto - Help Desk
## Fecha de inicio
16 de Mayo, 2026
## Stack tecnológico
- Node.js con Express 5
- EJS como motor de plantillas
- MariaDB/MySQL con mysql2
- express-session + bcryptjs para autenticación
- Bootstrap 5 + Font Awesome para UI
- morgan, connect-flash, express-fileupload
## Decisiones de arquitectura
1. **MySQL vs MongoDB**: El usuario eligió MySQL (MariaDB) como base de datos, consistente con su stack habitual en otros proyectos Node.
2. **express-session vs JWT**: Se optó por sesiones del lado del servidor con express-session en lugar de JWT para mantener la compatibilidad con connect-flash y facilitar mensajes flash. Aunque se instaló jsonwebtoken, no se está utilizando activamente.
3. **3 roles de usuario**: admin, agent, client. Cada uno con acceso a rutas específicas protegidas por middleware de autenticación.
4. **EJS con partials**: Se usan partials (header.ejs, footer.ejs, navbar.ejs, sidebar.ejs) en lugar de layouts para mantener la estructura simple sin dependencias extra.
5. **Bootstrap 5 desde CDN**: No se incluye Bootstrap local para mantener el proyecto liviano. Se carga desde CDN junto con Font Awesome.
## Base de Datos
- Servidor MariaDB en `192.168.10.149`
- Usuario: `helpdesk`@`192.168.10.101`
- Base de datos: `help_desk`
- 4 tablas: users, departments, tickets, ticket_respuestas
## Puerto del servidor
- El servidor corre en el puerto 3008
## Pendientes / Notas
- La contraseña del usuario helpdesk se cambió de `password` a `5oyN9zvq/2K8b1Fs`
- jsonwebtoken está instalado como dependencia pero no se usa activamente
- El frontend es funcional pero se puede mejorar
## Problemas conocidos
- node --watch no funciona correctamente en algunos entornos; usar nodemon como alternativa
+60
View File
@@ -0,0 +1,60 @@
const Company = require('../models/Company');
module.exports = {
isAuthenticated: (req, res, next) => {
if (req.session.user) return next();
req.flash('error_msg', 'Por favor inicia sesión');
res.redirect('/login');
},
isSuperAdmin: (req, res, next) => {
if (req.session.user && req.session.user.rol === 'superadmin') return next();
req.flash('error_msg', 'Acceso denegado');
res.redirect('/');
},
isCompanyAdmin: (req, res, next) => {
if (req.session.user && ['superadmin', 'admin'].includes(req.session.user.rol)) return next();
req.flash('error_msg', 'Acceso denegado');
res.redirect('/');
},
isAgent: (req, res, next) => {
if (req.session.user && ['superadmin', 'admin', 'agent'].includes(req.session.user.rol)) return next();
req.flash('error_msg', 'Acceso denegado');
res.redirect('/');
},
loadCompany: async (req, res, next) => {
if (req.session.user && req.session.user.company_id) {
const company = await Company.findById(req.session.user.company_id);
res.locals.company = company;
}
next();
},
checkPlan: (feature) => {
return async (req, res, next) => {
if (!res.locals.company) return next();
const company = res.locals.company;
if (!company.is_active) {
req.flash('error_msg', 'Tu cuenta está desactivada');
return res.redirect('/logout');
}
if (feature === 'users') {
const User = require('../models/User');
const count = await User.countByCompany(company.id);
if (count >= company.max_users) {
req.flash('error_msg', `Límite de usuarios alcanzado (${company.max_users})`);
return res.redirect('/');
}
}
if (feature === 'tickets') {
const Ticket = require('../models/Ticket');
const [rows] = await require('../config/db').query(
'SELECT COUNT(*) as total FROM tickets WHERE company_id = ?', [company.id]
);
if (rows[0].total >= company.max_tickets) {
req.flash('error_msg', `Límite de tickets alcanzado (${company.max_tickets})`);
return res.redirect('/');
}
}
next();
};
}
};
+44
View File
@@ -0,0 +1,44 @@
const db = require('../config/db');
const Company = {
findAll: async () => {
const [rows] = await db.query('SELECT * FROM companies ORDER BY created_at DESC');
return rows;
},
findById: async (id) => {
const [rows] = await db.query('SELECT * FROM companies WHERE id = ?', [id]);
return rows[0];
},
findBySlug: async (slug) => {
const [rows] = await db.query('SELECT * FROM companies WHERE slug = ?', [slug]);
return rows[0];
},
create: async (name, slug, plan = 'free') => {
const [result] = await db.query(
'INSERT INTO companies (name, slug, plan) VALUES (?, ?, ?)',
[name, slug, plan]
);
return result.insertId;
},
update: async (id, data) => {
const fields = [];
const values = [];
for (const [key, value] of Object.entries(data)) {
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return;
values.push(id);
await db.query(`UPDATE companies SET ${fields.join(', ')} WHERE id = ?`, values);
},
countUsers: async (companyId) => {
const [rows] = await db.query('SELECT COUNT(*) as total FROM users WHERE company_id = ? AND is_active = 1', [companyId]);
return rows[0].total;
},
countTickets: async (companyId) => {
const [rows] = await db.query('SELECT COUNT(*) as total FROM tickets WHERE company_id = ?', [companyId]);
return rows[0].total;
}
};
module.exports = Company;
+27
View File
@@ -0,0 +1,27 @@
const db = require('../config/db');
const Department = {
findAll: async (companyId) => {
const [rows] = await db.query('SELECT * FROM departments WHERE company_id = ? ORDER BY nombre', [companyId]);
return rows;
},
findById: async (id) => {
const [rows] = await db.query('SELECT * FROM departments WHERE id = ?', [id]);
return rows[0];
},
create: async (companyId, nombre, descripcion) => {
const [result] = await db.query(
'INSERT INTO departments (company_id, nombre, descripcion) VALUES (?, ?, ?)',
[companyId, nombre, descripcion]
);
return result.insertId;
},
update: async (id, nombre, descripcion) => {
await db.query('UPDATE departments SET nombre = ?, descripcion = ? WHERE id = ?', [nombre, descripcion, id]);
},
delete: async (id) => {
await db.query('DELETE FROM departments WHERE id = ?', [id]);
}
};
module.exports = Department;
+95
View File
@@ -0,0 +1,95 @@
const db = require('../config/db');
const Ticket = {
findAll: async (companyId) => {
const [rows] = await db.query(`
SELECT t.*, u.nombre as usuario_nombre, d.nombre as departamento_nombre,
a.nombre as asignado_nombre
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN departments d ON t.department_id = d.id
LEFT JOIN users a ON t.assigned_to = a.id
WHERE t.company_id = ?
ORDER BY t.created_at DESC
`, [companyId]);
return rows;
},
findByUser: async (companyId, userId) => {
const [rows] = await db.query(`
SELECT t.*, d.nombre as departamento_nombre, a.nombre as asignado_nombre
FROM tickets t
LEFT JOIN departments d ON t.department_id = d.id
LEFT JOIN users a ON t.assigned_to = a.id
WHERE t.company_id = ? AND t.user_id = ?
ORDER BY t.created_at DESC
`, [companyId, userId]);
return rows;
},
findByAgent: async (companyId, agentId) => {
const [rows] = await db.query(`
SELECT t.*, u.nombre as usuario_nombre, d.nombre as departamento_nombre
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN departments d ON t.department_id = d.id
WHERE t.company_id = ? AND t.assigned_to = ?
ORDER BY t.created_at DESC
`, [companyId, agentId]);
return rows;
},
findById: async (id) => {
const [rows] = await db.query(`
SELECT t.*, u.nombre as usuario_nombre, u.email as usuario_email,
d.nombre as departamento_nombre, a.nombre as asignado_nombre
FROM tickets t
LEFT JOIN users u ON t.user_id = u.id
LEFT JOIN departments d ON t.department_id = d.id
LEFT JOIN users a ON t.assigned_to = a.id
WHERE t.id = ?
`, [id]);
return rows[0];
},
create: async (companyId, userId, departmentId, asunto, mensaje, prioridad = 'media') => {
const [result] = await db.query(
'INSERT INTO tickets (company_id, user_id, department_id, asunto, mensaje, prioridad) VALUES (?, ?, ?, ?, ?, ?)',
[companyId, userId, departmentId, asunto, mensaje, prioridad]
);
return result.insertId;
},
update: async (id, data) => {
const fields = [];
const values = [];
for (const [key, value] of Object.entries(data)) {
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return;
values.push(id);
await db.query(`UPDATE tickets SET ${fields.join(', ')} WHERE id = ?`, values);
},
getResponses: async (ticketId) => {
const [rows] = await db.query(`
SELECT r.*, u.nombre as usuario_nombre, u.rol as usuario_rol
FROM ticket_respuestas r
LEFT JOIN users u ON r.user_id = u.id
WHERE r.ticket_id = ?
ORDER BY r.created_at ASC
`, [ticketId]);
return rows;
},
addResponse: async (ticketId, userId, mensaje) => {
const [result] = await db.query(
'INSERT INTO ticket_respuestas (ticket_id, user_id, mensaje) VALUES (?, ?, ?)',
[ticketId, userId, mensaje]
);
return result.insertId;
},
countByStatus: async (companyId) => {
const [rows] = await db.query(
'SELECT status, COUNT(*) as total FROM tickets WHERE company_id = ? GROUP BY status',
[companyId]
);
return rows;
}
};
module.exports = Ticket;
+72
View File
@@ -0,0 +1,72 @@
const db = require('../config/db');
const crypto = require('crypto');
const User = {
findAll: async (companyId) => {
const [rows] = await db.query(
'SELECT id, company_id, nombre, email, rol, is_active, created_at FROM users WHERE company_id = ? ORDER BY created_at DESC',
[companyId]
);
return rows;
},
findById: async (id) => {
const [rows] = await db.query(
'SELECT u.*, c.name as company_name, c.plan as company_plan, c.max_users, c.max_tickets FROM users u LEFT JOIN companies c ON u.company_id = c.id WHERE u.id = ?',
[id]
);
return rows[0];
},
findByEmail: async (email) => {
const [rows] = await db.query(
'SELECT u.*, c.name as company_name, c.plan as company_plan, c.max_users, c.max_tickets FROM users u LEFT JOIN companies c ON u.company_id = c.id WHERE u.email = ?',
[email]
);
return rows[0];
},
create: async (companyId, nombre, email, password, rol = 'client') => {
const [result] = await db.query(
'INSERT INTO users (company_id, nombre, email, password, rol) VALUES (?, ?, ?, ?, ?)',
[companyId, nombre, email, password, rol]
);
return result.insertId;
},
update: async (id, data) => {
const fields = [];
const values = [];
for (const [key, value] of Object.entries(data)) {
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return;
values.push(id);
await db.query(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values);
},
delete: async (id) => {
await db.query('DELETE FROM users WHERE id = ?', [id]);
},
findByInviteToken: async (token) => {
const [rows] = await db.query(
'SELECT * FROM users WHERE invite_token = ? AND invite_token_expires > NOW()',
[token]
);
return rows[0];
},
generateInviteToken: async (userId) => {
const token = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
await db.query(
'UPDATE users SET invite_token = ?, invite_token_expires = ? WHERE id = ?',
[token, expires, userId]
);
return token;
},
countByCompany: async (companyId) => {
const [rows] = await db.query(
'SELECT COUNT(*) as total FROM users WHERE company_id = ? AND is_active = 1',
[companyId]
);
return rows[0].total;
}
};
module.exports = User;
+1331
View File
File diff suppressed because it is too large Load Diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "help_desk",
"version": "1.0.0",
"description": "Sistema Help Desk en Node.js",
"main": "app.js",
"scripts": {
"start": "node app.js",
"dev": "node --watch app.js",
"check:errors": "node scripts/consultar_errores.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"bcryptjs": "^3.0.3",
"connect-flash": "^0.1.1",
"cookie-parser": "^1.4.7",
"dotenv": "^17.4.2",
"ejs": "^5.0.2",
"express": "^5.2.1",
"express-fileupload": "^1.5.2",
"express-session": "^1.19.0",
"jsonwebtoken": "^9.0.3",
"morgan": "^1.10.1",
"mysql2": "^3.22.3"
}
}
+125
View File
@@ -0,0 +1,125 @@
# Help Desk - Documentación del Proyecto
## Estructura del Proyecto
```
help_desk/
├── app.js # Entry point, configura Express
├── package.json # Dependencias y scripts
├── .env # Variables de entorno
├── .gitignore
├── config/
│ ├── db.js # Conexión a MySQL/MariaDB con mysql2
│ ├── init.sql # Script SQL inicial
│ └── setup.js # Script Node para inicializar BD
├── middleware/
│ └── auth.js # Middleware: isAuthenticated, isAdmin, isAgent, isClient
├── models/
│ ├── User.js # CRUD usuarios
│ ├── Ticket.js # CRUD tickets + respuestas
│ └── Department.js # CRUD departamentos
├── routes/
│ ├── auth.js # Login, Register, Logout
│ ├── client.js # Rutas para clientes (ver/crear tickets)
│ ├── agent.js # Rutas para agentes (gestionar tickets)
│ └── admin.js # Rutas para admin (usuarios, departamentos)
├── views/
│ ├── partials/
│ │ ├── header.ejs # Head HTML + navbar
│ │ ├── footer.ejs # Scripts + cierre
│ │ ├── navbar.ejs # Barra de navegación superior
│ │ └── sidebar.ejs # Menú lateral por rol
│ ├── auth/
│ │ ├── login.ejs
│ │ └── register.ejs
│ ├── client/
│ │ ├── dashboard.ejs # Lista de tickets del cliente
│ │ ├── new-ticket.ejs # Formulario crear ticket
│ │ └── view-ticket.ejs # Ver ticket + respuestas
│ ├── agent/
│ │ ├── dashboard.ejs # Panel con todos los tickets
│ │ └── view-ticket.ejs # Ver ticket + responder + cambiar estado
│ └── admin/
│ ├── dashboard.ejs # Estadísticas y accesos rápidos
│ ├── users.ejs # CRUD usuarios
│ ├── create-user.ejs # Formulario crear usuario
│ └── departments.ejs # CRUD departamentos con modales
├── public/
│ ├── css/
│ │ └── style.css # Estilos personalizados
│ └── js/
│ └── main.js # JS del lado cliente
└── proyecto.md, memoria.md, readme.md
```
## Base de Datos (MariaDB/MySQL)
### Tabla: `users`
| Campo | Tipo | Descripción |
|------------|-----------------------------|---------------------------|
| id | INT AUTO_INCREMENT PK | ID único |
| nombre | VARCHAR(100) NOT NULL | Nombre completo |
| email | VARCHAR(100) NOT NULL UNIQUE| Correo electrónico |
| password | VARCHAR(255) NOT NULL | Hash bcrypt |
| rol | ENUM('admin','agent','client') DEFAULT 'client' | Rol del usuario |
| created_at | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Fecha de registro |
### Tabla: `departments`
| Campo | Tipo | Descripción |
|------------|-----------------------------|---------------------------|
| id | INT AUTO_INCREMENT PK | ID único |
| nombre | VARCHAR(100) NOT NULL | Nombre del departamento |
| descripcion| TEXT | Descripción |
| created_at | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Fecha de creación |
### Tabla: `tickets`
| Campo | Tipo | Descripción |
|-------------|-----------------------------|------------------------------|
| id | INT AUTO_INCREMENT PK | ID único (# ticket) |
| user_id | INT NOT NULL FK → users.id | Cliente creador |
| department_id| INT FK → departments.id | Departamento |
| asunto | VARCHAR(200) NOT NULL | Asunto del ticket |
| mensaje | TEXT NOT NULL | Descripción del problema |
| status | ENUM('abierto','en_progreso','cerrado') DEFAULT 'abierto' | Estado |
| prioridad | ENUM('baja','media','alta','critica') DEFAULT 'media' | Prioridad |
| assigned_to | INT FK → users.id | Agente asignado |
| created_at | TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Fecha de creación |
| updated_at | TIMESTAMP ON UPDATE | Última actualización |
### Tabla: `ticket_respuestas`
| Campo | Tipo | Descripción |
|-----------|-----------------------------|------------------------|
| id | INT AUTO_INCREMENT PK | ID único |
| ticket_id | INT NOT NULL FK → tickets.id| Ticket relacionado |
| user_id | INT NOT NULL FK → users.id | Quién respondió |
| mensaje | TEXT NOT NULL | Contenido respuesta |
| created_at| TIMESTAMP DEFAULT CURRENT_TIMESTAMP | Fecha de respuesta |
## Roles y Permisos
| Rol | Descripción |
|--------|------------------------------------------|
| admin | Acceso total: gestiona usuarios, deptos, tickets |
| agent | Gestiona tickets asignados, responde, cambia estado |
| client | Crea tickets, ve sus propios tickets, responde |
## Tecnologías
- **Backend**: Node.js + Express 5
- **Motor de plantillas**: EJS
- **Base de Datos**: MariaDB/MySQL (mysql2)
- **Autenticación**: express-session + bcryptjs
- **Frontend**: Bootstrap 5 + Font Awesome
- **Middleware**: morgan, connect-flash, express-fileupload
## Scripts
```bash
npm start # Iniciar servidor producción
npm run dev # Iniciar con nodemon (auto-reload)
node config/setup.js # Inicializar BD (crear tablas + datos seed)
```
## Credenciales por defecto
- **Admin**: admin@helpdesk.com / 123456
+56
View File
@@ -0,0 +1,56 @@
body {
font-size: .875rem;
background-color: #f8f9fa;
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100;
padding: 56px 0 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
}
.sidebar .nav-link {
font-weight: 500;
color: #333;
padding: .5rem 1rem;
}
.sidebar .nav-link:hover {
background-color: #e9ecef;
}
.sidebar .nav-link i {
margin-right: 8px;
width: 20px;
}
main {
padding-top: 20px;
}
.card {
box-shadow: 0 1px 3px rgba(0,0,0,.1);
}
.table th {
border-top: none;
}
.badge {
font-size: 85%;
}
.navbar-brand i {
margin-right: 8px;
}
@media (max-width: 768px) {
.sidebar {
position: static;
padding-top: 0;
}
}
+8
View File
@@ -0,0 +1,8 @@
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() {
document.querySelectorAll('.alert').forEach(function(alert) {
var bsAlert = new bootstrap.Alert(alert);
setTimeout(function() { bsAlert.close(); }, 5000);
});
}, 100);
});
+153
View File
@@ -0,0 +1,153 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { isCompanyAdmin } = require('../middleware/auth');
const User = require('../models/User');
const Department = require('../models/Department');
const Ticket = require('../models/Ticket');
router.get('/dashboard', isCompanyAdmin, async (req, res) => {
try {
const users = await User.findAll(req.session.user.company_id);
const tickets = await Ticket.findAll(req.session.user.company_id);
const departments = await Department.findAll(req.session.user.company_id);
const stats = await Ticket.countByStatus(req.session.user.company_id);
res.render('admin/dashboard', { users, tickets, departments, stats });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar dashboard');
res.redirect('/');
}
});
router.get('/users', isCompanyAdmin, async (req, res) => {
try {
const users = await User.findAll(req.session.user.company_id);
res.render('admin/users', { users });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar usuarios');
res.redirect('/admin/dashboard');
}
});
router.get('/users/create', isCompanyAdmin, (req, res) => {
res.render('admin/create-user');
});
router.post('/users/create', isCompanyAdmin, async (req, res) => {
const { nombre, email, password, rol } = req.body;
if (!nombre || !email || !password) {
req.flash('error_msg', 'Todos los campos son obligatorios');
return res.redirect('/admin/users/create');
}
try {
const existing = await User.findByEmail(email);
if (existing) {
req.flash('error_msg', 'El email ya existe');
return res.redirect('/admin/users/create');
}
const hashedPassword = await bcrypt.hash(password, 10);
const userId = await User.create(req.session.user.company_id, nombre, email, hashedPassword, rol || 'client');
req.flash('success_msg', 'Usuario creado exitosamente');
res.redirect('/admin/users');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al crear usuario');
res.redirect('/admin/users/create');
}
});
router.post('/users/invite/:id', isCompanyAdmin, async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user || user.company_id !== req.session.user.company_id) {
req.flash('error_msg', 'Usuario no encontrado');
return res.redirect('/admin/users');
}
const token = await User.generateInviteToken(user.id);
const inviteLink = `${req.protocol}://${req.get('host')}/invite/${token}`;
req.flash('success_msg', `Link de invitación: ${inviteLink}`);
res.redirect('/admin/users');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al generar invitación');
res.redirect('/admin/users');
}
});
router.post('/users/delete/:id', isCompanyAdmin, async (req, res) => {
try {
const user = await User.findById(req.params.id);
if (!user || user.company_id !== req.session.user.company_id) {
req.flash('error_msg', 'Usuario no encontrado');
return res.redirect('/admin/users');
}
if (user.rol === 'superadmin') {
req.flash('error_msg', 'No puedes eliminar al superadmin');
return res.redirect('/admin/users');
}
await User.delete(req.params.id);
req.flash('success_msg', 'Usuario eliminado');
res.redirect('/admin/users');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al eliminar usuario');
res.redirect('/admin/users');
}
});
router.get('/departments', isCompanyAdmin, async (req, res) => {
try {
const departments = await Department.findAll(req.session.user.company_id);
res.render('admin/departments', { departments });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar departamentos');
res.redirect('/admin/dashboard');
}
});
router.post('/departments/create', isCompanyAdmin, async (req, res) => {
const { nombre, descripcion } = req.body;
if (!nombre) {
req.flash('error_msg', 'El nombre es obligatorio');
return res.redirect('/admin/departments');
}
try {
await Department.create(req.session.user.company_id, nombre, descripcion || '');
req.flash('success_msg', 'Departamento creado');
res.redirect('/admin/departments');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al crear departamento');
res.redirect('/admin/departments');
}
});
router.post('/departments/update/:id', isCompanyAdmin, async (req, res) => {
const { nombre, descripcion } = req.body;
try {
await Department.update(req.params.id, nombre, descripcion || '');
req.flash('success_msg', 'Departamento actualizado');
res.redirect('/admin/departments');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al actualizar departamento');
res.redirect('/admin/departments');
}
});
router.post('/departments/delete/:id', isCompanyAdmin, async (req, res) => {
try {
await Department.delete(req.params.id);
req.flash('success_msg', 'Departamento eliminado');
res.redirect('/admin/departments');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al eliminar departamento');
res.redirect('/admin/departments');
}
});
module.exports = router;
+106
View File
@@ -0,0 +1,106 @@
const express = require('express');
const router = express.Router();
const { isAgent } = require('../middleware/auth');
const Ticket = require('../models/Ticket');
const Department = require('../models/Department');
router.get('/dashboard', isAgent, async (req, res) => {
try {
let tickets;
if (['superadmin', 'admin'].includes(req.session.user.rol)) {
tickets = await Ticket.findAll(req.session.user.company_id);
} else {
tickets = await Ticket.findByAgent(req.session.user.company_id, req.session.user.id);
}
const stats = await Ticket.countByStatus(req.session.user.company_id);
const departments = await Department.findAll(req.session.user.company_id);
res.render('agent/dashboard', { tickets, stats, departments });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar dashboard');
res.redirect('/');
}
});
router.get('/ticket/:id', isAgent, async (req, res) => {
try {
const ticket = await Ticket.findById(req.params.id);
if (!ticket) {
req.flash('error_msg', 'Ticket no encontrado');
return res.redirect('/agent/dashboard');
}
if (ticket.company_id !== req.session.user.company_id && req.session.user.rol !== 'superadmin') {
req.flash('error_msg', 'Acceso denegado');
return res.redirect('/agent/dashboard');
}
const respuestas = await Ticket.getResponses(req.params.id);
let agents = [];
if (['superadmin', 'admin'].includes(req.session.user.rol)) {
const User = require('../models/User');
const allUsers = await User.findAll(req.session.user.company_id);
agents = allUsers.filter(u => u.rol === 'agent');
}
res.render('agent/view-ticket', { ticket, respuestas, agents });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar ticket');
res.redirect('/agent/dashboard');
}
});
router.post('/ticket/:id/respond', isAgent, async (req, res) => {
const { mensaje } = req.body;
if (!mensaje) {
req.flash('error_msg', 'El mensaje es obligatorio');
return res.redirect(`/agent/ticket/${req.params.id}`);
}
try {
await Ticket.addResponse(req.params.id, req.session.user.id, mensaje);
req.flash('success_msg', 'Respuesta enviada');
res.redirect(`/agent/ticket/${req.params.id}`);
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al enviar respuesta');
res.redirect(`/agent/ticket/${req.params.id}`);
}
});
router.post('/ticket/:id/status', isAgent, async (req, res) => {
const { status, assigned_to } = req.body;
try {
const data = {};
if (status) data.status = status;
if (assigned_to && ['superadmin', 'admin'].includes(req.session.user.rol)) {
data.assigned_to = assigned_to;
}
await Ticket.update(req.params.id, data);
req.flash('success_msg', 'Ticket actualizado');
res.redirect(`/agent/ticket/${req.params.id}`);
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al actualizar ticket');
res.redirect(`/agent/ticket/${req.params.id}`);
}
});
router.post('/ticket/:id/assign', isAgent, async (req, res) => {
if (!['superadmin', 'admin'].includes(req.session.user.rol)) {
req.flash('error_msg', 'Acceso denegado');
return res.redirect('/agent/dashboard');
}
const { assigned_to } = req.body;
try {
await Ticket.update(req.params.id, { assigned_to: assigned_to || null });
if (assigned_to) {
await Ticket.update(req.params.id, { status: 'en_progreso' });
}
req.flash('success_msg', 'Ticket asignado');
res.redirect(`/agent/ticket/${req.params.id}`);
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al asignar ticket');
res.redirect(`/agent/ticket/${req.params.id}`);
}
});
module.exports = router;
+157
View File
@@ -0,0 +1,157 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const User = require('../models/User');
const Company = require('../models/Company');
const Department = require('../models/Department');
router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('auth/login');
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
try {
const user = await User.findByEmail(email);
if (!user) {
req.flash('error_msg', 'Email o contraseña incorrectos');
return res.redirect('/login');
}
if (!user.is_active) {
req.flash('error_msg', 'Cuenta desactivada');
return res.redirect('/login');
}
const company = await Company.findById(user.company_id);
if (company && !company.is_active) {
req.flash('error_msg', 'Tu empresa está desactivada');
return res.redirect('/login');
}
const match = await bcrypt.compare(password, user.password);
if (!match) {
req.flash('error_msg', 'Email o contraseña incorrectos');
return res.redirect('/login');
}
req.session.user = {
id: user.id,
company_id: user.company_id,
nombre: user.nombre,
email: user.email,
rol: user.rol
};
req.flash('success_msg', 'Inicio de sesión exitoso');
res.redirect('/');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al iniciar sesión');
res.redirect('/login');
}
});
router.get('/register', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('auth/register');
});
router.post('/register', async (req, res) => {
const { nombre, email, password, confirm_password, company_name } = req.body;
if (!nombre || !email || !password || !confirm_password) {
req.flash('error_msg', 'Todos los campos son obligatorios');
return res.redirect('/register');
}
if (password !== confirm_password) {
req.flash('error_msg', 'Las contraseñas no coinciden');
return res.redirect('/register');
}
if (password.length < 6) {
req.flash('error_msg', 'La contraseña debe tener al menos 6 caracteres');
return res.redirect('/register');
}
try {
const existing = await User.findByEmail(email);
if (existing) {
req.flash('error_msg', 'El email ya está registrado');
return res.redirect('/register');
}
// Create company or use default
let companyId = 1;
if (company_name) {
const slug = company_name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').slice(0, 100);
const slugExists = await Company.findBySlug(slug);
if (slugExists) {
req.flash('error_msg', 'El nombre de empresa ya está registrado');
return res.redirect('/register');
}
companyId = await Company.create(company_name, slug, 'free');
// Seed default departments for new company
await Department.create(companyId, 'Soporte Técnico', 'Problemas técnicos y de software');
await Department.create(companyId, 'Facturación', 'Problemas de facturación y pagos');
await Department.create(companyId, 'General', 'Consultas generales');
}
const hashedPassword = await bcrypt.hash(password, 10);
await User.create(companyId, nombre, email, hashedPassword, company_name ? 'admin' : 'client');
req.flash('success_msg', 'Registro exitoso, ahora puedes iniciar sesión');
res.redirect('/login');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al registrarse');
res.redirect('/register');
}
});
router.get('/invite/:token', async (req, res) => {
try {
const invite = await User.findByInviteToken(req.params.token);
if (!invite) {
req.flash('error_msg', 'Invitación inválida o expirada');
return res.redirect('/login');
}
res.render('auth/accept-invite', { token: req.params.token, email: invite.email });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al procesar invitación');
res.redirect('/login');
}
});
router.post('/invite/:token', async (req, res) => {
const { nombre, password, confirm_password } = req.body;
if (!nombre || !password || !confirm_password) {
req.flash('error_msg', 'Todos los campos son obligatorios');
return res.redirect(`/invite/${req.params.token}`);
}
if (password !== confirm_password) {
req.flash('error_msg', 'Las contraseñas no coinciden');
return res.redirect(`/invite/${req.params.token}`);
}
try {
const invite = await User.findByInviteToken(req.params.token);
if (!invite) {
req.flash('error_msg', 'Invitación inválida o expirada');
return res.redirect('/login');
}
const hashedPassword = await bcrypt.hash(password, 10);
await User.update(invite.id, {
nombre,
password: hashedPassword,
is_active: 1,
invite_token: null,
invite_token_expires: null
});
req.flash('success_msg', 'Cuenta activada, ahora puedes iniciar sesión');
res.redirect('/login');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al aceptar invitación');
res.redirect(`/invite/${req.params.token}`);
}
});
router.get('/logout', (req, res) => {
req.session.destroy();
res.redirect('/login');
});
module.exports = router;
+104
View File
@@ -0,0 +1,104 @@
const express = require('express');
const router = express.Router();
const { isAuthenticated } = require('../middleware/auth');
const Ticket = require('../models/Ticket');
const Department = require('../models/Department');
router.get('/dashboard', isAuthenticated, async (req, res) => {
try {
const tickets = await Ticket.findByUser(req.session.user.company_id, req.session.user.id);
res.render('client/dashboard', { tickets });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar tickets');
res.redirect('/');
}
});
router.get('/new-ticket', isAuthenticated, async (req, res) => {
try {
const departments = await Department.findAll(req.session.user.company_id);
res.render('client/new-ticket', { departments });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar formulario');
res.redirect('/client/dashboard');
}
});
router.post('/new-ticket', isAuthenticated, async (req, res) => {
const { department_id, asunto, mensaje, prioridad } = req.body;
if (!asunto || !mensaje) {
req.flash('error_msg', 'Asunto y mensaje son obligatorios');
return res.redirect('/client/new-ticket');
}
try {
await Ticket.create(req.session.user.company_id, req.session.user.id, department_id || null, asunto, mensaje, prioridad || 'media');
req.flash('success_msg', 'Ticket creado exitosamente');
res.redirect('/client/dashboard');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al crear ticket');
res.redirect('/client/new-ticket');
}
});
router.get('/ticket/:id', isAuthenticated, async (req, res) => {
try {
const ticket = await Ticket.findById(req.params.id);
if (!ticket) {
req.flash('error_msg', 'Ticket no encontrado');
return res.redirect('/client/dashboard');
}
if (ticket.company_id !== req.session.user.company_id) {
req.flash('error_msg', 'Acceso denegado');
return res.redirect('/client/dashboard');
}
if (ticket.user_id !== req.session.user.id && req.session.user.rol === 'client') {
req.flash('error_msg', 'Acceso denegado');
return res.redirect('/client/dashboard');
}
const respuestas = await Ticket.getResponses(req.params.id);
res.render('client/view-ticket', { ticket, respuestas });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar ticket');
res.redirect('/client/dashboard');
}
});
router.post('/ticket/:id/respond', isAuthenticated, async (req, res) => {
const { mensaje } = req.body;
if (!mensaje) {
req.flash('error_msg', 'El mensaje es obligatorio');
return res.redirect(`/client/ticket/${req.params.id}`);
}
try {
await Ticket.addResponse(req.params.id, req.session.user.id, mensaje);
req.flash('success_msg', 'Respuesta enviada');
res.redirect(`/client/ticket/${req.params.id}`);
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al enviar respuesta');
res.redirect(`/client/ticket/${req.params.id}`);
}
});
router.post('/ticket/:id/close', isAuthenticated, async (req, res) => {
try {
const ticket = await Ticket.findById(req.params.id);
if (ticket && ticket.company_id !== req.session.user.company_id) {
req.flash('error_msg', 'Acceso denegado');
return res.redirect('/client/dashboard');
}
await Ticket.update(req.params.id, { status: 'cerrado' });
req.flash('success_msg', 'Ticket cerrado');
res.redirect(`/client/ticket/${req.params.id}`);
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cerrar ticket');
res.redirect(`/client/ticket/${req.params.id}`);
}
});
module.exports = router;
+35
View File
@@ -0,0 +1,35 @@
const express = require('express');
const router = express.Router();
const { isAuthenticated } = require('../middleware/auth');
const Company = require('../models/Company');
const User = require('../models/User');
router.get('/', isAuthenticated, async (req, res) => {
try {
const company = await Company.findById(req.session.user.company_id);
const userCount = await Company.countUsers(company.id);
const ticketCount = await Company.countTickets(company.id);
res.render('company/settings', { company, userCount, ticketCount });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar configuración');
res.redirect('/');
}
});
router.post('/update', isAuthenticated, async (req, res) => {
const { name } = req.body;
try {
if (name) {
await Company.update(req.session.user.company_id, { name });
}
req.flash('success_msg', 'Empresa actualizada');
res.redirect('/company');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al actualizar');
res.redirect('/company');
}
});
module.exports = router;
+72
View File
@@ -0,0 +1,72 @@
const express = require('express');
const router = express.Router();
const { isSuperAdmin } = require('../middleware/auth');
const Company = require('../models/Company');
const User = require('../models/User');
const Ticket = require('../models/Ticket');
router.get('/companies', isSuperAdmin, async (req, res) => {
try {
const companies = await Company.findAll();
for (const c of companies) {
c.userCount = await Company.countUsers(c.id);
c.ticketCount = await Company.countTickets(c.id);
}
res.render('saas/companies', { companies });
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al cargar empresas');
res.redirect('/');
}
});
router.post('/companies/create', isSuperAdmin, async (req, res) => {
const { name, slug, plan } = req.body;
if (!name || !slug) {
req.flash('error_msg', 'Nombre y slug son obligatorios');
return res.redirect('/saas/companies');
}
try {
const existing = await Company.findBySlug(slug);
if (existing) {
req.flash('error_msg', 'El slug ya existe');
return res.redirect('/saas/companies');
}
const companyId = await Company.create(name, slug, plan || 'free');
const Department = require('../models/Department');
await Department.create(companyId, 'Soporte Técnico', 'Problemas técnicos');
await Department.create(companyId, 'General', 'Consultas generales');
const bcrypt = require('bcryptjs');
const hash = await bcrypt.hash('123456', 10);
await User.create(companyId, 'Admin', `admin@${slug}.com`, hash, 'admin');
req.flash('success_msg', `Empresa ${name} creada`);
res.redirect('/saas/companies');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al crear empresa');
res.redirect('/saas/companies');
}
});
router.post('/companies/update/:id', isSuperAdmin, async (req, res) => {
const { name, plan, max_users, max_tickets, is_active } = req.body;
try {
const data = {};
if (name) data.name = name;
if (plan) data.plan = plan;
if (max_users) data.max_users = parseInt(max_users);
if (max_tickets) data.max_tickets = parseInt(max_tickets);
data.is_active = is_active ? 1 : 0;
await Company.update(req.params.id, data);
req.flash('success_msg', 'Empresa actualizada');
res.redirect('/saas/companies');
} catch (error) {
console.error(error);
req.flash('error_msg', 'Error al actualizar empresa');
res.redirect('/saas/companies');
}
});
module.exports = router;
+22
View File
@@ -0,0 +1,22 @@
require('dotenv').config();
const { consultarErrores, APP_ORIGEN } = require('../config/errorReporter');
async function main() {
console.log(`=== Consultando Bitácora de Errores para [${APP_ORIGEN}] ===`);
try {
const res = await consultarErrores({ page: 1, limit: 20 });
console.log(`HTTP Status: ${res.statusCode}`);
if (res.data) {
console.log('Resultados:', JSON.stringify(res.data, null, 2));
} else {
console.log('Respuesta cruda:', res.raw);
}
} catch (error) {
console.error('No se pudo consultar la bitácora de errores:', error.message);
if (!process.env.WEBCONTROL_API_KEY) {
console.log('💡 Tip: Configura WEBCONTROL_API_KEY en tu archivo .env para acceder a la API.');
}
}
}
main();
+52
View File
@@ -0,0 +1,52 @@
<%- include('../partials/header') %>
<% title = 'Crear Usuario'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Nuevo Usuario</h1>
<a href="/admin/users" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> Volver</a>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<form action="/admin/users/create" method="POST">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="nombre" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Contraseña</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Rol</label>
<select name="rol" class="form-select">
<option value="client">Cliente</option>
<option value="agent">Agente</option>
<option value="admin">Admin</option>
</select>
</div>
<button type="submit" class="btn btn-primary">Crear Usuario</button>
</form>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+91
View File
@@ -0,0 +1,91 @@
<%- include('../partials/header') %>
<% title = 'Admin Dashboard'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Dashboard Administrativo</h1>
</div>
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card text-white bg-primary">
<div class="card-body text-center">
<h5><i class="fas fa-users"></i> Usuarios</h5>
<h2><%= users.length %></h2>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-white bg-success">
<div class="card-body text-center">
<h5><i class="fas fa-ticket"></i> Tickets</h5>
<h2><%= tickets.length %></h2>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-white bg-info">
<div class="card-body text-center">
<h5><i class="fas fa-building"></i> Departamentos</h5>
<h2><%= departments.length %></h2>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card text-white bg-warning">
<div class="card-body text-center">
<h5><i class="fas fa-spinner"></i> Abiertos</h5>
<h2><%= stats.find(s => s.status === 'abierto') ? stats.find(s => s.status === 'abierto').total : 0 %></h2>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-header"><h5>Últimos Tickets</h5></div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm">
<thead><tr><th>#</th><th>Asunto</th><th>Usuario</th><th>Estado</th></tr></thead>
<tbody>
<% tickets.slice(0, 5).forEach(t => { %>
<tr>
<td>#<%= t.id %></td>
<td><%= t.asunto %></td>
<td><%= t.usuario_nombre || '' %></td>
<td><span class="badge bg-<%= t.status === 'abierto' ? 'success' : t.status === 'en_progreso' ? 'primary' : 'secondary' %>"><%= t.status %></span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-header"><h5>Accesos Rápidos</h5></div>
<div class="card-body">
<div class="list-group">
<a href="/admin/users" class="list-group-item list-group-item-action"><i class="fas fa-users"></i> Gestionar Usuarios</a>
<a href="/admin/departments" class="list-group-item list-group-item-action"><i class="fas fa-building"></i> Gestionar Departamentos</a>
<a href="/agent/dashboard" class="list-group-item list-group-item-action"><i class="fas fa-tasks"></i> Ver Todos los Tickets</a>
</div>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+97
View File
@@ -0,0 +1,97 @@
<%- include('../partials/header') %>
<% title = 'Departamentos'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Departamentos</h1>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal">
<i class="fas fa-plus"></i> Nuevo Departamento
</button>
</div>
<div class="row">
<% departments.forEach(d => { %>
<div class="col-md-4 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title"><%= d.nombre %></h5>
<p class="card-text"><%= d.descripcion || 'Sin descripción' %></p>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-warning" data-bs-toggle="modal" data-bs-target="#editModal<%= d.id %>">
<i class="fas fa-edit"></i>
</button>
<form action="/admin/departments/delete/<%= d.id %>" method="POST" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('¿Eliminar este departamento?')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</div>
</div>
</div>
<div class="modal fade" id="editModal<%= d.id %>">
<div class="modal-dialog">
<div class="modal-content">
<form action="/admin/departments/update/<%= d.id %>" method="POST">
<div class="modal-header">
<h5 class="modal-title">Editar Departamento</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="nombre" class="form-control" value="<%= d.nombre %>" required>
</div>
<div class="mb-3">
<label class="form-label">Descripción</label>
<textarea name="descripcion" class="form-control" rows="3"><%= d.descripcion || '' %></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<% }) %>
</div>
<div class="modal fade" id="createModal">
<div class="modal-dialog">
<div class="modal-content">
<form action="/admin/departments/create" method="POST">
<div class="modal-header">
<h5 class="modal-title">Nuevo Departamento</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="nombre" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Descripción</label>
<textarea name="descripcion" class="form-control" rows="3"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Crear</button>
</div>
</form>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+67
View File
@@ -0,0 +1,67 @@
<%- include('../partials/header') %>
<% title = 'Usuarios'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Usuarios</h1>
<a href="/admin/users/create" class="btn btn-primary">
<i class="fas fa-plus"></i> Nuevo Usuario
</a>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Email</th>
<th>Rol</th>
<th>Activo</th>
<th>Registro</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
<% if (users.length === 0) { %>
<tr><td colspan="7" class="text-center">No hay usuarios</td></tr>
<% } %>
<% users.forEach(u => { %>
<tr>
<td><%= u.id %></td>
<td><%= u.nombre %></td>
<td><%= u.email %></td>
<td><span class="badge bg-<%= u.rol === 'superadmin' ? 'dark' : u.rol === 'admin' ? 'danger' : u.rol === 'agent' ? 'info' : 'secondary' %>"><%= u.rol %></span></td>
<td><span class="badge bg-<%= u.is_active ? 'success' : 'danger' %>"><%= u.is_active ? 'Sí' : 'No' %></span></td>
<td><%= u.created_at %></td>
<td>
<% if (u.rol !== 'superadmin') { %>
<form action="/admin/users/invite/<%= u.id %>" method="POST" style="display:inline">
<button type="submit" class="btn btn-sm btn-info" title="Generar link de invitación">
<i class="fas fa-envelope"></i>
</button>
</form>
<form action="/admin/users/delete/<%= u.id %>" method="POST" style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" onclick="return confirm('¿Eliminar este usuario?')">
<i class="fas fa-trash"></i>
</button>
</form>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<%- include('../partials/footer') %>
+75
View File
@@ -0,0 +1,75 @@
<%- include('../partials/header') %>
<% title = 'Panel de ' + (user.rol === 'admin' ? 'Administración' : 'Agente'); %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Panel de <%= user.rol === 'admin' ? 'Administración' : 'Agente' %></h1>
</div>
<div class="row mb-4">
<% stats.forEach(s => { %>
<% const color = s.status === 'abierto' ? 'success' : s.status === 'en_progreso' ? 'primary' : 'secondary' %>
<div class="col-md-4 mb-3">
<div class="card border-<%= color %> text-<%= color %>">
<div class="card-body text-center">
<h5 class="card-title"><%= s.status %></h5>
<h2><%= s.total %></h2>
</div>
</div>
</div>
<% }) %>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>#</th>
<th>Usuario</th>
<th>Asunto</th>
<th>Departamento</th>
<th>Prioridad</th>
<th>Estado</th>
<th>Asignado</th>
<th>Fecha</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
<% if (tickets.length === 0) { %>
<tr><td colspan="9" class="text-center">No hay tickets</td></tr>
<% } %>
<% tickets.forEach(t => { %>
<tr>
<td>#<%= t.id %></td>
<td><%= t.usuario_nombre || '' %></td>
<td><%= t.asunto %></td>
<td><%= t.departamento_nombre || 'Sin depto' %></td>
<td>
<span class="badge bg-<%= t.prioridad === 'alta' ? 'danger' : t.prioridad === 'critica' ? 'dark' : t.prioridad === 'media' ? 'warning' : 'info' %>"><%= t.prioridad %></span>
</td>
<td>
<span class="badge bg-<%= t.status === 'abierto' ? 'success' : t.status === 'en_progreso' ? 'primary' : 'secondary' %>"><%= t.status %></span>
</td>
<td><%= t.asignado_nombre || 'Sin asignar' %></td>
<td><%= t.created_at %></td>
<td>
<a href="/agent/ticket/<%= t.id %>" class="btn btn-sm btn-info"><i class="fas fa-eye"></i></a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<%- include('../partials/footer') %>
+102
View File
@@ -0,0 +1,102 @@
<%- include('../partials/header') %>
<% title = 'Ticket #' + ticket.id; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Ticket #<%= ticket.id %>: <%= ticket.asunto %></h1>
<a href="/agent/dashboard" class="btn btn-secondary"><i class="fas fa-arrow-left"></i> Volver</a>
</div>
<div class="row">
<div class="col-md-3">
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0">Información</h6></div>
<div class="card-body">
<p><strong>Estado:</strong> <span class="badge bg-<%= ticket.status === 'abierto' ? 'success' : ticket.status === 'en_progreso' ? 'primary' : 'secondary' %>"><%= ticket.status %></span></p>
<p><strong>Prioridad:</strong> <span class="badge bg-<%= ticket.prioridad === 'alta' ? 'danger' : ticket.prioridad === 'critica' ? 'dark' : ticket.prioridad === 'media' ? 'warning' : 'info' %>"><%= ticket.prioridad %></span></p>
<p><strong>Departamento:</strong> <%= ticket.departamento_nombre || 'Sin departamento' %></p>
<p><strong>Cliente:</strong> <%= ticket.usuario_nombre %> (<%= ticket.usuario_email %>)</p>
<p><strong>Asignado:</strong> <%= ticket.asignado_nombre || 'Sin asignar' %></p>
<p><strong>Creado:</strong> <%= ticket.created_at %></p>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0">Acciones</h6></div>
<div class="card-body">
<form action="/agent/ticket/<%= ticket.id %>/status" method="POST" class="mb-2">
<label class="form-label">Cambiar Estado</label>
<select name="status" class="form-select mb-2">
<option value="abierto" <%= ticket.status === 'abierto' ? 'selected' : '' %>>Abierto</option>
<option value="en_progreso" <%= ticket.status === 'en_progreso' ? 'selected' : '' %>>En Progreso</option>
<option value="cerrado" <%= ticket.status === 'cerrado' ? 'selected' : '' %>>Cerrado</option>
</select>
<button type="submit" class="btn btn-primary btn-sm w-100">Actualizar</button>
</form>
<% if (user.rol === 'admin') { %>
<form action="/agent/ticket/<%= ticket.id %>/assign" method="POST">
<label class="form-label">Asignar a</label>
<select name="assigned_to" class="form-select mb-2">
<option value="">Sin asignar</option>
<% agents.forEach(a => { %>
<option value="<%= a.id %>" <%= ticket.assigned_to === a.id ? 'selected' : '' %>><%= a.nombre %></option>
<% }) %>
</select>
<button type="submit" class="btn btn-info btn-sm w-100">Asignar</button>
</form>
<% } %>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card mb-3">
<div class="card-header">
<strong><%= ticket.usuario_nombre %></strong> <span class="text-muted">- <%= ticket.created_at %></span>
</div>
<div class="card-body">
<p><%= ticket.mensaje %></p>
</div>
</div>
<h5>Respuestas</h5>
<% if (respuestas.length === 0) { %>
<p class="text-muted">No hay respuestas aún</p>
<% } %>
<% respuestas.forEach(r => { %>
<div class="card mb-2 <%= r.usuario_rol === 'agent' || r.usuario_rol === 'admin' ? 'border-primary' : '' %>">
<div class="card-header">
<strong><%= r.usuario_nombre %></strong>
<span class="badge bg-<%= r.usuario_rol === 'admin' ? 'danger' : r.usuario_rol === 'agent' ? 'info' : 'secondary' %> ms-1"><%= r.usuario_rol %></span>
<span class="text-muted float-end"><%= r.created_at %></span>
</div>
<div class="card-body"><p class="mb-0"><%= r.mensaje %></p></div>
</div>
<% }) %>
<div class="card mt-3">
<div class="card-header"><strong>Responder</strong></div>
<div class="card-body">
<form action="/agent/ticket/<%= ticket.id %>/respond" method="POST">
<div class="mb-3">
<textarea name="mensaje" class="form-control" rows="3" required></textarea>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-reply"></i> Responder</button>
</form>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+43
View File
@@ -0,0 +1,43 @@
<%- include('../partials/header') %>
<% title = 'Aceptar Invitación'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="row justify-content-center mt-5">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-info text-white text-center">
<h4><i class="fas fa-envelope"></i> Aceptar Invitación</h4>
</div>
<div class="card-body">
<p class="text-muted">Has sido invitado con el email: <strong><%= email %></strong></p>
<form action="/invite/<%= token %>" method="POST">
<div class="mb-3">
<label class="form-label">Nombre Completo</label>
<input type="text" name="nombre" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Contraseña</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Confirmar Contraseña</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-info w-100">Activar Cuenta</button>
</form>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+49
View File
@@ -0,0 +1,49 @@
<%- include('../partials/header') %>
<% title = 'Iniciar Sesión'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3">
<%= msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3">
<%= msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% }) %>
<% } %>
<div class="row justify-content-center mt-5">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-primary text-white text-center">
<h4><i class="fas fa-headset"></i> Help Desk</h4>
<span>Iniciar Sesión</span>
</div>
<div class="card-body">
<form action="/login" method="POST">
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Contraseña</label>
<input type="password" name="password" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary w-100">Ingresar</button>
</form>
<div class="text-center mt-3">
<a href="/register">¿No tienes cuenta? Regístrate</a>
</div>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+55
View File
@@ -0,0 +1,55 @@
<%- include('../partials/header') %>
<% title = 'Registro'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="row justify-content-center mt-5">
<div class="col-md-5">
<div class="card shadow">
<div class="card-header bg-success text-white text-center">
<h4><i class="fas fa-user-plus"></i> Crear Cuenta</h4>
<small>Registra tu empresa</small>
</div>
<div class="card-body">
<form action="/register" method="POST">
<div class="mb-3">
<label class="form-label">Nombre de la Empresa</label>
<input type="text" name="company_name" class="form-control" placeholder="Mi Empresa" required>
</div>
<hr>
<div class="mb-3">
<label class="form-label">Tu Nombre</label>
<input type="text" name="nombre" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Email</label>
<input type="email" name="email" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Contraseña</label>
<input type="password" name="password" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Confirmar Contraseña</label>
<input type="password" name="confirm_password" class="form-control" required>
</div>
<button type="submit" class="btn btn-success w-100">Crear Empresa</button>
</form>
<div class="text-center mt-3">
<a href="/login">¿Ya tienes cuenta? Inicia sesión</a>
</div>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+68
View File
@@ -0,0 +1,68 @@
<%- include('../partials/header') %>
<% title = 'Mis Tickets'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Mis Tickets</h1>
<a href="/client/new-ticket" class="btn btn-primary">
<i class="fas fa-plus"></i> Nuevo Ticket
</a>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>#</th>
<th>Asunto</th>
<th>Departamento</th>
<th>Prioridad</th>
<th>Estado</th>
<th>Asignado</th>
<th>Fecha</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
<% if (tickets.length === 0) { %>
<tr><td colspan="8" class="text-center">No tienes tickets aún</td></tr>
<% } %>
<% tickets.forEach(ticket => { %>
<tr>
<td>#<%= ticket.id %></td>
<td><%= ticket.asunto %></td>
<td><%= ticket.departamento_nombre || 'Sin departamento' %></td>
<td>
<span class="badge bg-<%= ticket.prioridad === 'alta' ? 'danger' : ticket.prioridad === 'critica' ? 'dark' : ticket.prioridad === 'media' ? 'warning' : 'info' %>">
<%= ticket.prioridad %>
</span>
</td>
<td>
<span class="badge bg-<%= ticket.status === 'abierto' ? 'success' : ticket.status === 'en_progreso' ? 'primary' : 'secondary' %>">
<%= ticket.status %>
</span>
</td>
<td><%= ticket.asignado_nombre || 'Sin asignar' %></td>
<td><%= ticket.created_at %></td>
<td>
<a href="/client/ticket/<%= ticket.id %>" class="btn btn-sm btn-info">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<%- include('../partials/footer') %>
+59
View File
@@ -0,0 +1,59 @@
<%- include('../partials/header') %>
<% title = 'Nuevo Ticket'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Nuevo Ticket</h1>
</div>
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-body">
<form action="/client/new-ticket" method="POST">
<div class="mb-3">
<label class="form-label">Asunto</label>
<input type="text" name="asunto" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Departamento</label>
<select name="department_id" class="form-select">
<option value="">Sin departamento</option>
<% departments.forEach(dept => { %>
<option value="<%= dept.id %>"><%= dept.nombre %></option>
<% }) %>
</select>
</div>
<div class="mb-3">
<label class="form-label">Prioridad</label>
<select name="prioridad" class="form-select">
<option value="baja">Baja</option>
<option value="media" selected>Media</option>
<option value="alta">Alta</option>
<option value="critica">Crítica</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Mensaje</label>
<textarea name="mensaje" class="form-control" rows="6" required></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-paper-plane"></i> Enviar Ticket
</button>
</form>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+88
View File
@@ -0,0 +1,88 @@
<%- include('../partials/header') %>
<% title = 'Ticket #' + ticket.id; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Ticket #<%= ticket.id %>: <%= ticket.asunto %></h1>
<div>
<form action="/client/ticket/<%= ticket.id %>/close" method="POST" style="display:inline">
<button type="submit" class="btn btn-warning" onclick="return confirm('¿Cerrar este ticket?')">
<i class="fas fa-times"></i> Cerrar Ticket
</button>
</form>
<a href="/client/dashboard" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Volver
</a>
</div>
</div>
<div class="row">
<div class="col-md-3">
<div class="card mb-3">
<div class="card-header"><h6 class="mb-0">Información</h6></div>
<div class="card-body">
<p><strong>Estado:</strong>
<span class="badge bg-<%= ticket.status === 'abierto' ? 'success' : ticket.status === 'en_progreso' ? 'primary' : 'secondary' %>"><%= ticket.status %></span>
</p>
<p><strong>Prioridad:</strong>
<span class="badge bg-<%= ticket.prioridad === 'alta' ? 'danger' : ticket.prioridad === 'critica' ? 'dark' : ticket.prioridad === 'media' ? 'warning' : 'info' %>"><%= ticket.prioridad %></span>
</p>
<p><strong>Departamento:</strong> <%= ticket.departamento_nombre || 'Sin departamento' %></p>
<p><strong>Asignado:</strong> <%= ticket.asignado_nombre || 'Sin asignar' %></p>
<p><strong>Creado:</strong> <%= ticket.created_at %></p>
</div>
</div>
</div>
<div class="col-md-9">
<div class="card mb-3">
<div class="card-header">
<strong><%= ticket.usuario_nombre %></strong>
<span class="text-muted">- <%= ticket.created_at %></span>
</div>
<div class="card-body">
<p><%= ticket.mensaje %></p>
</div>
</div>
<h5>Respuestas</h5>
<% if (respuestas.length === 0) { %>
<p class="text-muted">No hay respuestas aún</p>
<% } %>
<% respuestas.forEach(r => { %>
<div class="card mb-2 <%= r.usuario_rol === 'agent' || r.usuario_rol === 'admin' ? 'border-primary' : '' %>">
<div class="card-header">
<strong><%= r.usuario_nombre %></strong>
<span class="badge bg-<%= r.usuario_rol === 'admin' ? 'danger' : r.usuario_rol === 'agent' ? 'info' : 'secondary' %> ms-1"><%= r.usuario_rol %></span>
<span class="text-muted float-end"><%= r.created_at %></span>
</div>
<div class="card-body">
<p class="mb-0"><%= r.mensaje %></p>
</div>
</div>
<% }) %>
<div class="card mt-3">
<div class="card-header"><strong>Responder</strong></div>
<div class="card-body">
<form action="/client/ticket/<%= ticket.id %>/respond" method="POST">
<div class="mb-3">
<textarea name="mensaje" class="form-control" rows="3" required></textarea>
</div>
<button type="submit" class="btn btn-primary"><i class="fas fa-reply"></i> Responder</button>
</form>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+56
View File
@@ -0,0 +1,56 @@
<%- include('../partials/header') %>
<% title = 'Configuración'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Configuración de la Empresa</h1>
</div>
<div class="row">
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header"><h5>Información</h5></div>
<div class="card-body">
<form action="/company/update" method="POST">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="name" class="form-control" value="<%= company.name %>">
</div>
<button type="submit" class="btn btn-primary">Guardar</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card mb-3">
<div class="card-header"><h5>Plan y Límites</h5></div>
<div class="card-body">
<p><strong>Plan:</strong> <span class="badge bg-<%= company.plan === 'enterprise' ? 'danger' : company.plan === 'pro' ? 'warning' : 'secondary' %>"><%= company.plan %></span></p>
<p><strong>Usuarios:</strong> <%= userCount %> / <%= company.max_users %></p>
<p><strong>Tickets:</strong> <%= ticketCount %> / <%= company.max_tickets %></p>
<div class="progress mb-2">
<div class="progress-bar" style="width: <%= Math.min(100, (userCount / company.max_users) * 100) %>%">
<%= Math.round((userCount / company.max_users) * 100) %>% usuarios
</div>
</div>
<div class="progress">
<div class="progress-bar bg-success" style="width: <%= Math.min(100, (ticketCount / company.max_tickets) * 100) %>%">
<%= Math.round((ticketCount / company.max_tickets) * 100) %>% tickets
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('../partials/footer') %>
+49
View File
@@ -0,0 +1,49 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help Desk - <%- title || 'Sistema de Tickets' %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
</head>
<body>
<%- include('../partials/navbar') %>
<div class="container-fluid">
<div class="row">
<% if (user) { %>
<%- include('../partials/sidebar') %>
<main class="col-md-9 ms-sm-auto col-lg-10 px-4">
<% } else { %>
<main class="col-12 px-4">
<% } %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3">
<%= msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3">
<%= msg %>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<% }) %>
<% } %>
<%- body %>
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/js/main.js"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Help Desk - <%= typeof title !== 'undefined' ? title : 'Sistema de Tickets' %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="/css/style.css" rel="stylesheet">
</head>
<body>
<%- include('navbar') %>
<div class="container-fluid">
<div class="row">
<% if (user) { %>
<%- include('sidebar') %>
<main class="col-md-9 ms-sm-auto col-lg-10 px-4">
<% } else { %>
<main class="col-12 px-4">
<% } %>
+18
View File
@@ -0,0 +1,18 @@
<nav class="navbar navbar-dark bg-dark sticky-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="fas fa-headset"></i> Help Desk
</a>
<% if (user) { %>
<div class="d-flex align-items-center">
<span class="text-light me-3">
<i class="fas fa-user"></i> <%= user.nombre %>
<span class="badge bg-info ms-1"><%= user.rol %></span>
</span>
<a href="/logout" class="btn btn-outline-light btn-sm">
<i class="fas fa-sign-out-alt"></i> Salir
</a>
</div>
<% } %>
</div>
</nav>
+70
View File
@@ -0,0 +1,70 @@
<nav class="col-md-3 col-lg-2 d-md-block bg-light sidebar">
<div class="position-sticky pt-3">
<% if (company) { %>
<div class="px-3 mb-3">
<small class="text-muted"><%= company.name %></small>
<span class="badge bg-info"><%= company.plan %></span>
</div>
<% } %>
<ul class="nav flex-column">
<% if (user.rol === 'superadmin') { %>
<li class="nav-item">
<a class="nav-link" href="/saas/companies">
<i class="fas fa-building"></i> Empresas
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/dashboard">
<i class="fas fa-tachometer-alt"></i> Mi Empresa
</a>
</li>
<% } %>
<li class="nav-item">
<a class="nav-link" href="/<%= ['superadmin','admin'].includes(user.rol) ? 'admin' : user.rol %>/dashboard">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<% if (user.rol === 'client') { %>
<li class="nav-item">
<a class="nav-link" href="/client/new-ticket">
<i class="fas fa-plus-circle"></i> Nuevo Ticket
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/client/dashboard">
<i class="fas fa-tickets"></i> Mis Tickets
</a>
</li>
<% } %>
<% if (['superadmin', 'admin', 'agent'].includes(user.rol)) { %>
<li class="nav-item">
<a class="nav-link" href="/agent/dashboard">
<i class="fas fa-tasks"></i> Tickets
</a>
</li>
<% } %>
<% if (['superadmin', 'admin'].includes(user.rol)) { %>
<li class="nav-item">
<a class="nav-link" href="/admin/users">
<i class="fas fa-users"></i> Usuarios
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/admin/departments">
<i class="fas fa-building"></i> Departamentos
</a>
</li>
<% } %>
<li class="nav-item">
<a class="nav-link" href="/company">
<i class="fas fa-cog"></i> Configuración
</a>
</li>
</ul>
</div>
</nav>
+141
View File
@@ -0,0 +1,141 @@
<%- include('../partials/header') %>
<% title = 'Empresas - SaaS Admin'; %>
<% if (success_msg && success_msg.length > 0) { %>
<% success_msg.forEach(msg => { %>
<div class="alert alert-success alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<% if (error_msg && error_msg.length > 0) { %>
<% error_msg.forEach(msg => { %>
<div class="alert alert-danger alert-dismissible fade show mt-3"><%= msg %><button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>
<% }) %>
<% } %>
<div class="d-flex justify-content-between align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">Panel SaaS - Empresas</h1>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createModal">
<i class="fas fa-plus"></i> Nueva Empresa
</button>
</div>
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Nombre</th>
<th>Slug</th>
<th>Plan</th>
<th>Usuarios</th>
<th>Tickets</th>
<th>Límites</th>
<th>Activo</th>
<th>Acción</th>
</tr>
</thead>
<tbody>
<% companies.forEach(c => { %>
<tr>
<td><%= c.id %></td>
<td><%= c.name %></td>
<td><code><%= c.slug %></code></td>
<td><span class="badge bg-<%= c.plan === 'enterprise' ? 'danger' : c.plan === 'pro' ? 'warning' : 'secondary' %>"><%= c.plan %></span></td>
<td><%= c.userCount || 0 %></td>
<td><%= c.ticketCount || 0 %></td>
<td><%= c.max_users %> users / <%= c.max_tickets %> tickets</td>
<td>
<span class="badge bg-<%= c.is_active ? 'success' : 'danger' %>">
<%= c.is_active ? 'Sí' : 'No' %>
</span>
</td>
<td>
<button type="button" class="btn btn-sm btn-warning" data-bs-toggle="modal" data-bs-target="#editModal<%= c.id %>">
<i class="fas fa-edit"></i>
</button>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="modal fade" id="createModal">
<div class="modal-dialog">
<div class="modal-content">
<form action="/saas/companies/create" method="POST">
<div class="modal-header">
<h5 class="modal-title">Nueva Empresa</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="name" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Slug (identificador único)</label>
<input type="text" name="slug" class="form-control" required>
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<select name="plan" class="form-select">
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Crear</button>
</div>
</form>
</div>
</div>
</div>
<% companies.forEach(c => { %>
<div class="modal fade" id="editModal<%= c.id %>">
<div class="modal-dialog">
<div class="modal-content">
<form action="/saas/companies/update/<%= c.id %>" method="POST">
<div class="modal-header">
<h5 class="modal-title">Editar <%= c.name %></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Nombre</label>
<input type="text" name="name" class="form-control" value="<%= c.name %>">
</div>
<div class="mb-3">
<label class="form-label">Plan</label>
<select name="plan" class="form-select">
<option value="free" <%= c.plan === 'free' ? 'selected' : '' %>>Free</option>
<option value="pro" <%= c.plan === 'pro' ? 'selected' : '' %>>Pro</option>
<option value="enterprise" <%= c.plan === 'enterprise' ? 'selected' : '' %>>Enterprise</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Máx. Usuarios</label>
<input type="number" name="max_users" class="form-control" value="<%= c.max_users %>">
</div>
<div class="mb-3">
<label class="form-label">Máx. Tickets</label>
<input type="number" name="max_tickets" class="form-control" value="<%= c.max_tickets %>">
</div>
<div class="form-check mb-3">
<input type="checkbox" name="is_active" class="form-check-input" value="1" <%= c.is_active ? 'checked' : '' %>>
<label class="form-check-label">Activo</label>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">Guardar</button>
</div>
</form>
</div>
</div>
</div>
<% }) %>
<%- include('../partials/footer') %>