feat: inicializacion de Help Desk segun estandar Telcotronics con bitacora de errores e integracion MCP
This commit is contained in:
@@ -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
@@ -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` |
|
||||
@@ -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).
|
||||
|
||||
@@ -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) |
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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=
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
.env
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+1331
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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') %>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
<% } %>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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') %>
|
||||
Reference in New Issue
Block a user