Initial commit
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
|||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.*.timestamp*
|
||||||
|
|
||||||
|
# Build
|
||||||
|
/dist
|
||||||
|
/build
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Tokens/secrets
|
||||||
|
*token*
|
||||||
|
*_token*
|
||||||
|
*.secret
|
||||||
|
*.secrets
|
||||||
|
|
||||||
|
# Local DB dumps
|
||||||
|
*.sql
|
||||||
|
*.dump
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Manual Admin Empresa
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Gestionar mediciones de clima laboral, participantes y reportes de su empresa.
|
||||||
|
|
||||||
|
## Acceso
|
||||||
|
1) Ingresar con rol **admin** en la pantalla de Login.
|
||||||
|
2) Indicar `company_code` asignado por superadmin.
|
||||||
|
|
||||||
|
## Funciones principales
|
||||||
|
- Crear periodos de medición.
|
||||||
|
- Activar y cerrar mediciones.
|
||||||
|
- Ver dashboard de resultados.
|
||||||
|
- Importar participantes desde Excel.
|
||||||
|
- Descargar reportes PDF y Excel.
|
||||||
|
|
||||||
|
## Crear una medición
|
||||||
|
1) Ir a **Mediciones**.
|
||||||
|
2) Completar año, mes y fechas.
|
||||||
|
3) Al crear la medición se genera el snapshot de preguntas.
|
||||||
|
|
||||||
|
## Activar una medición
|
||||||
|
- En el listado de mediciones, presionar **Activar**.
|
||||||
|
|
||||||
|
## Cerrar una medición (cálculo automático)
|
||||||
|
- En el listado de mediciones, presionar **Cerrar**.
|
||||||
|
- El sistema calcula automáticamente:
|
||||||
|
- Promedio por pregunta.
|
||||||
|
- Promedio por dimensión.
|
||||||
|
- Índice global.
|
||||||
|
- Tasa de participación.
|
||||||
|
- Clasificación Bajo/Medio/Alto.
|
||||||
|
|
||||||
|
## Importación de usuarios
|
||||||
|
- Ir a **Importar**.
|
||||||
|
- Usar plantilla con columnas:
|
||||||
|
1) Nombre
|
||||||
|
2) Email
|
||||||
|
3) Departamento
|
||||||
|
4) Cargo
|
||||||
|
- El sistema valida y reporta errores por fila.
|
||||||
|
|
||||||
|
## Importación de admins
|
||||||
|
No aplica. En Fase 1 solo existe **un admin principal por empresa** y se crea automáticamente desde el Superadmin al provisionar la compañía, para evitar inconsistencias y garantizar el aislamiento por tenant.
|
||||||
|
|
||||||
|
## Reportes
|
||||||
|
- PDF: `GET /reports/pdf/:surveyPeriodId`
|
||||||
|
- Excel: `GET /reports/excel/:surveyPeriodId`
|
||||||
|
|
||||||
|
## Dashboard
|
||||||
|
- Índice global actual
|
||||||
|
- Resultado por dimensión
|
||||||
|
- Histórico por periodo
|
||||||
|
- Avance de encuestas (registrados, 100% completadas, <100%)
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
- Las preguntas no se editan en Fase 1.
|
||||||
|
- Cada medición queda vinculada al snapshot de preguntas del periodo.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Clima Laboral SaaS - Fase 1
|
||||||
|
|
||||||
|
## Resumen
|
||||||
|
Sistema SaaS multi-tenant con MariaDB (una base por empresa). Incluye superadmin, admin por empresa, participantes, encuestas fijas, cálculos automáticos, histórico, reportes PDF/Excel e importación de usuarios.
|
||||||
|
|
||||||
|
## Conexión MariaDB
|
||||||
|
Actualmente el acceso correcto es con `user_ia` en `192.168.222.20` (puerto 3306).
|
||||||
|
|
||||||
|
## Estructura
|
||||||
|
- `backend/` API Node.js + Express
|
||||||
|
- `frontend/` UI React + Vite
|
||||||
|
- `backend/src/sql/` scripts de base de datos
|
||||||
|
|
||||||
|
## Base de datos central
|
||||||
|
Archivo: `backend/src/sql/central_schema.sql`
|
||||||
|
|
||||||
|
Tablas:
|
||||||
|
- `superadmins`
|
||||||
|
- `companies` (datos de conexión de cada empresa)
|
||||||
|
- `start_date` / `end_date` controlan vigencia de acceso por empresa
|
||||||
|
|
||||||
|
## Base de datos por empresa
|
||||||
|
Archivos:
|
||||||
|
- `backend/src/sql/tenant_schema.sql`
|
||||||
|
- `backend/src/sql/seed_questions.sql`
|
||||||
|
|
||||||
|
Tablas principales:
|
||||||
|
- `admins`, `users`, `survey_periods`, `dimensions`, `questions`, `survey_questions`, `responses`, `survey_results`
|
||||||
|
|
||||||
|
Preparación Fase 2:
|
||||||
|
- `scales`, `question_versions`, `question_weights`, `metric_definitions`, `survey_rules`
|
||||||
|
|
||||||
|
## Flujo del sistema
|
||||||
|
1. Superadmin crea empresa.
|
||||||
|
2. Se genera DB de empresa, se ejecuta schema + seed, se crea admin principal.
|
||||||
|
3. Admin crea medición (survey_period) y se genera snapshot en `survey_questions`.
|
||||||
|
4. Admin activa la medición.
|
||||||
|
5. Participantes responden.
|
||||||
|
6. Admin cierra la medición y se calculan resultados automáticamente.
|
||||||
|
7. Dashboard, histórico, PDF y Excel disponibles.
|
||||||
|
|
||||||
|
## Cálculos automáticos
|
||||||
|
- Promedio por pregunta, por dimensión y global.
|
||||||
|
- Conversión a escala 0-100: `(avg - 1) / 4 * 100`.
|
||||||
|
- Clasificación: 0-59 Bajo, 60-79 Medio, 80-100 Alto.
|
||||||
|
- Participación: participantes únicos / total usuarios activos.
|
||||||
|
|
||||||
|
## Endpoints principales
|
||||||
|
- `POST /auth/login`
|
||||||
|
- `GET /superadmin/companies`
|
||||||
|
- `POST /superadmin/companies`
|
||||||
|
- `GET /admin/survey-periods`
|
||||||
|
- `POST /admin/survey-periods`
|
||||||
|
- `PATCH /admin/survey-periods/:id/activate`
|
||||||
|
- `PATCH /admin/survey-periods/:id/close`
|
||||||
|
- `GET /admin/dashboard`
|
||||||
|
- `POST /admin/users/import`
|
||||||
|
- `GET /reports/pdf/:surveyPeriodId`
|
||||||
|
- `GET /reports/excel/:surveyPeriodId`
|
||||||
|
- `GET /surveys/active`
|
||||||
|
- `POST /surveys/responses`
|
||||||
|
|
||||||
|
## Importación de usuarios
|
||||||
|
Se espera un Excel con columnas:
|
||||||
|
1) Nombre
|
||||||
|
2) Email
|
||||||
|
3) Departamento
|
||||||
|
4) Cargo
|
||||||
|
|
||||||
|
## Banco de preguntas (Excel)
|
||||||
|
Exportación e importación desde Admin:
|
||||||
|
- Archivo Excel con columnas: `question_code`, `dimension_code`, `dimension_name`, `text`, `is_active`.
|
||||||
|
- Si cambia el texto de una pregunta con mismo `question_code`, se crea una nueva versión con fecha.
|
||||||
|
- Para cambios, exporta, edita y vuelve a importar.
|
||||||
|
|
||||||
|
## Versiones de encuesta
|
||||||
|
Cada set de preguntas genera un `question_set_code` con versión. Las mediciones se crean usando el set activo.
|
||||||
|
## Importación de admins
|
||||||
|
No aplica en Fase 1. Solo se permite un admin principal por empresa, creado por Superadmin al provisionar la compañía.
|
||||||
|
|
||||||
|
## Puesta en marcha (local)
|
||||||
|
1) Instalar dependencias en `backend/` y `frontend/`.
|
||||||
|
2) Crear base central con `central_schema.sql`.
|
||||||
|
3) Crear superadmin con `npm run seed:superadmin -- "Nombre" correo@dominio.com clave`.
|
||||||
|
4) Iniciar backend y frontend.
|
||||||
|
|
||||||
|
## Puertos y acceso
|
||||||
|
- Backend: `4020`
|
||||||
|
- Frontend: `5373`
|
||||||
|
- La UI consume la API usando la IP del host (no usa `localhost`).
|
||||||
|
|
||||||
|
## Nota
|
||||||
|
No se ejecutaron instalaciones por restricciones del entorno.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Manual Superadmin
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Administrar la plataforma SaaS y crear empresas con su base de datos independiente.
|
||||||
|
|
||||||
|
## Acceso
|
||||||
|
1) Ingresar con rol **superadmin** en la pantalla de Login.
|
||||||
|
2) No requiere `company_code`.
|
||||||
|
|
||||||
|
## Funciones principales
|
||||||
|
- Crear empresas (provisionamiento automático de base de datos y admin principal).
|
||||||
|
- Crear múltiples administradores por empresa.
|
||||||
|
- Activar o suspender empresas.
|
||||||
|
- Definir fecha de inicio y fecha de fin de vigencia por empresa.
|
||||||
|
- Las fechas son obligatorias. Si no están en rango, la empresa queda sin acceso.
|
||||||
|
- Ver avance de encuestas por empresa (registrados, 100% completadas, <100%).
|
||||||
|
- Ver listado de empresas.
|
||||||
|
|
||||||
|
## Flujo de creación de empresa
|
||||||
|
1) Completar el formulario **Nueva Empresa**.
|
||||||
|
2) Al guardar, el sistema:
|
||||||
|
- Crea la base de datos de la empresa.
|
||||||
|
- Ejecuta el schema + seed de preguntas.
|
||||||
|
- Crea el admin principal.
|
||||||
|
|
||||||
|
## Campos requeridos al crear empresa
|
||||||
|
- Nombre
|
||||||
|
- Código (ej: EMPRESA-001)
|
||||||
|
- Fecha inicio (opcional)
|
||||||
|
- Fecha fin (opcional)
|
||||||
|
- DB Name (ej: clima_empresa_001)
|
||||||
|
- Admin Nombre
|
||||||
|
- Admin Email
|
||||||
|
- Admin Password
|
||||||
|
|
||||||
|
> Nota: Los datos de conexión por defecto están precargados en UI, se pueden ajustar si aplica.
|
||||||
|
|
||||||
|
## Endpoints relevantes
|
||||||
|
- `POST /auth/login`
|
||||||
|
- `GET /superadmin/companies`
|
||||||
|
- `POST /superadmin/companies`
|
||||||
|
- `PATCH /superadmin/companies/:id/status`
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
- Si el acceso a MariaDB falla (Error 1045), revisar permisos del usuario en el host cliente.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Manual Usuario Participante
|
||||||
|
|
||||||
|
## Objetivo
|
||||||
|
Responder la encuesta de clima laboral activa de su empresa.
|
||||||
|
|
||||||
|
## Acceso
|
||||||
|
1) Ingresar con rol **user** en la pantalla de Login.
|
||||||
|
2) Indicar `company_code`.
|
||||||
|
3) Usar correo y contraseña entregados por el admin.
|
||||||
|
|
||||||
|
## Responder encuesta
|
||||||
|
1) Ir a **Encuesta**.
|
||||||
|
2) Completar todas las preguntas (escala 1 a 5).
|
||||||
|
3) Presionar **Enviar**.
|
||||||
|
|
||||||
|
## Escala
|
||||||
|
- 1: Totalmente en desacuerdo
|
||||||
|
- 2: En desacuerdo
|
||||||
|
- 3: Ni de acuerdo ni en desacuerdo
|
||||||
|
- 4: De acuerdo
|
||||||
|
- 5: Totalmente de acuerdo
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
- Sólo se puede responder durante el periodo activo.
|
||||||
|
- Las respuestas quedan asociadas al periodo de medición vigente.
|
||||||
Generated
+2999
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "clima-laboral-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node src/server.js",
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"seed:superadmin": "node src/scripts/seed_superadmin.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dayjs": "^1.11.11",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"mysql2": "^3.10.2",
|
||||||
|
"pdfkit": "^0.15.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
|
||||||
|
const authRoutes = require('./routes/auth');
|
||||||
|
const superadminRoutes = require('./routes/superadmin');
|
||||||
|
const adminRoutes = require('./routes/admin');
|
||||||
|
const surveyRoutes = require('./routes/surveys');
|
||||||
|
const reportRoutes = require('./routes/reports');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json({ limit: '5mb' }));
|
||||||
|
|
||||||
|
app.get('/health', (req, res) => res.json({ ok: true }));
|
||||||
|
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/superadmin', superadminRoutes);
|
||||||
|
app.use('/admin', adminRoutes);
|
||||||
|
app.use('/surveys', surveyRoutes);
|
||||||
|
app.use('/reports', reportRoutes);
|
||||||
|
|
||||||
|
const { getCentralPool } = require('./db/central');
|
||||||
|
|
||||||
|
app.use(async (err, req, res, next) => {
|
||||||
|
console.error(err);
|
||||||
|
const role = req.user?.role || null;
|
||||||
|
const userId = req.user?.id || null;
|
||||||
|
const companyCode = req.user?.company_code || req.headers['x-company-code'] || null;
|
||||||
|
const menu = req.headers['x-menu'] || req.headers['x-route'] || req.path;
|
||||||
|
const errorMessage = err?.message || 'Error interno';
|
||||||
|
|
||||||
|
if (role !== 'superadmin') {
|
||||||
|
try {
|
||||||
|
const central = getCentralPool();
|
||||||
|
await central.query(
|
||||||
|
'INSERT INTO error_logs (user_role, user_id, company_code, menu, error_message) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[role, userId, companyCode, menu, errorMessage]
|
||||||
|
);
|
||||||
|
} catch (logErr) {
|
||||||
|
console.error('Error log fail:', logErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'superadmin') {
|
||||||
|
return res.status(500).json({ error: 'Error interno', detail: errorMessage });
|
||||||
|
}
|
||||||
|
return res.status(500).json({ error: 'Error interno' });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
port: process.env.PORT || 4020,
|
||||||
|
jwtSecret: process.env.JWT_SECRET || 'CHANGE_ME',
|
||||||
|
centralDb: {
|
||||||
|
host: process.env.CENTRAL_DB_HOST || '192.168.222.20',
|
||||||
|
port: process.env.CENTRAL_DB_PORT || 3306,
|
||||||
|
user: process.env.CENTRAL_DB_USER || 'user_ia',
|
||||||
|
password: process.env.CENTRAL_DB_PASS || 'UserIA2026',
|
||||||
|
database: process.env.CENTRAL_DB_NAME || 'clima_platform'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const { centralDb } = require('../config/env');
|
||||||
|
|
||||||
|
let pool;
|
||||||
|
|
||||||
|
function getCentralPool() {
|
||||||
|
if (!pool) {
|
||||||
|
pool = mysql.createPool({
|
||||||
|
host: centralDb.host,
|
||||||
|
port: centralDb.port,
|
||||||
|
user: centralDb.user,
|
||||||
|
password: centralDb.password,
|
||||||
|
database: centralDb.database,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getCentralPool };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const pools = new Map();
|
||||||
|
|
||||||
|
function getTenantPool(company) {
|
||||||
|
if (!company || !company.db_name) {
|
||||||
|
throw new Error('Empresa sin datos de conexión');
|
||||||
|
}
|
||||||
|
if (!pools.has(company.db_name)) {
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: company.db_host,
|
||||||
|
port: company.db_port || 3306,
|
||||||
|
user: company.db_user,
|
||||||
|
password: company.db_pass,
|
||||||
|
database: company.db_name,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
pools.set(company.db_name, pool);
|
||||||
|
}
|
||||||
|
return pools.get(company.db_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getTenantPool };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { verifyToken } = require('../utils/jwt');
|
||||||
|
|
||||||
|
function authRequired(req, res, next) {
|
||||||
|
const header = req.headers.authorization || '';
|
||||||
|
const token = header.replace('Bearer ', '');
|
||||||
|
if (!token) return res.status(401).json({ error: 'Token requerido' });
|
||||||
|
try {
|
||||||
|
req.user = verifyToken(token);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: 'Token inválido' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authRequired };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
function requireRole(...roles) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (!req.user || !roles.includes(req.user.role)) {
|
||||||
|
return res.status(403).json({ error: 'No autorizado' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { requireRole };
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const { getCentralPool } = require('../db/central');
|
||||||
|
const { getTenantPool } = require('../db/tenant');
|
||||||
|
|
||||||
|
async function resolveTenant(req, res, next) {
|
||||||
|
try {
|
||||||
|
const code = req.headers['x-company-code'] || req.query.company_code || req.body.company_code;
|
||||||
|
if (!code) return res.status(400).json({ error: 'company_code requerido' });
|
||||||
|
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [rows] = await central.query(
|
||||||
|
'SELECT * FROM companies WHERE code = ? AND status = "active" AND start_date <= CURDATE() AND end_date >= CURDATE()',
|
||||||
|
[code]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Empresa no encontrada o inactiva' });
|
||||||
|
|
||||||
|
req.company = rows[0];
|
||||||
|
req.tenantPool = getTenantPool(rows[0]);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveTenant };
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const multer = require('multer');
|
||||||
|
const { authRequired } = require('../middleware/auth');
|
||||||
|
const { requireRole } = require('../middleware/rbac');
|
||||||
|
const { resolveTenant } = require('../middleware/tenant');
|
||||||
|
const { createSurveyPeriod, activateSurvey, closeSurvey } = require('../services/surveyService');
|
||||||
|
const { importUsersFromExcel } = require('../services/importService');
|
||||||
|
const { getParticipationSummary } = require('../services/participationService');
|
||||||
|
const {
|
||||||
|
listQuestions,
|
||||||
|
exportQuestionsExcel,
|
||||||
|
exportQuestionsTemplate,
|
||||||
|
importQuestionsExcel,
|
||||||
|
listQuestionSets,
|
||||||
|
listDimensions,
|
||||||
|
createDimension,
|
||||||
|
createQuestionSetFromCurrent,
|
||||||
|
createQuestionSetEmpty,
|
||||||
|
addQuestionToSet
|
||||||
|
} = require('../services/questionService');
|
||||||
|
|
||||||
|
const upload = multer();
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authRequired, requireRole('admin', 'superadmin'), resolveTenant);
|
||||||
|
|
||||||
|
router.get('/survey-periods', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await req.tenantPool.query('SELECT * FROM survey_periods ORDER BY id DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/survey-periods', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { year, month, fecha_inicio, fecha_fin } = req.body;
|
||||||
|
if (!year || !month || !fecha_inicio || !fecha_fin) return res.status(400).json({ error: 'Campos requeridos' });
|
||||||
|
const period = await createSurveyPeriod(req.tenantPool, { year, month, fecha_inicio, fecha_fin });
|
||||||
|
res.json(period);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/survey-periods/:id/activate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await activateSurvey(req.tenantPool, req.params.id);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/survey-periods/:id/close', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const result = await closeSurvey(req.tenantPool, req.params.id);
|
||||||
|
res.json({ ok: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/dashboard', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[current]] = await req.tenantPool.query(
|
||||||
|
'SELECT score, classification, participation_rate FROM survey_results WHERE scope = "global" ORDER BY survey_period_id DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
const [dimensions] = await req.tenantPool.query(
|
||||||
|
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.scope = "dimension" AND r.survey_period_id = (SELECT MAX(id) FROM survey_periods WHERE estado = "cerrada")'
|
||||||
|
);
|
||||||
|
const [history] = await req.tenantPool.query(
|
||||||
|
'SELECT sp.codigo_medicion, r.score FROM survey_results r JOIN survey_periods sp ON sp.id = r.survey_period_id WHERE r.scope = "global" ORDER BY sp.id DESC'
|
||||||
|
);
|
||||||
|
const participation = await getParticipationSummary(req.tenantPool);
|
||||||
|
res.json({ current, dimensions, history, participation });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/users/import', upload.single('file'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Archivo requerido' });
|
||||||
|
const result = await importUsersFromExcel(req.file.buffer, req.tenantPool);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/questions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await listQuestions(req.tenantPool);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/questions/export', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const buffer = await exportQuestionsExcel(req.tenantPool);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=\"preguntas.xlsx\"');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/questions/template', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const buffer = await exportQuestionsTemplate();
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename=\"preguntas_template.xlsx\"');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/questions/import', upload.single('file'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) return res.status(400).json({ error: 'Archivo requerido' });
|
||||||
|
const setId = req.body?.set_id ? Number(req.body.set_id) : null;
|
||||||
|
const result = await importQuestionsExcel(req.file.buffer, req.tenantPool, setId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/question-sets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await listQuestionSets(req.tenantPool);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/question-sets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, empty } = req.body;
|
||||||
|
const result = empty ? await createQuestionSetEmpty(req.tenantPool, name) : await createQuestionSetFromCurrent(req.tenantPool, name);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/question-sets/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await req.tenantPool.query('SELECT id, code, is_active FROM question_sets WHERE id = ?', [req.params.id]);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'Set no encontrado' });
|
||||||
|
const setRow = rows[0];
|
||||||
|
if (setRow.is_active) {
|
||||||
|
return res.status(400).json({ error: 'No se puede eliminar un set activo' });
|
||||||
|
}
|
||||||
|
const [[{ used }]] = await req.tenantPool.query('SELECT COUNT(*) AS used FROM survey_periods WHERE question_set_code = ?', [setRow.code]);
|
||||||
|
if (used > 0) {
|
||||||
|
return res.status(400).json({ error: 'No se puede eliminar: ya tiene mediciones' });
|
||||||
|
}
|
||||||
|
await req.tenantPool.query('DELETE FROM question_set_questions WHERE question_set_id = ?', [setRow.id]);
|
||||||
|
await req.tenantPool.query('DELETE FROM question_sets WHERE id = ?', [setRow.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/question-sets/:id/questions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await req.tenantPool.query(
|
||||||
|
'SELECT qs.question_code, qs.text_snapshot, d.name AS dimension FROM question_set_questions qs JOIN dimensions d ON d.id = qs.dimension_id WHERE qs.question_set_id = ? ORDER BY qs.sort_order',
|
||||||
|
[req.params.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/dimensions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await listDimensions(req.tenantPool);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/dimensions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, code, description } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Nombre requerido' });
|
||||||
|
const result = await createDimension(req.tenantPool, { name, code, description });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/question-sets/:id/questions', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { dimension_id, text } = req.body;
|
||||||
|
if (!dimension_id || !text) return res.status(400).json({ error: 'dimension_id y text requeridos' });
|
||||||
|
const result = await addQuestionToSet(req.tenantPool, { questionSetId: req.params.id, dimensionId: dimension_id, text });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { getCentralPool } = require('../db/central');
|
||||||
|
const { resolveTenant } = require('../middleware/tenant');
|
||||||
|
const { comparePassword } = require('../utils/password');
|
||||||
|
const { signToken } = require('../utils/jwt');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/login', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, password, role, company_code } = req.body;
|
||||||
|
if (!email || !password || !role) {
|
||||||
|
return res.status(400).json({ error: 'email, password y role requeridos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'superadmin') {
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [rows] = await central.query('SELECT * FROM superadmins WHERE email = ? AND status = "active"', [email]);
|
||||||
|
if (!rows.length) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||||
|
const ok = await comparePassword(password, rows[0].password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||||
|
|
||||||
|
const token = signToken({ id: rows[0].id, role: 'superadmin' });
|
||||||
|
return res.json({ token, role: 'superadmin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!company_code) return res.status(400).json({ error: 'company_code requerido' });
|
||||||
|
|
||||||
|
req.body.company_code = company_code;
|
||||||
|
await resolveTenant(req, res, async (err) => {
|
||||||
|
if (err) return next(err);
|
||||||
|
const pool = req.tenantPool;
|
||||||
|
const table = role === 'admin' ? 'admins' : 'users';
|
||||||
|
const [rows] = await pool.query(`SELECT * FROM ${table} WHERE email = ? AND status = 'active'`, [email]);
|
||||||
|
if (!rows.length) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||||
|
const ok = await comparePassword(password, rows[0].password_hash);
|
||||||
|
if (!ok) return res.status(401).json({ error: 'Credenciales inválidas' });
|
||||||
|
|
||||||
|
const token = signToken({ id: rows[0].id, role, company_code });
|
||||||
|
return res.json({ token, role, company_code });
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authRequired } = require('../middleware/auth');
|
||||||
|
const { requireRole } = require('../middleware/rbac');
|
||||||
|
const { resolveTenant } = require('../middleware/tenant');
|
||||||
|
const { generatePdfReport, generateExcelReport } = require('../services/reportService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/pdf/:surveyPeriodId', authRequired, requireRole('admin'), resolveTenant, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const buffer = await generatePdfReport(req.tenantPool, req.params.surveyPeriodId, req.company.name);
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="reporte.pdf"');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/excel/:surveyPeriodId', authRequired, requireRole('admin'), resolveTenant, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const buffer = await generateExcelReport(req.tenantPool, req.params.surveyPeriodId);
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="reporte.xlsx"');
|
||||||
|
res.send(buffer);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authRequired } = require('../middleware/auth');
|
||||||
|
const { requireRole } = require('../middleware/rbac');
|
||||||
|
const { getCentralPool } = require('../db/central');
|
||||||
|
const { provisionTenant } = require('../services/tenantProvision');
|
||||||
|
const { getTenantPool } = require('../db/tenant');
|
||||||
|
const { hashPassword } = require('../utils/password');
|
||||||
|
const { getParticipationSummary } = require('../services/participationService');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/companies', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [rows] = await central.query('SELECT id, name, code, status, start_date, end_date, db_host, db_port, db_name, created_at FROM companies ORDER BY id DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/companies', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, code, start_date, end_date, db_name, db_host, db_port, db_user, db_pass, admin_name, admin_email, admin_password } = req.body;
|
||||||
|
if (!name || !code || !db_name || !db_host || !db_user || !db_pass || !admin_name || !admin_email || !admin_password) {
|
||||||
|
return res.status(400).json({ error: 'Faltan campos requeridos' });
|
||||||
|
}
|
||||||
|
if (!start_date || !end_date) {
|
||||||
|
return res.status(400).json({ error: 'Fecha inicio y fin son obligatorias' });
|
||||||
|
}
|
||||||
|
if (start_date > end_date) {
|
||||||
|
return res.status(400).json({ error: 'Fecha inicio no puede ser mayor que fecha fin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await provisionTenant({
|
||||||
|
name,
|
||||||
|
code,
|
||||||
|
dbName: db_name,
|
||||||
|
dbHost: db_host,
|
||||||
|
dbPort: db_port || 3306,
|
||||||
|
dbUser: db_user,
|
||||||
|
dbPass: db_pass,
|
||||||
|
adminName: admin_name,
|
||||||
|
adminEmail: admin_email,
|
||||||
|
adminPassword: admin_password,
|
||||||
|
startDate: start_date,
|
||||||
|
endDate: end_date
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/companies/:id/status', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!['active', 'suspended'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Estado inválido' });
|
||||||
|
}
|
||||||
|
const central = getCentralPool();
|
||||||
|
await central.query('UPDATE companies SET status = ? WHERE id = ?', [status, req.params.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/companies/:id/dates', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { start_date, end_date } = req.body;
|
||||||
|
if (!start_date || !end_date) {
|
||||||
|
return res.status(400).json({ error: 'Fecha inicio y fin son obligatorias' });
|
||||||
|
}
|
||||||
|
if (start_date > end_date) {
|
||||||
|
return res.status(400).json({ error: 'Fecha inicio no puede ser mayor que fecha fin' });
|
||||||
|
}
|
||||||
|
const central = getCentralPool();
|
||||||
|
await central.query('UPDATE companies SET start_date = ?, end_date = ? WHERE id = ?', [
|
||||||
|
start_date || null,
|
||||||
|
end_date || null,
|
||||||
|
req.params.id
|
||||||
|
]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/companies/:id/participation', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||||
|
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||||
|
const tenantPool = getTenantPool(company);
|
||||||
|
const participation = await getParticipationSummary(tenantPool);
|
||||||
|
res.json({ company_id: company.id, participation });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/companies/:id', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||||
|
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||||
|
|
||||||
|
const tenantPool = getTenantPool(company);
|
||||||
|
const [[{ periods }]] = await tenantPool.query('SELECT COUNT(*) AS periods FROM survey_periods');
|
||||||
|
const [[{ responses }]] = await tenantPool.query('SELECT COUNT(*) AS responses FROM responses');
|
||||||
|
|
||||||
|
if (periods > 0 || responses > 0) {
|
||||||
|
return res.status(400).json({ error: 'No se puede eliminar: existen encuestas registradas' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await central.query('DROP DATABASE IF EXISTS `' + company.db_name + '`');
|
||||||
|
await central.query('DELETE FROM companies WHERE id = ?', [company.id]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/companies/:id/admins', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, email, password } = req.body;
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Nombre, email y password requeridos' });
|
||||||
|
}
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||||
|
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||||
|
|
||||||
|
const tenantPool = getTenantPool(company);
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await tenantPool.query(
|
||||||
|
'INSERT INTO admins (name, email, password_hash, role, status) VALUES (?, ?, ?, "admin", "active")',
|
||||||
|
[name, email, passwordHash]
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/companies/:id/admins', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||||
|
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||||
|
|
||||||
|
const tenantPool = getTenantPool(company);
|
||||||
|
const [rows] = await tenantPool.query('SELECT id, name, email, status, created_at FROM admins ORDER BY id DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/companies/:id/admins/:adminId/status', authRequired, requireRole('superadmin'), async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { status } = req.body;
|
||||||
|
if (!['active', 'inactive'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Estado inválido' });
|
||||||
|
}
|
||||||
|
const central = getCentralPool();
|
||||||
|
const [[company]] = await central.query('SELECT * FROM companies WHERE id = ?', [req.params.id]);
|
||||||
|
if (!company) return res.status(404).json({ error: 'Empresa no encontrada' });
|
||||||
|
|
||||||
|
const tenantPool = getTenantPool(company);
|
||||||
|
await tenantPool.query('UPDATE admins SET status = ? WHERE id = ?', [status, req.params.adminId]);
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { authRequired } = require('../middleware/auth');
|
||||||
|
const { resolveTenant } = require('../middleware/tenant');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/active', resolveTenant, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[period]] = await req.tenantPool.query('SELECT * FROM survey_periods WHERE estado = "activa" ORDER BY id DESC LIMIT 1');
|
||||||
|
if (!period) return res.json({ period: null, questions: [] });
|
||||||
|
|
||||||
|
const [questions] = await req.tenantPool.query(
|
||||||
|
'SELECT sq.question_id, sq.question_code, sq.text_snapshot AS text, d.name AS dimension FROM survey_questions sq JOIN dimensions d ON d.id = sq.dimension_id WHERE sq.survey_period_id = ? ORDER BY sq.sort_order ASC',
|
||||||
|
[period.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ period, questions });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/responses', authRequired, resolveTenant, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { survey_period_id, answers } = req.body;
|
||||||
|
if (!survey_period_id || !Array.isArray(answers)) {
|
||||||
|
return res.status(400).json({ error: 'survey_period_id y answers requeridos' });
|
||||||
|
}
|
||||||
|
const [[{ total_questions }]] = await req.tenantPool.query(
|
||||||
|
'SELECT COUNT(*) AS total_questions FROM survey_questions WHERE survey_period_id = ?',
|
||||||
|
[survey_period_id]
|
||||||
|
);
|
||||||
|
const uniqueIds = new Set(answers.map((a) => a.question_id));
|
||||||
|
if (uniqueIds.size !== total_questions) {
|
||||||
|
return res.status(400).json({ error: 'Resultados incompletos' });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ans of answers) {
|
||||||
|
if (![1,2,3,4,5].includes(Number(ans.value))) {
|
||||||
|
return res.status(400).json({ error: 'Valor inválido' });
|
||||||
|
}
|
||||||
|
const [[qrow]] = await req.tenantPool.query(
|
||||||
|
'SELECT question_code FROM survey_questions WHERE survey_period_id = ? AND question_id = ? LIMIT 1',
|
||||||
|
[survey_period_id, ans.question_id]
|
||||||
|
);
|
||||||
|
await req.tenantPool.query(
|
||||||
|
'INSERT INTO responses (survey_period_id, user_id, question_id, question_code, value) VALUES (?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE value = VALUES(value)',
|
||||||
|
[survey_period_id, req.user.id, ans.question_id, qrow?.question_code || '', ans.value]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const outDir = path.resolve(__dirname, '../../../templates');
|
||||||
|
fs.mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Usuarios');
|
||||||
|
sheet.addRow(['Nombre', 'Email', 'Departamento', 'Cargo']);
|
||||||
|
|
||||||
|
const outPath = path.join(outDir, 'plantilla_usuarios.xlsx');
|
||||||
|
await workbook.xlsx.writeFile(outPath);
|
||||||
|
console.log('Plantilla XLSX creada:', outPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const { getCentralPool } = require('../db/central');
|
||||||
|
const { hashPassword } = require('../utils/password');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const name = process.argv[2];
|
||||||
|
const email = process.argv[3];
|
||||||
|
const password = process.argv[4];
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
console.error('Uso: node seed_superadmin.js "Nombre" correo@example.com clave');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
const pool = getCentralPool();
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO superadmins (name, email, password_hash, status) VALUES (?, ?, ?, "active")',
|
||||||
|
[name, email, passwordHash]
|
||||||
|
);
|
||||||
|
console.log('Superadmin creado');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
const app = require('./app');
|
||||||
|
const { port } = require('./config/env');
|
||||||
|
|
||||||
|
app.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`API escuchando en puerto ${port}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
|
||||||
|
async function importUsersFromExcel(buffer, pool) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer);
|
||||||
|
const sheet = workbook.worksheets[0];
|
||||||
|
const errors = [];
|
||||||
|
const inserted = [];
|
||||||
|
|
||||||
|
if (!sheet) return { errors: ['No hay hoja en el archivo'], inserted: 0 };
|
||||||
|
|
||||||
|
sheet.eachRow((row, rowNumber) => {
|
||||||
|
if (rowNumber === 1) return;
|
||||||
|
const name = String(row.getCell(1).value || '').trim();
|
||||||
|
const email = String(row.getCell(2).value || '').trim();
|
||||||
|
const department = String(row.getCell(3).value || '').trim();
|
||||||
|
const position = String(row.getCell(4).value || '').trim();
|
||||||
|
|
||||||
|
if (!name || !email) {
|
||||||
|
errors.push(`Fila ${rowNumber}: nombre y email son obligatorios`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted.push({ name, email, department, position });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const user of inserted) {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO users (name, email, department, position, status) VALUES (?, ?, ?, ?, "active")',
|
||||||
|
[user.name, user.email, user.department, user.position]
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push(`Usuario ${user.email}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { inserted: inserted.length - errors.length, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { importUsersFromExcel };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
async function getLatestSurveyPeriodId(pool) {
|
||||||
|
const [[active]] = await pool.query(
|
||||||
|
'SELECT id FROM survey_periods WHERE estado = "activa" ORDER BY id DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
if (active && active.id) return active.id;
|
||||||
|
const [[closed]] = await pool.query(
|
||||||
|
'SELECT id FROM survey_periods WHERE estado = "cerrada" ORDER BY id DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
return closed ? closed.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getParticipationSummary(pool) {
|
||||||
|
const [[{ total_users }]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS total_users FROM users WHERE status = "active"'
|
||||||
|
);
|
||||||
|
const surveyPeriodId = await getLatestSurveyPeriodId(pool);
|
||||||
|
if (!surveyPeriodId) {
|
||||||
|
return {
|
||||||
|
survey_period_id: null,
|
||||||
|
total_users,
|
||||||
|
total_questions: 0,
|
||||||
|
completed_users: 0,
|
||||||
|
partial_users: 0,
|
||||||
|
started_users: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[{ total_questions }]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS total_questions FROM survey_questions WHERE survey_period_id = ?',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!total_questions) {
|
||||||
|
return {
|
||||||
|
survey_period_id: surveyPeriodId,
|
||||||
|
total_users,
|
||||||
|
total_questions: 0,
|
||||||
|
completed_users: 0,
|
||||||
|
partial_users: 0,
|
||||||
|
started_users: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[{ started_users }]] = await pool.query(
|
||||||
|
'SELECT COUNT(DISTINCT user_id) AS started_users FROM responses WHERE survey_period_id = ?',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[{ completed_users }]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS completed_users FROM (SELECT user_id, COUNT(*) c FROM responses WHERE survey_period_id = ? GROUP BY user_id HAVING c = ?) t',
|
||||||
|
[surveyPeriodId, total_questions]
|
||||||
|
);
|
||||||
|
|
||||||
|
const [[{ partial_users }]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS partial_users FROM (SELECT user_id, COUNT(*) c FROM responses WHERE survey_period_id = ? GROUP BY user_id HAVING c > 0 AND c < ?) t',
|
||||||
|
[surveyPeriodId, total_questions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
survey_period_id: surveyPeriodId,
|
||||||
|
total_users,
|
||||||
|
total_questions,
|
||||||
|
completed_users,
|
||||||
|
partial_users,
|
||||||
|
started_users
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getParticipationSummary };
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
const { createQuestionSet, createEmptyQuestionSet, getActiveQuestionSet } = require('./questionSetService');
|
||||||
|
|
||||||
|
async function listQuestions(pool) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT q.id, q.code, q.text, q.is_active, d.name AS dimension, d.code AS dimension_code FROM questions q JOIN dimensions d ON d.id = q.dimension_id ORDER BY d.sort_order, q.id'
|
||||||
|
);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listQuestionSets(pool) {
|
||||||
|
const [rows] = await pool.query('SELECT id, code, name, version, is_active, created_at FROM question_sets ORDER BY id DESC');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listDimensions(pool) {
|
||||||
|
const [rows] = await pool.query('SELECT id, name, code, description, sort_order FROM dimensions ORDER BY sort_order, id');
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createDimension(pool, { name, code, description }) {
|
||||||
|
const finalCode = code || `DIM-${Date.now()}`;
|
||||||
|
const [res] = await pool.query(
|
||||||
|
'INSERT INTO dimensions (name, code, description, sort_order) VALUES (?, ?, ?, ?)',
|
||||||
|
[name, finalCode, description || null, 0]
|
||||||
|
);
|
||||||
|
return { id: res.insertId, code: finalCode, name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportQuestionsExcel(pool) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Preguntas');
|
||||||
|
sheet.addRow(['question_code', 'dimension_code', 'dimension_name', 'text', 'is_active']);
|
||||||
|
|
||||||
|
const rows = await listQuestions(pool);
|
||||||
|
rows.forEach((r) => {
|
||||||
|
sheet.addRow([r.code, r.dimension_code, r.dimension, r.text, r.is_active]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportQuestionsTemplate() {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const sheet = workbook.addWorksheet('Preguntas');
|
||||||
|
sheet.addRow(['question_code', 'dimension_code', 'dimension_name', 'text', 'is_active']);
|
||||||
|
sheet.addRow(['Q-001', 'DIM-LID', 'Liderazgo', 'Ejemplo de pregunta', 1]);
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importQuestionsExcel(buffer, pool, setId) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
await workbook.xlsx.load(buffer);
|
||||||
|
const sheet = workbook.worksheets[0];
|
||||||
|
const errors = [];
|
||||||
|
let processed = 0;
|
||||||
|
const setSortOrder = new Map();
|
||||||
|
|
||||||
|
if (!sheet) return { processed, errors: ['No hay hoja en el archivo'] };
|
||||||
|
|
||||||
|
const dimMap = new Map();
|
||||||
|
const [dims] = await pool.query('SELECT id, code FROM dimensions');
|
||||||
|
dims.forEach((d) => dimMap.set(String(d.code).trim(), d.id));
|
||||||
|
|
||||||
|
for (let i = 2; i <= sheet.rowCount; i++) {
|
||||||
|
const row = sheet.getRow(i);
|
||||||
|
const code = String(row.getCell(1).value || '').trim();
|
||||||
|
const dimCode = String(row.getCell(2).value || '').trim();
|
||||||
|
const text = String(row.getCell(4).value || '').trim();
|
||||||
|
const isActiveRaw = row.getCell(5).value;
|
||||||
|
const isActive = String(isActiveRaw || '1').trim() === '0' ? 0 : 1;
|
||||||
|
|
||||||
|
if (!code || !dimCode || !text) {
|
||||||
|
errors.push(`Fila ${i}: code, dimension_code y text son obligatorios`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dimId = dimMap.get(dimCode);
|
||||||
|
if (!dimId) {
|
||||||
|
errors.push(`Fila ${i}: dimension_code inválido (${dimCode})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[existing]] = await pool.query('SELECT id, text FROM questions WHERE code = ?', [code]);
|
||||||
|
let questionId = existing?.id;
|
||||||
|
if (!existing) {
|
||||||
|
const [res] = await pool.query(
|
||||||
|
'INSERT INTO questions (dimension_id, code, text, is_active) VALUES (?, ?, ?, ?)',
|
||||||
|
[dimId, code, text, isActive]
|
||||||
|
);
|
||||||
|
questionId = res.insertId;
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, 1, ?, 1)',
|
||||||
|
[questionId, text]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (existing.text !== text) {
|
||||||
|
const [[ver]] = await pool.query('SELECT MAX(version) AS v FROM question_versions WHERE question_id = ?', [existing.id]);
|
||||||
|
const nextVer = (ver?.v || 0) + 1;
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, ?, ?, 1)',
|
||||||
|
[existing.id, nextVer, text]
|
||||||
|
);
|
||||||
|
await pool.query('UPDATE questions SET text = ?, is_active = ? WHERE id = ?', [text, isActive, existing.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (setId && questionId) {
|
||||||
|
const [[{ existsInSet }]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS existsInSet FROM question_set_questions WHERE question_set_id = ? AND question_id = ?',
|
||||||
|
[setId, questionId]
|
||||||
|
);
|
||||||
|
if (!existsInSet) {
|
||||||
|
let currentOrder = setSortOrder.get(setId);
|
||||||
|
if (!currentOrder) {
|
||||||
|
const [[{ maxOrder }]] = await pool.query(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), 0) AS maxOrder FROM question_set_questions WHERE question_set_id = ?',
|
||||||
|
[setId]
|
||||||
|
);
|
||||||
|
currentOrder = maxOrder || 0;
|
||||||
|
}
|
||||||
|
currentOrder += 1;
|
||||||
|
setSortOrder.set(setId, currentOrder);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||||
|
[setId, questionId, code, dimId, text, currentOrder]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processed += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processed, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createQuestionSetFromCurrent(pool, name) {
|
||||||
|
await getActiveQuestionSet(pool);
|
||||||
|
return createQuestionSet(pool, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createQuestionSetEmpty(pool, name) {
|
||||||
|
return createEmptyQuestionSet(pool, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addQuestionToSet(pool, { questionSetId, dimensionId, text }) {
|
||||||
|
const code = `Q-${Date.now()}`;
|
||||||
|
const [qRes] = await pool.query(
|
||||||
|
'INSERT INTO questions (dimension_id, code, text, is_active) VALUES (?, ?, ?, 1)',
|
||||||
|
[dimensionId, code, text]
|
||||||
|
);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_versions (question_id, version, text, is_active) VALUES (?, 1, ?, 1)',
|
||||||
|
[qRes.insertId, text]
|
||||||
|
);
|
||||||
|
const [[{ maxOrder }]] = await pool.query(
|
||||||
|
'SELECT COALESCE(MAX(sort_order), 0) AS maxOrder FROM question_set_questions WHERE question_set_id = ?',
|
||||||
|
[questionSetId]
|
||||||
|
);
|
||||||
|
const sortOrder = (maxOrder || 0) + 1;
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||||
|
[questionSetId, qRes.insertId, code, dimensionId, text, sortOrder]
|
||||||
|
);
|
||||||
|
return { question_id: qRes.insertId, code, sort_order: sortOrder };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listQuestions,
|
||||||
|
listQuestionSets,
|
||||||
|
listDimensions,
|
||||||
|
createDimension,
|
||||||
|
exportQuestionsExcel,
|
||||||
|
exportQuestionsTemplate,
|
||||||
|
importQuestionsExcel,
|
||||||
|
createQuestionSetFromCurrent,
|
||||||
|
createQuestionSetEmpty,
|
||||||
|
addQuestionToSet
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
const dayjs = require('dayjs');
|
||||||
|
|
||||||
|
async function ensureDefaultQuestionSet(pool) {
|
||||||
|
const [[exists]] = await pool.query('SELECT id, code FROM question_sets WHERE is_active = 1 ORDER BY id DESC LIMIT 1');
|
||||||
|
if (exists && exists.id) return exists;
|
||||||
|
|
||||||
|
const code = `QSET-${dayjs().format('YYYYMMDD')}-01`;
|
||||||
|
const name = `Set ${dayjs().format('YYYY-MM-DD')}`;
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, 1, 1)',
|
||||||
|
[code, name]
|
||||||
|
);
|
||||||
|
const questionSetId = result.insertId;
|
||||||
|
const [questions] = await pool.query('SELECT id, code, dimension_id, text FROM questions WHERE is_active = 1 ORDER BY id ASC');
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, 1, ?)',
|
||||||
|
[questionSetId, q.id, q.code, q.dimension_id, q.text, i + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { id: questionSetId, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createQuestionSet(pool, name) {
|
||||||
|
const [[latest]] = await pool.query('SELECT version FROM question_sets ORDER BY id DESC LIMIT 1');
|
||||||
|
const version = (latest?.version || 0) + 1;
|
||||||
|
const code = `QSET-${dayjs().format('YYYYMMDD')}-${String(version).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await pool.query('UPDATE question_sets SET is_active = 0');
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, ?, 1)',
|
||||||
|
[code, name || `Set ${dayjs().format('YYYY-MM-DD')}`, version]
|
||||||
|
);
|
||||||
|
const questionSetId = result.insertId;
|
||||||
|
|
||||||
|
const [questions] = await pool.query('SELECT id, code, dimension_id, text FROM questions WHERE is_active = 1 ORDER BY id ASC');
|
||||||
|
for (let i = 0; i < questions.length; i++) {
|
||||||
|
const q = questions[i];
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[questionSetId, q.id, q.code, q.dimension_id, q.text, version, i + 1]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: questionSetId, code, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEmptyQuestionSet(pool, name) {
|
||||||
|
const [[latest]] = await pool.query('SELECT version FROM question_sets ORDER BY id DESC LIMIT 1');
|
||||||
|
const version = (latest?.version || 0) + 1;
|
||||||
|
const code = `QSET-${dayjs().format('YYYYMMDD')}-${String(version).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
await pool.query('UPDATE question_sets SET is_active = 0');
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO question_sets (code, name, version, is_active) VALUES (?, ?, ?, 1)',
|
||||||
|
[code, name || `Set ${dayjs().format('YYYY-MM-DD')}`, version]
|
||||||
|
);
|
||||||
|
return { id: result.insertId, code, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getActiveQuestionSet(pool) {
|
||||||
|
const [[active]] = await pool.query('SELECT id, code FROM question_sets WHERE is_active = 1 ORDER BY id DESC LIMIT 1');
|
||||||
|
if (active && active.id) return active;
|
||||||
|
return ensureDefaultQuestionSet(pool);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { ensureDefaultQuestionSet, createQuestionSet, createEmptyQuestionSet, getActiveQuestionSet };
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const ExcelJS = require('exceljs');
|
||||||
|
|
||||||
|
async function generatePdfReport(pool, surveyPeriodId, companyName, logoPath) {
|
||||||
|
const [[period]] = await pool.query('SELECT * FROM survey_periods WHERE id = ?', [surveyPeriodId]);
|
||||||
|
const [dimensions] = await pool.query(
|
||||||
|
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.survey_period_id = ? AND r.scope = "dimension"',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
const [[global]] = await pool.query(
|
||||||
|
'SELECT score, classification, participation_rate FROM survey_results WHERE survey_period_id = ? AND scope = "global"',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const doc = new PDFDocument({ margin: 50 });
|
||||||
|
const chunks = [];
|
||||||
|
doc.on('data', (c) => chunks.push(c));
|
||||||
|
|
||||||
|
if (logoPath) {
|
||||||
|
try { doc.image(logoPath, 50, 40, { width: 80 }); } catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.fontSize(18).text('Reporte de Clima Laboral', 150, 50);
|
||||||
|
doc.moveDown();
|
||||||
|
doc.fontSize(12).text(`Empresa: ${companyName || '-'}`);
|
||||||
|
doc.text(`Código medición: ${period.codigo_medicion}`);
|
||||||
|
doc.text(`Periodo: ${period.fecha_inicio} a ${period.fecha_fin}`);
|
||||||
|
doc.text(`Participación: ${global ? global.participation_rate : 0}%`);
|
||||||
|
|
||||||
|
doc.moveDown();
|
||||||
|
doc.fontSize(14).text('Resumen Ejecutivo');
|
||||||
|
doc.fontSize(11).text('El índice global refleja el nivel general de clima laboral. Se recomienda revisar dimensiones con puntajes bajos para generar planes de acción.');
|
||||||
|
|
||||||
|
doc.moveDown();
|
||||||
|
doc.fontSize(14).text('Resultados por Dimensión');
|
||||||
|
dimensions.forEach((d) => {
|
||||||
|
doc.fontSize(11).text(`${d.name}: ${d.score} (${d.classification})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateExcelReport(pool, surveyPeriodId) {
|
||||||
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const rawSheet = workbook.addWorksheet('Respuestas');
|
||||||
|
const dimSheet = workbook.addWorksheet('Dimensiones');
|
||||||
|
|
||||||
|
rawSheet.addRow(['Usuario', 'Pregunta', 'Valor']);
|
||||||
|
const [rawRows] = await pool.query(
|
||||||
|
'SELECT u.email, q.text, r.value FROM responses r JOIN users u ON u.id = r.user_id JOIN questions q ON q.id = r.question_id WHERE r.survey_period_id = ?',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
rawRows.forEach((r) => rawSheet.addRow([r.email, r.text, r.value]));
|
||||||
|
|
||||||
|
dimSheet.addRow(['Dimensión', 'Puntaje', 'Clasificación']);
|
||||||
|
const [dimRows] = await pool.query(
|
||||||
|
'SELECT d.name, r.score, r.classification FROM survey_results r JOIN dimensions d ON d.id = r.dimension_id WHERE r.survey_period_id = ? AND r.scope = "dimension"',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
dimRows.forEach((r) => dimSheet.addRow([r.name, r.score, r.classification]));
|
||||||
|
|
||||||
|
return workbook.xlsx.writeBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generatePdfReport, generateExcelReport };
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
function scoreFromAvg(avg) {
|
||||||
|
if (avg === null || avg === undefined) return 0;
|
||||||
|
return ((avg - 1) / 4) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classify(score) {
|
||||||
|
if (score < 60) return 'Bajo';
|
||||||
|
if (score < 80) return 'Medio';
|
||||||
|
return 'Alto';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calculateResults(pool, surveyPeriodId) {
|
||||||
|
const [[{ total_users }]] = await pool.query('SELECT COUNT(*) AS total_users FROM users WHERE status = "active"');
|
||||||
|
const [[{ participants }]] = await pool.query(
|
||||||
|
'SELECT COUNT(DISTINCT user_id) AS participants FROM responses WHERE survey_period_id = ?',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
const participationRate = total_users ? (participants / total_users) * 100 : 0;
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM survey_results WHERE survey_period_id = ?', [surveyPeriodId]);
|
||||||
|
|
||||||
|
const [questionRows] = await pool.query(
|
||||||
|
'SELECT question_id, AVG(value) AS avg_val, COUNT(*) AS responses FROM responses WHERE survey_period_id = ? GROUP BY question_id',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of questionRows) {
|
||||||
|
const score = scoreFromAvg(row.avg_val);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO survey_results (survey_period_id, scope, question_id, score, classification, response_count, participation_rate) VALUES (?, "question", ?, ?, ?, ?, ?)',
|
||||||
|
[surveyPeriodId, row.question_id, score.toFixed(2), classify(score), row.responses, participationRate.toFixed(2)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [dimensionRows] = await pool.query(
|
||||||
|
'SELECT q.dimension_id, AVG(r.value) AS avg_val, COUNT(*) AS responses FROM responses r JOIN questions q ON q.id = r.question_id WHERE r.survey_period_id = ? GROUP BY q.dimension_id',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of dimensionRows) {
|
||||||
|
const score = scoreFromAvg(row.avg_val);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO survey_results (survey_period_id, scope, dimension_id, score, classification, response_count, participation_rate) VALUES (?, "dimension", ?, ?, ?, ?, ?)',
|
||||||
|
[surveyPeriodId, row.dimension_id, score.toFixed(2), classify(score), row.responses, participationRate.toFixed(2)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[globalRow]] = await pool.query(
|
||||||
|
'SELECT AVG(value) AS avg_val, COUNT(*) AS responses FROM responses WHERE survey_period_id = ?',
|
||||||
|
[surveyPeriodId]
|
||||||
|
);
|
||||||
|
const globalScore = scoreFromAvg(globalRow.avg_val || 0);
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO survey_results (survey_period_id, scope, score, classification, response_count, participation_rate) VALUES (?, "global", ?, ?, ?, ?)',
|
||||||
|
[surveyPeriodId, globalScore.toFixed(2), classify(globalScore), globalRow.responses || 0, participationRate.toFixed(2)]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { participationRate, globalScore };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { calculateResults, classify };
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const dayjs = require('dayjs');
|
||||||
|
const { calculateResults } = require('./resultService');
|
||||||
|
const { getActiveQuestionSet } = require('./questionSetService');
|
||||||
|
|
||||||
|
async function createSurveyPeriod(pool, { year, month, fecha_inicio, fecha_fin }) {
|
||||||
|
const code = `CLIMA-${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
const activeSet = await getActiveQuestionSet(pool);
|
||||||
|
const [result] = await pool.query(
|
||||||
|
'INSERT INTO survey_periods (codigo_medicion, question_set_code, year, month, fecha_inicio, fecha_fin, estado) VALUES (?, ?, ?, ?, ?, ?, "borrador")',
|
||||||
|
[code, activeSet.code, year, month, fecha_inicio, fecha_fin]
|
||||||
|
);
|
||||||
|
|
||||||
|
const surveyPeriodId = result.insertId;
|
||||||
|
const [setQuestions] = await pool.query(
|
||||||
|
'SELECT question_id, question_code, dimension_id, text_snapshot, sort_order FROM question_set_questions WHERE question_set_id = ? ORDER BY sort_order ASC',
|
||||||
|
[activeSet.id]
|
||||||
|
);
|
||||||
|
for (const sq of setQuestions) {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO survey_questions (survey_period_id, question_id, question_code, dimension_id, text_snapshot, sort_order, question_set_code) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[surveyPeriodId, sq.question_id, sq.question_code, sq.dimension_id, sq.text_snapshot, sq.sort_order, activeSet.code]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: surveyPeriodId, code };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateSurvey(pool, surveyPeriodId) {
|
||||||
|
await pool.query('UPDATE survey_periods SET estado = "activa" WHERE id = ?', [surveyPeriodId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSurvey(pool, surveyPeriodId) {
|
||||||
|
await pool.query('UPDATE survey_periods SET estado = "cerrada", fecha_cierre = ? WHERE id = ?', [dayjs().format('YYYY-MM-DD HH:mm:ss'), surveyPeriodId]);
|
||||||
|
return calculateResults(pool, surveyPeriodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createSurveyPeriod, activateSurvey, closeSurvey };
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { getCentralPool } = require('../db/central');
|
||||||
|
const { hashPassword } = require('../utils/password');
|
||||||
|
|
||||||
|
function splitSql(sql) {
|
||||||
|
return sql
|
||||||
|
.split(/;\s*\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSqlStatements(pool, sqlText) {
|
||||||
|
const statements = splitSql(sqlText);
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await pool.query(stmt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function provisionTenant({ name, code, dbName, dbHost, dbPort, dbUser, dbPass, adminName, adminEmail, adminPassword, startDate, endDate }) {
|
||||||
|
const central = getCentralPool();
|
||||||
|
|
||||||
|
await central.query(
|
||||||
|
'INSERT INTO companies (name, code, start_date, end_date, db_host, db_port, db_name, db_user, db_pass) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[name, code, startDate || null, endDate || null, dbHost, dbPort, dbName, dbUser, dbPass]
|
||||||
|
);
|
||||||
|
|
||||||
|
await central.query(`CREATE DATABASE IF NOT EXISTS \`${dbName}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci`);
|
||||||
|
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const tenantPool = mysql.createPool({
|
||||||
|
host: dbHost,
|
||||||
|
port: dbPort,
|
||||||
|
user: dbUser,
|
||||||
|
password: dbPass,
|
||||||
|
database: dbName,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaSql = fs.readFileSync(path.join(__dirname, '../sql/tenant_schema.sql'), 'utf8');
|
||||||
|
const seedSql = fs.readFileSync(path.join(__dirname, '../sql/seed_questions.sql'), 'utf8');
|
||||||
|
|
||||||
|
await runSqlStatements(tenantPool, schemaSql);
|
||||||
|
await runSqlStatements(tenantPool, seedSql);
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(adminPassword);
|
||||||
|
await tenantPool.query(
|
||||||
|
'INSERT INTO admins (name, email, password_hash, role, status) VALUES (?, ?, ?, "admin", "active")',
|
||||||
|
[adminName, adminEmail, passwordHash]
|
||||||
|
);
|
||||||
|
|
||||||
|
await tenantPool.end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { provisionTenant };
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
CREATE DATABASE IF NOT EXISTS clima_platform CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
USE clima_platform;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS superadmins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS companies (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(160) NOT NULL,
|
||||||
|
code VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
status ENUM('active','suspended') NOT NULL DEFAULT 'active',
|
||||||
|
start_date DATE NULL,
|
||||||
|
end_date DATE NULL,
|
||||||
|
db_host VARCHAR(120) NOT NULL,
|
||||||
|
db_port INT NOT NULL DEFAULT 3306,
|
||||||
|
db_name VARCHAR(120) NOT NULL,
|
||||||
|
db_user VARCHAR(120) NOT NULL,
|
||||||
|
db_pass VARCHAR(120) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS error_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_role VARCHAR(40) NULL,
|
||||||
|
user_id INT NULL,
|
||||||
|
company_code VARCHAR(40) NULL,
|
||||||
|
menu VARCHAR(120) NULL,
|
||||||
|
error_message TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
INSERT INTO dimensions (name, code, description, sort_order) VALUES
|
||||||
|
('Liderazgo', 'LID', 'Estilo de dirección y apoyo del liderazgo', 1),
|
||||||
|
('Comunicación', 'COM', 'Flujo, claridad y oportunidad de la información', 2),
|
||||||
|
('Trabajo en equipo', 'TEA', 'Colaboración y apoyo entre áreas', 3),
|
||||||
|
('Reconocimiento', 'REC', 'Valoración del desempeño y esfuerzos', 4),
|
||||||
|
('Desarrollo profesional', 'DES', 'Oportunidades de crecimiento y aprendizaje', 5),
|
||||||
|
('Cultura organizacional', 'CUL', 'Valores compartidos y ambiente laboral', 6);
|
||||||
|
|
||||||
|
INSERT INTO scales (name, min_value, max_value, labels_json) VALUES
|
||||||
|
('Likert 1-5', 1, 5, '{"1":"Totalmente en desacuerdo","2":"En desacuerdo","3":"Ni de acuerdo ni en desacuerdo","4":"De acuerdo","5":"Totalmente de acuerdo"}');
|
||||||
|
|
||||||
|
INSERT INTO questions (dimension_id, code, text) VALUES
|
||||||
|
(1, 'LID-01', 'Mi líder comunica expectativas claras sobre mi trabajo.'),
|
||||||
|
(1, 'LID-02', 'Recibo apoyo oportuno de mi líder cuando lo necesito.'),
|
||||||
|
(1, 'LID-03', 'Las decisiones de liderazgo se perciben justas y consistentes.'),
|
||||||
|
(1, 'LID-04', 'Mi líder promueve un ambiente de confianza y respeto.'),
|
||||||
|
|
||||||
|
(2, 'COM-01', 'La información importante llega a tiempo para hacer bien mi trabajo.'),
|
||||||
|
(2, 'COM-02', 'La comunicación entre áreas es clara y efectiva.'),
|
||||||
|
(2, 'COM-03', 'Puedo expresar mis ideas y ser escuchado/a.'),
|
||||||
|
(2, 'COM-04', 'Los cambios relevantes se comunican de manera adecuada.'),
|
||||||
|
|
||||||
|
(3, 'TEA-01', 'En mi equipo se fomenta la colaboración para lograr objetivos.'),
|
||||||
|
(3, 'TEA-02', 'Mis compañeros/as están dispuestos a ayudar cuando se requiere.'),
|
||||||
|
(3, 'TEA-03', 'Existe coordinación efectiva entre roles dentro del equipo.'),
|
||||||
|
(3, 'TEA-04', 'Se resuelven los conflictos de forma constructiva.'),
|
||||||
|
|
||||||
|
(4, 'REC-01', 'Mi trabajo es reconocido de manera oportuna.'),
|
||||||
|
(4, 'REC-02', 'El reconocimiento recibido es coherente con mi desempeño.'),
|
||||||
|
(4, 'REC-03', 'La organización valora los esfuerzos adicionales.'),
|
||||||
|
(4, 'REC-04', 'Se celebran los logros del equipo.'),
|
||||||
|
|
||||||
|
(5, 'DES-01', 'Tengo oportunidades reales de crecimiento profesional.'),
|
||||||
|
(5, 'DES-02', 'Recibo retroalimentación que me ayuda a mejorar.'),
|
||||||
|
(5, 'DES-03', 'La organización promueve el aprendizaje continuo.'),
|
||||||
|
(5, 'DES-04', 'Dispongo de recursos para desarrollar nuevas habilidades.'),
|
||||||
|
|
||||||
|
(6, 'CUL-01', 'Me siento orgulloso/a de pertenecer a esta organización.'),
|
||||||
|
(6, 'CUL-02', 'Los valores de la empresa se reflejan en las decisiones diarias.'),
|
||||||
|
(6, 'CUL-03', 'El ambiente laboral es respetuoso e inclusivo.'),
|
||||||
|
(6, 'CUL-04', 'Se promueve el bienestar de las personas.');
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
SET FOREIGN_KEY_CHECKS=0;
|
||||||
|
SOURCE tenant_schema.sql;
|
||||||
|
SOURCE seed_questions.sql;
|
||||||
|
SET FOREIGN_KEY_CHECKS=1;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- Add columns
|
||||||
|
ALTER TABLE dimensions ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL;
|
||||||
|
ALTER TABLE questions ADD COLUMN IF NOT EXISTS code VARCHAR(60) NULL;
|
||||||
|
ALTER TABLE survey_periods ADD COLUMN IF NOT EXISTS question_set_code VARCHAR(60) NULL;
|
||||||
|
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS question_code VARCHAR(60) NULL;
|
||||||
|
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS text_snapshot VARCHAR(500) NULL;
|
||||||
|
ALTER TABLE survey_questions ADD COLUMN IF NOT EXISTS question_set_code VARCHAR(60) NULL;
|
||||||
|
ALTER TABLE responses ADD COLUMN IF NOT EXISTS question_code VARCHAR(60) NULL;
|
||||||
|
ALTER TABLE question_versions ADD COLUMN IF NOT EXISTS changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
-- Create new tables
|
||||||
|
CREATE TABLE IF NOT EXISTS question_sets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(60) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
version INT NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_set_questions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
question_set_id INT NOT NULL,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
question_code VARCHAR(60) NOT NULL,
|
||||||
|
dimension_id INT NOT NULL,
|
||||||
|
text_snapshot VARCHAR(500) NOT NULL,
|
||||||
|
version INT NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (question_set_id) REFERENCES question_sets(id),
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Populate dimension codes if empty
|
||||||
|
UPDATE dimensions SET code = 'LID' WHERE name='Liderazgo' AND (code IS NULL OR code='');
|
||||||
|
UPDATE dimensions SET code = 'COM' WHERE name='Comunicación' AND (code IS NULL OR code='');
|
||||||
|
UPDATE dimensions SET code = 'TEA' WHERE name='Trabajo en equipo' AND (code IS NULL OR code='');
|
||||||
|
UPDATE dimensions SET code = 'REC' WHERE name='Reconocimiento' AND (code IS NULL OR code='');
|
||||||
|
UPDATE dimensions SET code = 'DES' WHERE name='Desarrollo profesional' AND (code IS NULL OR code='');
|
||||||
|
UPDATE dimensions SET code = 'CUL' WHERE name='Cultura organizacional' AND (code IS NULL OR code='');
|
||||||
|
|
||||||
|
-- Populate question codes
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='LID' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='COM' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='TEA' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='REC' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='DES' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
SET @n:=0;
|
||||||
|
UPDATE questions q JOIN dimensions d ON d.id=q.dimension_id SET q.code = CONCAT(d.code, '-', LPAD((@n:=@n+1),2,'0')) WHERE d.code='CUL' AND (q.code IS NULL OR q.code='') ORDER BY q.id;
|
||||||
|
|
||||||
|
-- Default question set
|
||||||
|
INSERT INTO question_sets (code, name, version, is_active)
|
||||||
|
SELECT 'QSET-DEFAULT', 'Set inicial', 1, 1
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM question_sets);
|
||||||
|
|
||||||
|
SET @setId := (SELECT id FROM question_sets WHERE is_active=1 ORDER BY id DESC LIMIT 1);
|
||||||
|
|
||||||
|
INSERT INTO question_set_questions (question_set_id, question_id, question_code, dimension_id, text_snapshot, version, sort_order)
|
||||||
|
SELECT @setId, q.id, q.code, q.dimension_id, q.text, 1, q.id
|
||||||
|
FROM questions q
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM question_set_questions WHERE question_set_id=@setId);
|
||||||
|
|
||||||
|
-- Backfill survey_periods and survey_questions
|
||||||
|
UPDATE survey_periods SET question_set_code = 'QSET-DEFAULT' WHERE question_set_code IS NULL OR question_set_code='';
|
||||||
|
UPDATE survey_questions sq JOIN questions q ON q.id = sq.question_id SET sq.question_code = q.code, sq.text_snapshot = q.text, sq.question_set_code = 'QSET-DEFAULT' WHERE sq.question_code IS NULL OR sq.text_snapshot IS NULL OR sq.question_set_code IS NULL;
|
||||||
|
|
||||||
|
-- Backfill responses
|
||||||
|
UPDATE responses r JOIN questions q ON q.id=r.question_id SET r.question_code = q.code WHERE r.question_code IS NULL OR r.question_code='';
|
||||||
|
|
||||||
|
-- Add unique constraints if not exists (run separately if needed)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS admins (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role ENUM('admin') NOT NULL DEFAULT 'admin',
|
||||||
|
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
email VARCHAR(160) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NULL,
|
||||||
|
department VARCHAR(120),
|
||||||
|
position VARCHAR(120),
|
||||||
|
status ENUM('active','inactive') NOT NULL DEFAULT 'active',
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_periods (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
codigo_medicion VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
question_set_code VARCHAR(60) NOT NULL,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
fecha_inicio DATE NOT NULL,
|
||||||
|
fecha_fin DATE NOT NULL,
|
||||||
|
estado ENUM('borrador','activa','cerrada') NOT NULL DEFAULT 'borrador',
|
||||||
|
fecha_cierre DATETIME NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dimensions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
code VARCHAR(40) NOT NULL UNIQUE,
|
||||||
|
description VARCHAR(255),
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
dimension_id INT NOT NULL,
|
||||||
|
code VARCHAR(60) NOT NULL UNIQUE,
|
||||||
|
text VARCHAR(500) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_questions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
survey_period_id INT NOT NULL,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
question_code VARCHAR(60) NOT NULL,
|
||||||
|
dimension_id INT NOT NULL,
|
||||||
|
text_snapshot VARCHAR(500) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
question_set_code VARCHAR(60) NOT NULL,
|
||||||
|
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id),
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS responses (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
survey_period_id INT NOT NULL,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
question_code VARCHAR(60) NOT NULL,
|
||||||
|
value INT NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_response (survey_period_id, user_id, question_id),
|
||||||
|
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_results (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
survey_period_id INT NOT NULL,
|
||||||
|
scope ENUM('global','dimension','question') NOT NULL,
|
||||||
|
dimension_id INT NULL,
|
||||||
|
question_id INT NULL,
|
||||||
|
score DECIMAL(5,2) NOT NULL,
|
||||||
|
classification ENUM('Bajo','Medio','Alto') NOT NULL,
|
||||||
|
response_count INT NOT NULL DEFAULT 0,
|
||||||
|
participation_rate DECIMAL(5,2) NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (survey_period_id) REFERENCES survey_periods(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS scales (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
min_value INT NOT NULL,
|
||||||
|
max_value INT NOT NULL,
|
||||||
|
labels_json JSON NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_versions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
version INT NOT NULL,
|
||||||
|
text VARCHAR(500) NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_sets (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(60) NOT NULL UNIQUE,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
version INT NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_set_questions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
question_set_id INT NOT NULL,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
question_code VARCHAR(60) NOT NULL,
|
||||||
|
dimension_id INT NOT NULL,
|
||||||
|
text_snapshot VARCHAR(500) NOT NULL,
|
||||||
|
version INT NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (question_set_id) REFERENCES question_sets(id),
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id),
|
||||||
|
FOREIGN KEY (dimension_id) REFERENCES dimensions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS question_weights (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
question_id INT NOT NULL,
|
||||||
|
weight DECIMAL(5,2) NOT NULL DEFAULT 1,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
FOREIGN KEY (question_id) REFERENCES questions(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS metric_definitions (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
formula TEXT NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS survey_rules (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(120) NOT NULL,
|
||||||
|
rule_json JSON NOT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||||
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { jwtSecret } = require('../config/env');
|
||||||
|
|
||||||
|
function signToken(payload) {
|
||||||
|
return jwt.sign(payload, jwtSecret, { expiresIn: '8h' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyToken(token) {
|
||||||
|
return jwt.verify(token, jwtSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { signToken, verifyToken };
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
return bcrypt.hash(password, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function comparePassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { hashPassword, comparePassword };
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Continuación proyecto Clima Laboral SaaS (Resumen actualizado)
|
||||||
|
|
||||||
|
Proyecto en `/var/www/react/clima_laboral` con backend Node/Express y frontend React/Vite (sin TS). MariaDB multi-tenant: DB central `clima_platform` + DB por empresa (ej. `P123456`). Usuario DB correcto: `user_ia` (host `%`), pass `UserIA2026`, host DB `192.168.222.20:3306`.
|
||||||
|
|
||||||
|
## Backend (estado)
|
||||||
|
- Rutas admin:
|
||||||
|
- `/admin/questions`, `/admin/questions/export`, `/admin/questions/import`
|
||||||
|
- `/admin/questions/template` (descarga template Excel)
|
||||||
|
- `/admin/question-sets`, `/admin/question-sets` POST (`empty` flag)
|
||||||
|
- `/admin/question-sets/:id` DELETE (solo si inactivo y sin survey_periods)
|
||||||
|
- `/admin/question-sets/:id/questions` GET + POST (agregar pregunta a set)
|
||||||
|
- `/admin/dimensions` GET + POST
|
||||||
|
- Servicios:
|
||||||
|
- `questionSetService.js` agrega `createEmptyQuestionSet`
|
||||||
|
- `questionService.js` agrega `listDimensions`, `createDimension`, `addQuestionToSet`, `exportQuestionsTemplate`
|
||||||
|
- `importQuestionsExcel` ahora acepta `setId` para cargar preguntas dentro del set
|
||||||
|
- Error logging en `error_logs` para no superadmin.
|
||||||
|
|
||||||
|
## Frontend (UI/UX)
|
||||||
|
- Estilos globales ya cargados: `frontend/src/main.jsx` importa `./styles/app.css`.
|
||||||
|
- Menú superior usa `NavLink`; botón activo se resalta (`.topbar nav a.active`).
|
||||||
|
- Iconos Font Awesome (tamaño 16px en botones). Sin sesión no se ven iconos.
|
||||||
|
- Login: al iniciar sesión desaparece el formulario y muestra imagen.
|
||||||
|
- Popups globales de error.
|
||||||
|
- Anti‑spam de mensajes en `frontend/src/utils/api.js`.
|
||||||
|
- `/admin/dimensions` 404 ya no genera popup (silenciado).
|
||||||
|
- `QuestionBank.jsx`:
|
||||||
|
- Subnav: Versiones + botón Excel template + botón “+”.
|
||||||
|
- “+” abre vista Nuevo Set con nombre sugerido `QSET-YYYYMMDD-XX` basado en máximo existente.
|
||||||
|
- Vista Nuevo Set: crear set, importar Excel, crear ámbitos y agregar preguntas.
|
||||||
|
- Lista de preguntas se muestra en vivo en nuevo/editar.
|
||||||
|
- Vista Editar Set (botón en cada set) permite importar Excel, crear ámbitos y agregar preguntas manuales.
|
||||||
|
- Vista Abrir set muestra solo preguntas.
|
||||||
|
- Si no hay `company_code`, muestra mensaje “Selecciona una empresa...”.
|
||||||
|
- `QuestionBank` no rompe si `/admin/dimensions` falla.
|
||||||
|
|
||||||
|
## Estándares UI (acordados)
|
||||||
|
- Todos los botones son **solo iconos** (Font Awesome), sin texto visible.
|
||||||
|
- Tooltips visibles al hover (título del icono).
|
||||||
|
- Tamaño de icono estándar: **16px** en todos los botones.
|
||||||
|
- Iconos activos resaltados con color de acento.
|
||||||
|
- Menús ocultos cuando no hay sesión (sin token).
|
||||||
|
- Submenús con botones redondos iguales a los de Empresas.
|
||||||
|
- Formularios y listados separados por vista (no mezclar en la misma pantalla).
|
||||||
|
- Fechas visibles en formato `YYYY-MM-DD`.
|
||||||
|
|
||||||
|
## Datos actuales
|
||||||
|
- Empresa: `EMPRESA DE PRUEBA`, code `123456`, DB `P123456`, status active.
|
||||||
|
- Superadmin: `superadmin@demo.com` / `SuperAdmin2026`
|
||||||
|
- Admin: `admin.prueba@demo.com` / `Admin2026!`
|
||||||
|
- User: `usuario.prueba@demo.com` / `Usuario2026!`
|
||||||
|
- Question sets en DB `P123456`: `QSET-DEFAULT`, `QSET-20260303-03`, `QSET-20260303-04`, `QSET-20260303-05` (algunos activos/inactivos).
|
||||||
|
|
||||||
|
## Problemas frecuentes
|
||||||
|
- Si “Versiones de Encuesta” sale vacío, es porque no se entró con `company_code` (usar Superadmin > Empresas > Acceder).
|
||||||
|
- Si `/admin/dimensions` da 404, reiniciar backend para cargar rutas nuevas.
|
||||||
|
|
||||||
|
## Últimos cambios recientes
|
||||||
|
- Botón Excel Template movido arriba junto a “+”.
|
||||||
|
- Sugerencia de nombre de set corregida para usar máximo existente + 1.
|
||||||
|
- Vista Editar Set agregada con lista de preguntas y edición.
|
||||||
|
- Popup 404 silenciado para `/admin/dimensions`.
|
||||||
|
|
||||||
|
## Pendientes
|
||||||
|
- A gusto del usuario, agregar etiqueta “Escala 1–5” visible en encuestas/preguntas.
|
||||||
|
- Si necesita renombrar set o eliminar preguntas, implementar.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Clima Laboral SaaS</title>
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "clima-laboral-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
|
"react-router-dom": "^6.26.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.2",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import LoginPage from './pages/LoginPage.jsx';
|
||||||
|
import SuperadminCompanies from './pages/SuperadminCompanies.jsx';
|
||||||
|
import AdminDashboard from './pages/AdminDashboard.jsx';
|
||||||
|
import SurveyPeriods from './pages/SurveyPeriods.jsx';
|
||||||
|
import ImportUsers from './pages/ImportUsers.jsx';
|
||||||
|
import ParticipantSurvey from './pages/ParticipantSurvey.jsx';
|
||||||
|
import QuestionBank from './pages/QuestionBank.jsx';
|
||||||
|
import creatorLogo from './assets/creator-logo.png';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [notice, setNotice] = useState(null);
|
||||||
|
const [role, setRole] = useState(localStorage.getItem('role'));
|
||||||
|
const [token, setToken] = useState(localStorage.getItem('token'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e) => setNotice(e.detail);
|
||||||
|
window.addEventListener('app:notice', handler);
|
||||||
|
return () => window.removeEventListener('app:notice', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onStorage = () => setRole(localStorage.getItem('role'));
|
||||||
|
const onAuth = () => setRole(localStorage.getItem('role'));
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
window.addEventListener('auth:changed', onAuth);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
window.removeEventListener('auth:changed', onAuth);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onAuth = () => setToken(localStorage.getItem('token'));
|
||||||
|
window.addEventListener('auth:changed', onAuth);
|
||||||
|
return () => window.removeEventListener('auth:changed', onAuth);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const hasSession = !!token && !!role;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setRole(null);
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
const Guard = ({ allow, children }) => {
|
||||||
|
if (!allow) return <Navigate to="/" replace />;
|
||||||
|
return children;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="layout">
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="brand">
|
||||||
|
<img src={creatorLogo} alt="Empresa creadora" />
|
||||||
|
<h1>Clima Laboral SaaS</h1>
|
||||||
|
</div>
|
||||||
|
{hasSession && (
|
||||||
|
<nav>
|
||||||
|
{role === 'superadmin' && (
|
||||||
|
<NavLink to="/superadmin" title="Superadmin" aria-label="Superadmin"><i className="fa-solid fa-shield-halved" /></NavLink>
|
||||||
|
)}
|
||||||
|
{['admin', 'superadmin'].includes(role) && (
|
||||||
|
<NavLink to="/admin" title="Admin" aria-label="Admin"><i className="fa-solid fa-user-tie" /></NavLink>
|
||||||
|
)}
|
||||||
|
{['admin', 'superadmin'].includes(role) && (
|
||||||
|
<NavLink to="/periods" title="Mediciones" aria-label="Mediciones"><i className="fa-solid fa-clipboard-list" /></NavLink>
|
||||||
|
)}
|
||||||
|
{['admin', 'superadmin'].includes(role) && (
|
||||||
|
<NavLink to="/import" title="Importar" aria-label="Importar"><i className="fa-solid fa-file-import" /></NavLink>
|
||||||
|
)}
|
||||||
|
{['admin', 'superadmin'].includes(role) && (
|
||||||
|
<NavLink to="/questions" title="Preguntas" aria-label="Preguntas"><i className="fa-solid fa-clipboard-question" /></NavLink>
|
||||||
|
)}
|
||||||
|
{['user', 'admin', 'superadmin'].includes(role) && (
|
||||||
|
<NavLink to="/survey" title="Encuesta" aria-label="Encuesta"><i className="fa-solid fa-square-poll-vertical" /></NavLink>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('role');
|
||||||
|
localStorage.removeItem('company_code');
|
||||||
|
setRole(null);
|
||||||
|
setToken(null);
|
||||||
|
window.dispatchEvent(new Event('auth:changed'));
|
||||||
|
}}
|
||||||
|
title="Cerrar sesión"
|
||||||
|
aria-label="Cerrar sesión"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-right-from-bracket" />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
{notice && (
|
||||||
|
<div className="modal-backdrop">
|
||||||
|
<div className={`modal ${notice.type || 'info'}`}>
|
||||||
|
<h3>Notificación</h3>
|
||||||
|
<p>{notice.message}</p>
|
||||||
|
<button className="action-btn action-save" onClick={() => setNotice(null)} aria-label="Cerrar">
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<main className="content">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<LoginPage />} />
|
||||||
|
<Route
|
||||||
|
path="/superadmin"
|
||||||
|
element={
|
||||||
|
<Guard allow={role === 'superadmin'}>
|
||||||
|
<SuperadminCompanies />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<Guard allow={['admin', 'superadmin'].includes(role)}>
|
||||||
|
<AdminDashboard />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/periods"
|
||||||
|
element={
|
||||||
|
<Guard allow={['admin', 'superadmin'].includes(role)}>
|
||||||
|
<SurveyPeriods />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/import"
|
||||||
|
element={
|
||||||
|
<Guard allow={['admin', 'superadmin'].includes(role)}>
|
||||||
|
<ImportUsers />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/questions"
|
||||||
|
element={
|
||||||
|
<Guard allow={['admin', 'superadmin'].includes(role)}>
|
||||||
|
<QuestionBank />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/survey"
|
||||||
|
element={
|
||||||
|
<Guard allow={['user', 'admin', 'superadmin'].includes(role)}>
|
||||||
|
<ParticipantSurvey />
|
||||||
|
</Guard>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import './styles/app.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(<App />);
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function AdminDashboard() {
|
||||||
|
const [data, setData] = useState({ current: null, dimensions: [], history: [], participation: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/admin/dashboard').then((res) => setData(res.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Índice Global</h2>
|
||||||
|
<p>{data.current ? `${data.current.score} (${data.current.classification})` : 'Sin datos'}</p>
|
||||||
|
<p>Participación: {data.current ? `${data.current.participation_rate}%` : '-'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Avance de Encuestas</h2>
|
||||||
|
<p>Registrados: {data.participation ? data.participation.total_users : 0}</p>
|
||||||
|
<p>Completaron 100%: {data.participation ? data.participation.completed_users : 0}</p>
|
||||||
|
<p>Menos del 100%: {data.participation ? data.participation.partial_users : 0}</p>
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Resultados por Dimensión</h2>
|
||||||
|
{data.dimensions.map((d) => (
|
||||||
|
<p key={d.name}>{d.name}: {d.score} ({d.classification})</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<h2>Histórico</h2>
|
||||||
|
{data.history.map((h) => (
|
||||||
|
<p key={h.codigo_medicion}>{h.codigo_medicion}: {h.score}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function ImportUsers() {
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
|
||||||
|
const upload = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
const res = await api.post('/admin/users/import', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
setResult(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Importar Usuarios</h2>
|
||||||
|
<input type="file" onChange={(e)=>setFile(e.target.files[0])} />
|
||||||
|
<button onClick={upload}>Importar</button>
|
||||||
|
{result && (
|
||||||
|
<div>
|
||||||
|
<p>Insertados: {result.inserted}</p>
|
||||||
|
{result.errors?.length > 0 && (
|
||||||
|
<ul>
|
||||||
|
{result.errors.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../utils/api';
|
||||||
|
import creatorLogo from '../assets/creator-logo.png';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [form, setForm] = useState({ role: 'admin', email: '', password: '', company_code: '' });
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const hasSession = !!localStorage.getItem('token');
|
||||||
|
|
||||||
|
const handleChange = (e) => {
|
||||||
|
setForm({ ...form, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.post('/auth/login', form);
|
||||||
|
localStorage.setItem('token', res.data.token);
|
||||||
|
localStorage.setItem('role', res.data.role);
|
||||||
|
if (res.data.company_code) localStorage.setItem('company_code', res.data.company_code);
|
||||||
|
window.dispatchEvent(new Event('auth:changed'));
|
||||||
|
setMsg('Login correcto');
|
||||||
|
navigate('/');
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err.response?.data?.error || 'Error de login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
{!hasSession && (
|
||||||
|
<>
|
||||||
|
<h2>Acceso</h2>
|
||||||
|
<label>Rol</label>
|
||||||
|
<select name="role" value={form.role} onChange={handleChange}>
|
||||||
|
<option value="superadmin">Superadmin</option>
|
||||||
|
<option value="admin">Admin Empresa</option>
|
||||||
|
<option value="user">Participante</option>
|
||||||
|
</select>
|
||||||
|
{form.role !== 'superadmin' && (
|
||||||
|
<>
|
||||||
|
<label>Company Code</label>
|
||||||
|
<input name="company_code" value={form.company_code} onChange={handleChange} placeholder="EMPRESA-001" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<label>Email</label>
|
||||||
|
<input name="email" value={form.email} onChange={handleChange} placeholder="correo@empresa.com" />
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" name="password" value={form.password} onChange={handleChange} />
|
||||||
|
<button onClick={login}>Ingresar</button>
|
||||||
|
{msg && <p>{msg}</p>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{hasSession && (
|
||||||
|
<div className="login-image">
|
||||||
|
<img src={creatorLogo} alt="Imagen de bienvenida" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function ParticipantSurvey() {
|
||||||
|
const [survey, setSurvey] = useState({ period: null, questions: [] });
|
||||||
|
const [answers, setAnswers] = useState({});
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [step, setStep] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get('/surveys/active').then((res) => setSurvey(res.data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
survey.questions.forEach((q) => {
|
||||||
|
if (!map.has(q.dimension)) map.set(q.dimension, []);
|
||||||
|
map.get(q.dimension).push(q);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries());
|
||||||
|
}, [survey.questions]);
|
||||||
|
|
||||||
|
const totalSteps = grouped.length;
|
||||||
|
const current = grouped[step] || [];
|
||||||
|
const currentTitle = current[0] || '';
|
||||||
|
const currentQuestions = current[1] || [];
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
const totalQuestions = survey.questions.length;
|
||||||
|
const answered = Object.keys(answers).length;
|
||||||
|
if (answered !== totalQuestions) {
|
||||||
|
window.dispatchEvent(new CustomEvent('app:notice', { detail: { type: 'error', message: 'Resultados incompletos. Responde todas las preguntas.' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = {
|
||||||
|
survey_period_id: survey.period.id,
|
||||||
|
answers: Object.entries(answers).map(([question_id, value]) => ({ question_id, value: Number(value) }))
|
||||||
|
};
|
||||||
|
await api.post('/surveys/responses', payload);
|
||||||
|
setMsg('Respuestas guardadas');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!survey.period) return <div className="card">No hay encuesta activa.</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Encuesta {survey.period.codigo_medicion}</h2>
|
||||||
|
<p>Periodo: {survey.period.fecha_inicio} a {survey.period.fecha_fin}</p>
|
||||||
|
{totalSteps > 0 && (
|
||||||
|
<h3>{currentTitle}</h3>
|
||||||
|
)}
|
||||||
|
{currentQuestions.map((q) => (
|
||||||
|
<div key={q.question_id}>
|
||||||
|
<p>{q.text}</p>
|
||||||
|
<select value={answers[q.question_id] || ''} onChange={(e)=>setAnswers({...answers, [q.question_id]: e.target.value})}>
|
||||||
|
<option value="">Seleccione</option>
|
||||||
|
<option value="1">1</option>
|
||||||
|
<option value="2">2</option>
|
||||||
|
<option value="3">3</option>
|
||||||
|
<option value="4">4</option>
|
||||||
|
<option value="5">5</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div>
|
||||||
|
<button className="action-btn action-suspend" title="Anterior" aria-label="Anterior" onClick={() => setStep(Math.max(step - 1, 0))} disabled={step === 0}>
|
||||||
|
<i className="fa-solid fa-arrow-left" />
|
||||||
|
</button>{' '}
|
||||||
|
<button className="action-btn action-activate" title="Siguiente" aria-label="Siguiente" onClick={() => setStep(Math.min(step + 1, totalSteps - 1))} disabled={step >= totalSteps - 1}>
|
||||||
|
<i className="fa-solid fa-arrow-right" />
|
||||||
|
</button>
|
||||||
|
{step === totalSteps - 1 && (
|
||||||
|
<button className="action-btn action-save" title="Guardar" aria-label="Guardar" onClick={submit}>
|
||||||
|
<i className="fa-solid fa-floppy-disk" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{msg && <p>{msg}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function QuestionBank() {
|
||||||
|
const [questions, setQuestions] = useState([]);
|
||||||
|
const [sets, setSets] = useState([]);
|
||||||
|
const [file, setFile] = useState(null);
|
||||||
|
const [setName, setSetName] = useState('');
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const [view, setView] = useState('sets');
|
||||||
|
const [activeSet, setActiveSet] = useState(null);
|
||||||
|
const [setQuestionsList, setSetQuestionsList] = useState([]);
|
||||||
|
const [dimensions, setDimensions] = useState([]);
|
||||||
|
const [newDimName, setNewDimName] = useState('');
|
||||||
|
const [selectedDimId, setSelectedDimId] = useState(null);
|
||||||
|
const [newQuestionText, setNewQuestionText] = useState('');
|
||||||
|
const [newSetInfo, setNewSetInfo] = useState(null);
|
||||||
|
const companyCode = localStorage.getItem('company_code');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [qRes, sRes] = await Promise.all([
|
||||||
|
api.get('/admin/questions'),
|
||||||
|
api.get('/admin/question-sets')
|
||||||
|
]);
|
||||||
|
setQuestions(qRes.data);
|
||||||
|
setSets(sRes.data);
|
||||||
|
} catch (err) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const dRes = await api.get('/admin/dimensions');
|
||||||
|
setDimensions(dRes.data);
|
||||||
|
} catch (err) {
|
||||||
|
setDimensions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!companyCode) {
|
||||||
|
window.dispatchEvent(new CustomEvent('app:notice', { detail: { type: 'error', message: 'Selecciona una empresa antes de entrar a Preguntas.' } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const exportExcel = async () => {
|
||||||
|
const res = await api.get('/admin/questions/export', { responseType: 'blob' });
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'preguntas.xlsx';
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const importExcel = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
if (newSetInfo?.id) form.append('set_id', String(newSetInfo.id));
|
||||||
|
const res = await api.post('/admin/questions/import', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||||
|
setMsg(`Procesadas: ${res.data.processed}. Errores: ${res.data.errors.length}`);
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSet = async () => {
|
||||||
|
const res = await api.post('/admin/question-sets', { name: setName, empty: true });
|
||||||
|
setMsg(`Set creado: ${res.data.code}`);
|
||||||
|
setNewSetInfo(res.data);
|
||||||
|
setSetName('');
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSet = async (setRow) => {
|
||||||
|
const res = await api.get(`/admin/question-sets/${setRow.id}/questions`);
|
||||||
|
setActiveSet(setRow);
|
||||||
|
setSetQuestionsList(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editSet = async (setRow) => {
|
||||||
|
const res = await api.get(`/admin/question-sets/${setRow.id}/questions`);
|
||||||
|
setNewSetInfo(setRow);
|
||||||
|
setSetQuestionsList(res.data);
|
||||||
|
setView('editset');
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = async () => {
|
||||||
|
const res = await api.get('/admin/questions/template', { responseType: 'blob' });
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'preguntas_template.xlsx';
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDimension = async () => {
|
||||||
|
if (!newDimName) return;
|
||||||
|
const res = await api.post('/admin/dimensions', { name: newDimName });
|
||||||
|
setNewDimName('');
|
||||||
|
await load();
|
||||||
|
setSelectedDimId(res.data.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addQuestion = async () => {
|
||||||
|
if (!newSetInfo || !selectedDimId || !newQuestionText) return;
|
||||||
|
await api.post(`/admin/question-sets/${newSetInfo.id}/questions`, { dimension_id: selectedDimId, text: newQuestionText });
|
||||||
|
setNewQuestionText('');
|
||||||
|
const res = await api.get(`/admin/question-sets/${newSetInfo.id}/questions`);
|
||||||
|
setSetQuestionsList(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!companyCode) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Preguntas</h2>
|
||||||
|
<p>Selecciona una empresa para ver las versiones de encuesta.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="subnav">
|
||||||
|
<button className={view === 'sets' ? 'active' : ''} onClick={() => setView('sets')} title="Versiones" aria-label="Versiones"><i className="fa-solid fa-layer-group" /></button>
|
||||||
|
<button className={view === 'sets' ? '' : ''} onClick={downloadTemplate} title="Template Excel" aria-label="Template Excel"><i className="fa-solid fa-file-arrow-down" /></button>
|
||||||
|
<button
|
||||||
|
className={view === 'newset' ? 'active' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
setView('newset');
|
||||||
|
setMsg('');
|
||||||
|
if (!setName) {
|
||||||
|
const today = new Date().toISOString().slice(0, 10).replaceAll('-', '');
|
||||||
|
const sameDay = sets
|
||||||
|
.map((s) => String(s.code || ''))
|
||||||
|
.filter((code) => code.startsWith(`QSET-${today}-`))
|
||||||
|
.map((code) => Number(code.split('-').pop()))
|
||||||
|
.filter((n) => Number.isFinite(n));
|
||||||
|
const max = sameDay.length ? Math.max(...sameDay) : 0;
|
||||||
|
const next = String(max + 1).padStart(2, '0');
|
||||||
|
setSetName(`QSET-${today}-${next}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Nuevo set"
|
||||||
|
aria-label="Nuevo set"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-plus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'sets' && !activeSet && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Versiones de Encuesta</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Nombre</th><th>Versión</th><th>Activo</th><th>Abrir</th><th>Editar</th><th>Eliminar</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{sets.map((s) => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td>{s.code}</td>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td>{s.version}</td>
|
||||||
|
<td>{s.is_active ? 'Sí' : 'No'}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="action-btn action-report"
|
||||||
|
title="Abrir set"
|
||||||
|
aria-label="Abrir set"
|
||||||
|
onClick={async () => {
|
||||||
|
await openSet(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-folder-open" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="action-btn action-save"
|
||||||
|
title="Editar set"
|
||||||
|
aria-label="Editar set"
|
||||||
|
onClick={async () => {
|
||||||
|
await editSet(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-pen" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="action-btn action-delete"
|
||||||
|
title="Eliminar"
|
||||||
|
aria-label="Eliminar"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await api.delete(`/admin/question-sets/${s.id}`);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
window.dispatchEvent(new CustomEvent('app:notice', { detail: { type: 'error', message: err.response?.data?.error || 'No se pudo eliminar' } }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-trash-can" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'newset' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Nuevo set de preguntas</h2>
|
||||||
|
<div>
|
||||||
|
<input value={setName} onChange={(e)=>setSetName(e.target.value)} placeholder="Nombre del set" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={(e) => setFile(e.target.files[0])} />
|
||||||
|
<button className="action-btn action-report" title="Importar preguntas" aria-label="Importar preguntas" onClick={importExcel}>
|
||||||
|
<i className="fa-solid fa-file-import" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button className="action-btn action-save" title="Crear set" aria-label="Crear set" onClick={createSet}>
|
||||||
|
<i className="fa-solid fa-layer-group" />
|
||||||
|
</button>
|
||||||
|
<button className="action-btn action-delete" title="Volver" aria-label="Volver" onClick={() => setView('sets')}>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{newSetInfo && (
|
||||||
|
<div>
|
||||||
|
<h3>Ámbitos</h3>
|
||||||
|
<div>
|
||||||
|
<input value={newDimName} onChange={(e) => setNewDimName(e.target.value)} placeholder="Nombre del ámbito" />
|
||||||
|
<button className="action-btn action-save" title="Crear ámbito" aria-label="Crear ámbito" onClick={createDimension}>
|
||||||
|
<i className="fa-solid fa-layer-group" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="subnav">
|
||||||
|
{dimensions.map((d) => (
|
||||||
|
<button key={d.id} className={selectedDimId === d.id ? 'active' : ''} onClick={() => setSelectedDimId(d.id)} title={d.name} aria-label={d.name}>
|
||||||
|
<i className="fa-solid fa-layer-group" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input value={newQuestionText} onChange={(e) => setNewQuestionText(e.target.value)} placeholder="Texto de la pregunta" />
|
||||||
|
<button className="action-btn action-save" title="Agregar pregunta" aria-label="Agregar pregunta" onClick={addQuestion}>
|
||||||
|
<i className="fa-solid fa-plus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Dimensión</th><th>Texto</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{setQuestionsList.map((q, idx) => (
|
||||||
|
<tr key={`${q.question_code}-${idx}`}>
|
||||||
|
<td>{q.question_code}</td>
|
||||||
|
<td>{q.dimension}</td>
|
||||||
|
<td>{q.text_snapshot}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{msg && <p>{msg}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'editset' && newSetInfo && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Editar set: {newSetInfo.code}</h2>
|
||||||
|
<div>
|
||||||
|
<input value={newSetInfo.name || ''} disabled />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input type="file" onChange={(e) => setFile(e.target.files[0])} />
|
||||||
|
<button className="action-btn action-report" title="Importar preguntas" aria-label="Importar preguntas" onClick={importExcel}>
|
||||||
|
<i className="fa-solid fa-file-import" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input value={newDimName} onChange={(e) => setNewDimName(e.target.value)} placeholder="Nombre del ámbito" />
|
||||||
|
<button className="action-btn action-save" title="Crear ámbito" aria-label="Crear ámbito" onClick={createDimension}>
|
||||||
|
<i className="fa-solid fa-layer-group" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="subnav">
|
||||||
|
{dimensions.map((d) => (
|
||||||
|
<button key={d.id} className={selectedDimId === d.id ? 'active' : ''} onClick={() => setSelectedDimId(d.id)} title={d.name} aria-label={d.name}>
|
||||||
|
<i className="fa-solid fa-layer-group" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input value={newQuestionText} onChange={(e) => setNewQuestionText(e.target.value)} placeholder="Texto de la pregunta" />
|
||||||
|
<button className="action-btn action-save" title="Agregar pregunta" aria-label="Agregar pregunta" onClick={addQuestion}>
|
||||||
|
<i className="fa-solid fa-plus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Dimensión</th><th>Texto</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{setQuestionsList.map((q, idx) => (
|
||||||
|
<tr key={`${q.question_code}-${idx}`}>
|
||||||
|
<td>{q.question_code}</td>
|
||||||
|
<td>{q.dimension}</td>
|
||||||
|
<td>{q.text_snapshot}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button className="action-btn action-delete" title="Volver" aria-label="Volver" onClick={() => { setView('sets'); setNewSetInfo(null); }}>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'sets' && activeSet && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Preguntas del set: {activeSet.code}</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Dimensión</th><th>Texto</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{setQuestionsList.map((q, idx) => (
|
||||||
|
<tr key={`${q.question_code}-${idx}`}>
|
||||||
|
<td>{q.question_code}</td>
|
||||||
|
<td>{q.dimension}</td>
|
||||||
|
<td>{q.text_snapshot}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
className="action-btn action-delete"
|
||||||
|
title="Cerrar"
|
||||||
|
aria-label="Cerrar"
|
||||||
|
onClick={() => { setActiveSet(null); setSetQuestionsList([]); }}
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preguntas se muestran al abrir un set */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function SuperadminCompanies() {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const nextYear = new Date(new Date().setFullYear(new Date().getFullYear() + 1)).toISOString().slice(0, 10);
|
||||||
|
const [companies, setCompanies] = useState([]);
|
||||||
|
const [editDates, setEditDates] = useState({});
|
||||||
|
const [participationByCompany, setParticipationByCompany] = useState({});
|
||||||
|
const [section, setSection] = useState('companies');
|
||||||
|
const [view, setView] = useState('list');
|
||||||
|
const [adminForm, setAdminForm] = useState({ name: '', email: '', password: '' });
|
||||||
|
const [adminItems, setAdminItems] = useState([]);
|
||||||
|
const [adminCtx, setAdminCtx] = useState({ companyId: null, companyName: '', mode: 'list' });
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
start_date: today,
|
||||||
|
end_date: nextYear,
|
||||||
|
db_name: '',
|
||||||
|
db_host: '192.168.222.20',
|
||||||
|
db_port: 3306,
|
||||||
|
db_user: 'user_ia',
|
||||||
|
db_pass: 'UserIA2026'
|
||||||
|
});
|
||||||
|
const [msg, setMsg] = useState('');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await api.get('/superadmin/companies');
|
||||||
|
setCompanies(res.data);
|
||||||
|
const nextDates = {};
|
||||||
|
res.data.forEach((c) => {
|
||||||
|
nextDates[c.id] = { start_date: c.start_date || '', end_date: c.end_date || '' };
|
||||||
|
});
|
||||||
|
setEditDates(nextDates);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
try {
|
||||||
|
if (!form.start_date || !form.end_date) {
|
||||||
|
setMsg('Fecha inicio y fin son obligatorias');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.post('/superadmin/companies', form);
|
||||||
|
setMsg('Empresa creada');
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err.response?.data?.error || 'Error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAdmins = async (company, mode) => {
|
||||||
|
const res = await api.get(`/superadmin/companies/${company.id}/admins`);
|
||||||
|
setAdminItems(res.data);
|
||||||
|
setAdminCtx({ companyId: company.id, companyName: company.name, mode });
|
||||||
|
setAdminForm({ name: '', email: '', password: '' });
|
||||||
|
setSection('admins');
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAdmin = async () => {
|
||||||
|
try {
|
||||||
|
if (!adminForm.name || !adminForm.email || !adminForm.password) {
|
||||||
|
setMsg('Nombre, email y password son obligatorios');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.post(`/superadmin/companies/${adminCtx.companyId}/admins`, adminForm);
|
||||||
|
const res = await api.get(`/superadmin/companies/${adminCtx.companyId}/admins`);
|
||||||
|
setAdminItems(res.data);
|
||||||
|
setAdminCtx({ ...adminCtx, mode: 'list' });
|
||||||
|
setAdminForm({ name: '', email: '', password: '' });
|
||||||
|
setMsg('Admin creado');
|
||||||
|
} catch (err) {
|
||||||
|
setMsg(err.response?.data?.error || 'Error al crear admin');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectCompany = (code) => {
|
||||||
|
localStorage.setItem('company_code', code);
|
||||||
|
setMsg(`Empresa seleccionada: ${code}`);
|
||||||
|
navigate('/admin');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (value) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
return String(value).split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdminStatus = async (adminId, currentStatus) => {
|
||||||
|
const next = currentStatus === 'active' ? 'inactive' : 'active';
|
||||||
|
await api.patch(`/superadmin/companies/${adminCtx.companyId}/admins/${adminId}/status`, { status: next });
|
||||||
|
const res = await api.get(`/superadmin/companies/${adminCtx.companyId}/admins`);
|
||||||
|
setAdminItems(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="subnav">
|
||||||
|
<button className={section === 'companies' ? 'active' : ''} onClick={() => setSection('companies')} title="Empresas" aria-label="Empresas"><i className="fa-solid fa-building" /></button>
|
||||||
|
<button className={section === 'admins' ? 'active' : ''} onClick={() => setSection('admins')} title="Administradores" aria-label="Administradores"><i className="fa-solid fa-users-gear" /></button>
|
||||||
|
<button className={section === 'avance' ? 'active' : ''} onClick={() => setSection('avance')} title="Avance" aria-label="Avance"><i className="fa-solid fa-chart-line" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section === 'companies' && (
|
||||||
|
<div>
|
||||||
|
<div className="subnav">
|
||||||
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="Listar" aria-label="Listar"><i className="fa-solid fa-list" /></button>
|
||||||
|
<button className={view === 'new' ? 'active' : ''} onClick={() => setView('new')} title="Nuevo" aria-label="Nuevo"><i className="fa-solid fa-plus" /></button>
|
||||||
|
<button className={view === 'print' ? 'active' : ''} onClick={() => setView('print')} title="Imprimir" aria-label="Imprimir"><i className="fa-solid fa-print" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'new' && (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Nueva Empresa</h2>
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input name="name" value={form.name} onChange={(e)=>setForm({...form, name:e.target.value})} />
|
||||||
|
<label>Código</label>
|
||||||
|
<input name="code" value={form.code} onChange={(e)=>setForm({...form, code:e.target.value})} />
|
||||||
|
<label>Fecha inicio</label>
|
||||||
|
<input type="date" name="start_date" value={form.start_date} onChange={(e)=>setForm({...form, start_date:e.target.value})} />
|
||||||
|
<label>Fecha fin</label>
|
||||||
|
<input type="date" name="end_date" value={form.end_date} onChange={(e)=>setForm({...form, end_date:e.target.value})} />
|
||||||
|
<label>DB Name</label>
|
||||||
|
<input name="db_name" value={form.db_name} onChange={(e)=>setForm({...form, db_name:e.target.value})} />
|
||||||
|
<button onClick={create} title="Guardar" aria-label="Guardar"><i className="fa-solid fa-floppy-disk" /></button>
|
||||||
|
{msg && <p>{msg}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'list' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Empresas</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Nombre</th><th>Código</th><th>Estado</th><th>Inicio</th><th>Fin</th><th>Editar</th><th>Acceso</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>{c.code}</td>
|
||||||
|
<td><span className="badge">{c.status}</span></td>
|
||||||
|
<td>{formatDate(c.start_date)}</td>
|
||||||
|
<td>{formatDate(c.end_date)}</td>
|
||||||
|
<td>
|
||||||
|
<button className="action-btn action-save" title="Editar" aria-label="Editar" onClick={() => setView('manage')}>
|
||||||
|
<i className="fa-solid fa-pen-to-square" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button className="action-btn action-report" title="Acceder" aria-label="Acceder" onClick={() => selectCompany(c.code)}>
|
||||||
|
<i className="fa-solid fa-right-to-bracket" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'manage' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Empresas</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Nombre</th><th>Código</th><th>Estado</th><th>Inicio</th><th>Fin</th><th>Acciones</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>{c.code}</td>
|
||||||
|
<td><span className="badge">{c.status}</span></td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editDates[c.id]?.start_date || ''}
|
||||||
|
onChange={(e) => setEditDates({
|
||||||
|
...editDates,
|
||||||
|
[c.id]: { ...editDates[c.id], start_date: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={editDates[c.id]?.end_date || ''}
|
||||||
|
onChange={(e) => setEditDates({
|
||||||
|
...editDates,
|
||||||
|
[c.id]: { ...editDates[c.id], end_date: e.target.value }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{c.status === 'active' ? (
|
||||||
|
<button className="action-btn action-suspend" title="Suspender" aria-label="Suspender" onClick={async () => {
|
||||||
|
await api.patch(`/superadmin/companies/${c.id}/status`, { status: 'suspended' });
|
||||||
|
load();
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-circle-pause" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="action-btn action-activate" title="Activar" aria-label="Activar" onClick={async () => {
|
||||||
|
await api.patch(`/superadmin/companies/${c.id}/status`, { status: 'active' });
|
||||||
|
load();
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-circle-play" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-save" title="Guardar fechas" aria-label="Guardar fechas" onClick={async () => {
|
||||||
|
const payload = editDates[c.id] || { start_date: '', end_date: '' };
|
||||||
|
await api.patch(`/superadmin/companies/${c.id}/dates`, payload);
|
||||||
|
load();
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-calendar-check" />
|
||||||
|
</button>
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-report" title="Ver avance" aria-label="Ver avance" onClick={async () => {
|
||||||
|
const res = await api.get(`/superadmin/companies/${c.id}/participation`);
|
||||||
|
setParticipationByCompany({ ...participationByCompany, [c.id]: res.data.participation });
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-chart-line" />
|
||||||
|
</button>
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-save" title="Administradores" aria-label="Administradores" onClick={() => openAdmins(c, 'list')}>
|
||||||
|
<i className="fa-solid fa-users" />
|
||||||
|
</button>
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-delete" title="Eliminar" aria-label="Eliminar" onClick={async () => {
|
||||||
|
if (!confirm('¿Eliminar empresa? Sólo se permite si no tiene encuestas.')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/superadmin/companies/${c.id}`);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.response?.data?.error || 'No se pudo eliminar');
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-trash-can" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'print' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Imprimir</h2>
|
||||||
|
<p>Imprime el listado actual de empresas y avance.</p>
|
||||||
|
<button onClick={() => window.print()} title="Imprimir" aria-label="Imprimir"><i className="fa-solid fa-print" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'admins' && (
|
||||||
|
<div className="card">
|
||||||
|
{!adminCtx.companyId && (
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Empresa</th><th>Código</th><th>Acciones</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<tr key={`adm-${c.id}`}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>{c.code}</td>
|
||||||
|
<td>
|
||||||
|
<button className="action-btn action-report" title="Ver admins" aria-label="Ver admins" onClick={() => openAdmins(c, 'list')}>
|
||||||
|
<i className="fa-solid fa-users" />
|
||||||
|
</button>
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-save" title="Crear admin" aria-label="Crear admin" onClick={() => openAdmins(c, 'new')}>
|
||||||
|
<i className="fa-solid fa-user-plus" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminCtx.companyId && adminCtx.mode === 'list' && (
|
||||||
|
<div>
|
||||||
|
<h2>Administradores - {adminCtx.companyName}</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Nombre</th><th>Email</th><th>Estado</th><th>Acciones</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{adminItems.map((a) => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td>{a.name}</td>
|
||||||
|
<td>{a.email}</td>
|
||||||
|
<td>{a.status}</td>
|
||||||
|
<td>
|
||||||
|
{a.status === 'active' ? (
|
||||||
|
<button className="action-btn action-suspend" title="Caducar" aria-label="Caducar" onClick={() => toggleAdminStatus(a.id, a.status)}>
|
||||||
|
<i className="fa-solid fa-user-slash" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button className="action-btn action-activate" title="Activar" aria-label="Activar" onClick={() => toggleAdminStatus(a.id, a.status)}>
|
||||||
|
<i className="fa-solid fa-user-check" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-save" title="Volver" aria-label="Volver" onClick={() => setAdminCtx({ companyId: null, companyName: '', mode: 'list' })}>
|
||||||
|
<i className="fa-solid fa-arrow-left" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{adminCtx.companyId && adminCtx.mode === 'new' && (
|
||||||
|
<div>
|
||||||
|
<h2>Crear administrador - {adminCtx.companyName}</h2>
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input value={adminForm.name} onChange={(e)=>setAdminForm({...adminForm, name:e.target.value})} />
|
||||||
|
<label>Email</label>
|
||||||
|
<input value={adminForm.email} onChange={(e)=>setAdminForm({...adminForm, email:e.target.value})} />
|
||||||
|
<label>Password</label>
|
||||||
|
<input type="password" value={adminForm.password} onChange={(e)=>setAdminForm({...adminForm, password:e.target.value})} />
|
||||||
|
<button className="action-btn action-save" title="Guardar" aria-label="Guardar" onClick={createAdmin}>
|
||||||
|
<i className="fa-solid fa-floppy-disk" />
|
||||||
|
</button>
|
||||||
|
{' '}
|
||||||
|
<button className="action-btn action-delete" title="Cancelar" aria-label="Cancelar" onClick={() => setAdminCtx({ companyId: null, companyName: '', mode: 'list' })}>
|
||||||
|
<i className="fa-solid fa-xmark" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{section === 'avance' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Avance por empresa</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Empresa</th><th>Acciones</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{companies.map((c) => (
|
||||||
|
<tr key={`av-${c.id}`}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td>
|
||||||
|
<button className="action-btn action-report" title="Ver avance" aria-label="Ver avance" onClick={async () => {
|
||||||
|
const res = await api.get(`/superadmin/companies/${c.id}/participation`);
|
||||||
|
setParticipationByCompany({ ...participationByCompany, [c.id]: res.data.participation });
|
||||||
|
}}>
|
||||||
|
<i className="fa-solid fa-chart-line" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{companies.map((c) => {
|
||||||
|
const p = participationByCompany[c.id];
|
||||||
|
if (!p) return null;
|
||||||
|
return (
|
||||||
|
<div key={`p2-${c.id}`} className="card">
|
||||||
|
<h3>{c.name} - Avance</h3>
|
||||||
|
<p>Registrados: {p.total_users}</p>
|
||||||
|
<p>Completaron 100%: {p.completed_users}</p>
|
||||||
|
<p>Menos del 100%: {p.partial_users}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import api from '../utils/api';
|
||||||
|
|
||||||
|
export default function SurveyPeriods() {
|
||||||
|
const [periods, setPeriods] = useState([]);
|
||||||
|
const [view, setView] = useState('list');
|
||||||
|
const [form, setForm] = useState({ year: 2026, month: 3, fecha_inicio: '2026-03-01', fecha_fin: '2026-03-31' });
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const res = await api.get('/admin/survey-periods');
|
||||||
|
setPeriods(res.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
await api.post('/admin/survey-periods', form);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const activate = async (id) => { await api.patch(`/admin/survey-periods/${id}/activate`); load(); };
|
||||||
|
const close = async (id) => { await api.patch(`/admin/survey-periods/${id}/close`); load(); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="subnav">
|
||||||
|
<button className={view === 'list' ? 'active' : ''} onClick={() => setView('list')} title="Listar" aria-label="Listar"><i className="fa-solid fa-list" /></button>
|
||||||
|
<button className={view === 'new' ? 'active' : ''} onClick={() => setView('new')} title="Nuevo" aria-label="Nuevo"><i className="fa-solid fa-plus" /></button>
|
||||||
|
<button className={view === 'manage' ? 'active' : ''} onClick={() => setView('manage')} title="Edición" aria-label="Edición"><i className="fa-solid fa-pen-to-square" /></button>
|
||||||
|
<button className={view === 'print' ? 'active' : ''} onClick={() => setView('print')} title="Imprimir" aria-label="Imprimir"><i className="fa-solid fa-print" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{view === 'new' && (
|
||||||
|
<div className="grid">
|
||||||
|
<div className="card">
|
||||||
|
<h2>Nueva Medición</h2>
|
||||||
|
<label>Año</label>
|
||||||
|
<input value={form.year} onChange={(e)=>setForm({...form, year:e.target.value})} />
|
||||||
|
<label>Mes</label>
|
||||||
|
<input value={form.month} onChange={(e)=>setForm({...form, month:e.target.value})} />
|
||||||
|
<label>Fecha Inicio</label>
|
||||||
|
<input value={form.fecha_inicio} onChange={(e)=>setForm({...form, fecha_inicio:e.target.value})} />
|
||||||
|
<label>Fecha Fin</label>
|
||||||
|
<input value={form.fecha_fin} onChange={(e)=>setForm({...form, fecha_fin:e.target.value})} />
|
||||||
|
<button onClick={create} title="Guardar" aria-label="Guardar"><i className="fa-solid fa-floppy-disk" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'list' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Mediciones</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Estado</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.codigo_medicion}</td>
|
||||||
|
<td>{p.estado}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'manage' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Mediciones</h2>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Código</th><th>Estado</th><th>Acciones</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{periods.map((p) => (
|
||||||
|
<tr key={p.id}>
|
||||||
|
<td>{p.codigo_medicion}</td>
|
||||||
|
<td>{p.estado}</td>
|
||||||
|
<td>
|
||||||
|
<button className="action-btn action-activate" title="Activar" aria-label="Activar" onClick={() => activate(p.id)}><i className="fa-solid fa-circle-play" /></button>{' '}
|
||||||
|
<button className="action-btn action-suspend" title="Cerrar" aria-label="Cerrar" onClick={() => close(p.id)}><i className="fa-solid fa-circle-xmark" /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{view === 'print' && (
|
||||||
|
<div className="card">
|
||||||
|
<h2>Imprimir</h2>
|
||||||
|
<p>Imprime el listado actual de mediciones.</p>
|
||||||
|
<button onClick={() => window.print()} title="Imprimir" aria-label="Imprimir"><i className="fa-solid fa-print" /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;600;700&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #f7f4ef;
|
||||||
|
--ink: #1b1b1f;
|
||||||
|
--accent: #0c6d5c;
|
||||||
|
--accent-2: #e1b85b;
|
||||||
|
--card: #ffffff;
|
||||||
|
--muted: #6c6c74;
|
||||||
|
--border: #e5e0d8;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
background: radial-gradient(circle at 20% 0%, #fff7e8 0%, #f7f4ef 55%, #f1ede7 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 28px;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand img {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a {
|
||||||
|
margin-left: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0e9db;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav button {
|
||||||
|
margin-left: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f0e9db;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-image {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-image img {
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: var(--accent-2);
|
||||||
|
color: #3b2c14;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-top: 6px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th, .table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav button {
|
||||||
|
background: #f0e9db;
|
||||||
|
color: var(--ink);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subnav button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: #b23a3a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 8px 16px rgba(0,0,0,0.08);
|
||||||
|
margin: 4px 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar nav a { font-size: 16px; }
|
||||||
|
.subnav button { font-size: 16px; }
|
||||||
|
.action-btn { font-size: 16px; }
|
||||||
|
button, a { font-size: 16px; }
|
||||||
|
|
||||||
|
.action-suspend { background: #e4b85b; color: #2b1a00; }
|
||||||
|
.action-activate { background: #7ac7a8; color: #0c2b1f; }
|
||||||
|
.action-save { background: #0c6d5c; color: #fff; }
|
||||||
|
.action-report { background: #0a5f7a; color: #fff; }
|
||||||
|
.action-delete { background: #b23a3a; color: #fff; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #e6f2ef;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px 24px;
|
||||||
|
width: min(420px, 90vw);
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h3 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL:
|
||||||
|
import.meta.env.VITE_API_URL ||
|
||||||
|
`http://${window.location.hostname}:4020`
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
const companyCode = localStorage.getItem('company_code');
|
||||||
|
if (companyCode) config.headers['x-company-code'] = companyCode;
|
||||||
|
config.headers['x-menu'] = window.location.pathname;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
const status = error?.response?.status;
|
||||||
|
const url = error?.config?.url || '';
|
||||||
|
const isSilent404 = status === 404 && url.includes('/admin/dimensions');
|
||||||
|
const notice = { type: 'error', message: '' };
|
||||||
|
if (isSilent404) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
if (status === 401) {
|
||||||
|
notice.message = 'Sesión expirada o inválida. Inicia sesión nuevamente.';
|
||||||
|
} else if (status === 403) {
|
||||||
|
notice.message = 'No tienes permisos para esta acción.';
|
||||||
|
} else if (status === 404) {
|
||||||
|
notice.message = 'Recurso no encontrado o empresa inactiva.';
|
||||||
|
} else if (status >= 500) {
|
||||||
|
notice.message = 'Error interno del servidor.';
|
||||||
|
}
|
||||||
|
if (notice.message) {
|
||||||
|
const last = window.__lastNotice || {};
|
||||||
|
const now = Date.now();
|
||||||
|
if (last.message !== notice.message || now - (last.time || 0) > 1500) {
|
||||||
|
window.__lastNotice = { message: notice.message, time: now };
|
||||||
|
window.dispatchEvent(new CustomEvent('app:notice', { detail: notice }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default api;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5373,
|
||||||
|
host: '0.0.0.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Nombre,Email,Departamento,Cargo
|
||||||
|
Binary file not shown.
Reference in New Issue
Block a user