feat(ui): migrate to Tailwind UI and add result matrix tooling
This commit is contained in:
@@ -18,6 +18,13 @@ const {
|
||||
createQuestionSetEmpty,
|
||||
addQuestionToSet
|
||||
} = require('../services/questionService');
|
||||
const {
|
||||
listResultModel,
|
||||
createResultModel,
|
||||
updateResultModel,
|
||||
exportResultModelTemplate,
|
||||
importResultModelExcel
|
||||
} = require('../services/resultModelService');
|
||||
|
||||
const upload = multer();
|
||||
const router = express.Router();
|
||||
@@ -183,6 +190,60 @@ router.get('/question-sets/:id/questions', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/result-model', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await listResultModel(req.tenantPool);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/result-model', async (req, res, next) => {
|
||||
try {
|
||||
const { dimension, variable, subvariable, score_value, max_score, sort_order } = req.body;
|
||||
if (!dimension || !variable || !subvariable) {
|
||||
return res.status(400).json({ error: 'dimension, variable y subvariable requeridos' });
|
||||
}
|
||||
const result = await createResultModel(req.tenantPool, { dimension, variable, subvariable, score_value, max_score, sort_order });
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/result-model/:id', async (req, res, next) => {
|
||||
try {
|
||||
const { score_value, max_score, sort_order } = req.body;
|
||||
await updateResultModel(req.tenantPool, req.params.id, { score_value, max_score, sort_order });
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/result-model/template', async (req, res, next) => {
|
||||
try {
|
||||
const buffer = await exportResultModelTemplate();
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=\"clima_result_model.xlsx\"');
|
||||
res.send(buffer);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/result-model/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Archivo requerido' });
|
||||
const mode = req.body?.mode || 'replace';
|
||||
const result = await importResultModelExcel(req.file.buffer, req.tenantPool, mode);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/dimensions', async (req, res, next) => {
|
||||
try {
|
||||
const rows = await listDimensions(req.tenantPool);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
const path = require('path');
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
async function main() {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Resultados');
|
||||
sheet.addRow(['dimension', 'variable', 'subvariable', 'max_score', 'sort_order']);
|
||||
sheet.addRow(['CREDIBILIDAD', 'Comunicación', 'Accesibilidad', 100, 1]);
|
||||
sheet.addRow(['CREDIBILIDAD', 'Comunicación', 'Información', 100, 2]);
|
||||
sheet.addRow(['RESPETO', 'Apoyo', 'Desarrollo', 100, 3]);
|
||||
sheet.addRow(['IMPARCIALIDAD', 'Equidad', 'Ausencia de favoritismo', 100, 4]);
|
||||
|
||||
const outPath = path.resolve(__dirname, '../../../templates/plantilla_matriz_clima.xlsx');
|
||||
await workbook.xlsx.writeFile(outPath);
|
||||
console.log('Template generado:', outPath);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
const ExcelJS = require('exceljs');
|
||||
|
||||
async function listResultModel(pool) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, dimension, variable, subvariable, score_value, max_score, sort_order FROM climate_result_model ORDER BY sort_order, id'
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function createResultModel(pool, data) {
|
||||
const { dimension, variable, subvariable, score_value, max_score, sort_order } = data;
|
||||
const [res] = await pool.query(
|
||||
'INSERT INTO climate_result_model (dimension, variable, subvariable, score_value, max_score, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[dimension, variable, subvariable, score_value || null, max_score || null, sort_order || 0]
|
||||
);
|
||||
return { id: res.insertId };
|
||||
}
|
||||
|
||||
async function updateResultModel(pool, id, data) {
|
||||
const { score_value, max_score, sort_order } = data;
|
||||
await pool.query(
|
||||
'UPDATE climate_result_model SET score_value = COALESCE(?, score_value), max_score = COALESCE(?, max_score), sort_order = COALESCE(?, sort_order) WHERE id = ?',
|
||||
[score_value, max_score, sort_order, id]
|
||||
);
|
||||
}
|
||||
|
||||
async function exportResultModelTemplate() {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('Resultados');
|
||||
sheet.addRow(['dimension', 'variable', 'subvariable', 'score_value', 'max_score', 'sort_order']);
|
||||
sheet.addRow(['CREDIBILIDAD', 'Comunicación', 'Accesibilidad', 37.33, '', 1]);
|
||||
return workbook.xlsx.writeBuffer();
|
||||
}
|
||||
|
||||
async function importResultModelExcel(buffer, pool, mode = 'replace') {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(buffer);
|
||||
const sheet = workbook.worksheets[0];
|
||||
if (!sheet) return { processed: 0, errors: ['No hay hoja en el archivo'] };
|
||||
|
||||
const headers = {};
|
||||
const headerRow = sheet.getRow(1);
|
||||
headerRow.eachCell((cell, idx) => {
|
||||
const key = String(cell.value || '').trim().toLowerCase();
|
||||
if (key) headers[key] = idx;
|
||||
});
|
||||
|
||||
const required = ['dimension', 'variable', 'subvariable'];
|
||||
for (const r of required) {
|
||||
if (!headers[r]) return { processed: 0, errors: [`Falta columna ${r}`] };
|
||||
}
|
||||
|
||||
if (mode === 'replace') {
|
||||
await pool.query('DELETE FROM climate_result_model');
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
const errors = [];
|
||||
|
||||
for (let i = 2; i <= sheet.rowCount; i++) {
|
||||
const row = sheet.getRow(i);
|
||||
const dimension = String(row.getCell(headers.dimension).value || '').trim();
|
||||
const variable = String(row.getCell(headers.variable).value || '').trim();
|
||||
const subvariable = String(row.getCell(headers.subvariable).value || '').trim();
|
||||
if (!dimension || !variable || !subvariable) {
|
||||
errors.push(`Fila ${i}: dimension, variable y subvariable son obligatorios`);
|
||||
continue;
|
||||
}
|
||||
const score_value = headers.score_value ? row.getCell(headers.score_value).value : null;
|
||||
const max_score = headers.max_score ? row.getCell(headers.max_score).value : null;
|
||||
const sort_order = headers.sort_order ? row.getCell(headers.sort_order).value : null;
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO climate_result_model (dimension, variable, subvariable, score_value, max_score, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[dimension, variable, subvariable, score_value || null, max_score || null, sort_order || 0]
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return { processed, errors };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listResultModel,
|
||||
createResultModel,
|
||||
updateResultModel,
|
||||
exportResultModelTemplate,
|
||||
importResultModelExcel
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
SOURCE tenant_schema.sql;
|
||||
SOURCE seed_questions.sql;
|
||||
SOURCE seed_result_model.sql;
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
|
||||
@@ -75,3 +75,47 @@ UPDATE survey_questions sq JOIN questions q ON q.id = sq.question_id SET sq.ques
|
||||
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)
|
||||
|
||||
-- Result model table and seed
|
||||
CREATE TABLE IF NOT EXISTS climate_result_model (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
dimension VARCHAR(120) NOT NULL,
|
||||
variable VARCHAR(120) NOT NULL,
|
||||
subvariable VARCHAR(160) NOT NULL,
|
||||
score_value DECIMAL(6,2) NULL,
|
||||
max_score DECIMAL(6,2) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT INTO climate_result_model (dimension, variable, subvariable, score_value, max_score, sort_order)
|
||||
SELECT * FROM (
|
||||
SELECT 'CREDIBILIDAD', 'Comunicación', 'Accesibilidad', NULL, 100, 1 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Comunicación', 'Información', NULL, 100, 2 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Integridad', 'Confiabilidad', NULL, 100, 3 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Integridad', 'Honestidad', NULL, 100, 4 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Liderazgo', 'Coordinación', NULL, 100, 5 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Liderazgo', 'Delegación', NULL, 100, 6 UNION ALL
|
||||
SELECT 'CREDIBILIDAD', 'Liderazgo', 'Visión', NULL, 100, 7 UNION ALL
|
||||
SELECT 'RESPETO', 'Apoyo', 'Desarrollo', NULL, 100, 8 UNION ALL
|
||||
SELECT 'RESPETO', 'Apoyo', 'Reconocimiento', NULL, 100, 9 UNION ALL
|
||||
SELECT 'RESPETO', 'Cuidado', 'Entorno de Trabajo', NULL, 100, 10 UNION ALL
|
||||
SELECT 'RESPETO', 'Cuidado', 'Vida Personal', NULL, 100, 11 UNION ALL
|
||||
SELECT 'RESPETO', 'Sinergia de Equipo', 'Participación', NULL, 100, 12 UNION ALL
|
||||
SELECT 'IMPARCIALIDAD', 'Equidad', 'Ausencia de favoritismo', NULL, 100, 13 UNION ALL
|
||||
SELECT 'IMPARCIALIDAD', 'Equidad', 'Equidad en el trato', NULL, 100, 14 UNION ALL
|
||||
SELECT 'IMPARCIALIDAD', 'Equidad', 'Equidad en la remuneración', NULL, 100, 15 UNION ALL
|
||||
SELECT 'IMPARCIALIDAD', 'Justicia', 'Capacidad de apelación', NULL, 100, 16 UNION ALL
|
||||
SELECT 'IMPARCIALIDAD', 'Justicia', 'Justicia en el trato a las personas', NULL, 100, 17 UNION ALL
|
||||
SELECT 'CAMARADERIA', 'Fraternidad', 'Compañerismo', NULL, 100, 18 UNION ALL
|
||||
SELECT 'CAMARADERIA', 'Hospitalidad', 'Hospitalidad de personas', NULL, 100, 19 UNION ALL
|
||||
SELECT 'CAMARADERIA', 'Hospitalidad', 'Hospitalidad del lugar', NULL, 100, 20 UNION ALL
|
||||
SELECT 'CAMARADERIA', 'Hospitalidad', 'Sentido de equipo', NULL, 100, 21 UNION ALL
|
||||
SELECT 'PERTENENCIA', 'Orgullo', 'Orgullo del Equipo', NULL, 100, 22 UNION ALL
|
||||
SELECT 'PERTENENCIA', 'Orgullo', 'Orgullo del Trabajo', NULL, 100, 23 UNION ALL
|
||||
SELECT 'PERTENENCIA', 'Orgullo', 'Orgullo por la Institución', NULL, 100, 24
|
||||
) AS data
|
||||
WHERE NOT EXISTS (SELECT 1 FROM climate_result_model);
|
||||
|
||||
UPDATE climate_result_model SET max_score = 100 WHERE max_score IS NULL;
|
||||
UPDATE climate_result_model SET score_value = NULL;
|
||||
|
||||
@@ -157,3 +157,14 @@ CREATE TABLE IF NOT EXISTS survey_rules (
|
||||
rule_json JSON NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS climate_result_model (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
dimension VARCHAR(120) NOT NULL,
|
||||
variable VARCHAR(120) NOT NULL,
|
||||
subvariable VARCHAR(160) NOT NULL,
|
||||
score_value DECIMAL(6,2) NULL,
|
||||
max_score DECIMAL(6,2) NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Método de presentación de resultados (basado en Rankmi) y adaptación al sistema
|
||||
|
||||
Este documento describe **cómo trabajar los resultados** de la encuesta de clima laboral en el sistema, **sin cambiar el contenido de la lista de preguntas actual**. Se toma como guía el enfoque de Rankmi de “7 pasos” y se adapta a nuestro flujo y módulos existentes. citeturn0search0
|
||||
|
||||
## Principios base
|
||||
|
||||
- La comunicación de resultados debe ir **de lo general a lo específico** y con lenguaje constructivo (enfoque “sándwich”). citeturn0search0
|
||||
- Siempre presentar **participación**, **resultados globales**, **ranking de mejores/peores**, y **prioridades de acción**. citeturn0search0
|
||||
- Los reportes deben ser **agregados** (sin identificación individual) y con **mínimos de respuestas** si aplica. citeturn0search2turn0search5
|
||||
|
||||
## 7 pasos (Rankmi) — cómo se aplica en nuestro sistema
|
||||
|
||||
1) **Agradecer**
|
||||
- Mensaje breve al cerrar la medición.
|
||||
- En PDF: incluir un párrafo de agradecimiento.
|
||||
citeturn0search0
|
||||
|
||||
2) **Recordar objetivos de la encuesta**
|
||||
- Sección fija en el PDF y en el dashboard (texto corto).
|
||||
citeturn0search0
|
||||
|
||||
3) **Informar participación**
|
||||
- Usar cálculo automático ya implementado (tasa de participación).
|
||||
- Mostrar en dashboard y PDF.
|
||||
citeturn0search0
|
||||
|
||||
4) **Resumen del proceso / histórico**
|
||||
- Mostrar comparativo con medición anterior (si existe).
|
||||
- En PDF: bloque “Histórico y acciones previas”.
|
||||
citeturn0search0
|
||||
|
||||
5) **Resultados generales**
|
||||
- Índice global, promedio por dimensión, clasificación (Bajo/Medio/Alto).
|
||||
- Se explica brevemente cómo se calculó (promedios).
|
||||
citeturn0search0
|
||||
|
||||
6) **Mejor y peor evaluado**
|
||||
- Ranking de dimensiones y/o subvariables.
|
||||
- En PDF: “Top fortalezas” y “Principales oportunidades”.
|
||||
citeturn0search0
|
||||
|
||||
7) **Prioridades / plan de acción**
|
||||
- En PDF: listado de 3–5 prioridades sugeridas según resultados.
|
||||
- Deben ser medibles y trazables.
|
||||
citeturn0search0
|
||||
|
||||
## Qué NO se cambia
|
||||
|
||||
- **La lista de preguntas actual permanece igual**.
|
||||
- Solo cambia la forma de **presentar y modelar resultados**.
|
||||
|
||||
## Cómo quedamos en el sistema (Fase 1)
|
||||
|
||||
- Resultados automáticos:
|
||||
- Promedios por pregunta, dimensión, global.
|
||||
- Clasificación Bajo/Medio/Alto.
|
||||
- Tasa de participación.
|
||||
- Reportes:
|
||||
- PDF con logo, código de medición, resumen ejecutivo, resultados por dimensión, históricos.
|
||||
- Excel con crudos + consolidados.
|
||||
|
||||
## Estructura de “Matriz” (modelo base)
|
||||
|
||||
Se usa la tabla `climate_result_model` como plantilla editable:
|
||||
- **dimension** / **variable** / **subvariable**
|
||||
- **max_score** (por defecto 100)
|
||||
- **score_value** se llena solo cuando hay resultados (no visible en matriz de configuración).
|
||||
|
||||
## Recomendación de flujo operativo
|
||||
|
||||
1) Crear medición y activar encuesta.
|
||||
2) Cerrar medición (se calculan resultados).
|
||||
3) Revisar dashboard (global, dimensión, histórico).
|
||||
4) Generar PDF y Excel.
|
||||
5) Presentar resultados siguiendo los 7 pasos.
|
||||
|
||||
## Nota sobre resultados por área (futuro)
|
||||
|
||||
Rankmi recomienda visualización por área/jerarquía y mínimos de respuestas.
|
||||
Podemos integrar en Fase 2 (si se requiere). citeturn0search1turn0search5
|
||||
@@ -51,6 +51,11 @@ Proyecto en `/var/www/react/clima_laboral` con backend Node/Express y frontend R
|
||||
- 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).
|
||||
|
||||
## Credenciales (recordatorio)
|
||||
- Superadmin: `superadmin@demo.com` / `SuperAdmin2026`
|
||||
- Admin: `admin.prueba@demo.com` / `Admin2026!` (company_code: `123456`)
|
||||
- User: `usuario.prueba@demo.com` / `Usuario2026!` (company_code: `123456`)
|
||||
|
||||
## 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.
|
||||
@@ -60,6 +65,13 @@ Proyecto en `/var/www/react/clima_laboral` con backend Node/Express y frontend R
|
||||
- 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`.
|
||||
- Repo Git creado y primer commit limpio (sin node_modules ni token).
|
||||
|
||||
## Git / Repo
|
||||
- Gitea: `https://gitea.linkedcorp.net/`
|
||||
- Repo: `mgonzalez/claboral.linkedcorp.net`
|
||||
- Rama: `main`
|
||||
- Remote: `origin`
|
||||
|
||||
## Pendientes
|
||||
- A gusto del usuario, agregar etiqueta “Escala 1–5” visible en encuestas/preguntas.
|
||||
|
||||
Generated
+914
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,6 @@ 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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles/app.css';
|
||||
import './styles/index.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
|
||||
@@ -9,7 +9,7 @@ export default function AdminDashboard() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="grid">
|
||||
<div className="grid-layout">
|
||||
<div className="card">
|
||||
<h2>Índice Global</h2>
|
||||
<p>{data.current ? `${data.current.score} (${data.current.classification})` : 'Sin datos'}</p>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../utils/api';
|
||||
|
||||
export default function QuestionBank() {
|
||||
const navigate = useNavigate();
|
||||
const [questions, setQuestions] = useState([]);
|
||||
const [sets, setSets] = useState([]);
|
||||
const [file, setFile] = useState(null);
|
||||
@@ -16,6 +18,9 @@ export default function QuestionBank() {
|
||||
const [newQuestionText, setNewQuestionText] = useState('');
|
||||
const [newSetInfo, setNewSetInfo] = useState(null);
|
||||
const companyCode = localStorage.getItem('company_code');
|
||||
const [resultModel, setResultModel] = useState([]);
|
||||
const [matrixFile, setMatrixFile] = useState(null);
|
||||
const [newMatrixRow, setNewMatrixRow] = useState({ dimension: '', variable: '', subvariable: '', max_score: '', sort_order: '' });
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
@@ -38,7 +43,6 @@ export default function QuestionBank() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!companyCode) {
|
||||
window.dispatchEvent(new CustomEvent('app:notice', { detail: { type: 'error', message: 'Selecciona una empresa antes de entrar a Preguntas.' } }));
|
||||
return;
|
||||
}
|
||||
load();
|
||||
@@ -95,6 +99,51 @@ export default function QuestionBank() {
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const loadResultModel = async () => {
|
||||
const res = await api.get('/admin/result-model');
|
||||
setResultModel(res.data);
|
||||
};
|
||||
|
||||
const downloadMatrixTemplate = async () => {
|
||||
const res = await api.get('/admin/result-model/template', { responseType: 'blob' });
|
||||
const url = window.URL.createObjectURL(new Blob([res.data]));
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'clima_result_model.xlsx';
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const importMatrix = async () => {
|
||||
if (!matrixFile) return;
|
||||
const form = new FormData();
|
||||
form.append('file', matrixFile);
|
||||
form.append('mode', 'replace');
|
||||
const res = await api.post('/admin/result-model/import', form, { headers: { 'Content-Type': 'multipart/form-data' } });
|
||||
setMsg(`Matriz: ${res.data.processed} filas. Errores: ${res.data.errors.length}`);
|
||||
await loadResultModel();
|
||||
};
|
||||
|
||||
const addMatrixRow = async () => {
|
||||
const { dimension, variable, subvariable } = newMatrixRow;
|
||||
if (!dimension || !variable || !subvariable) return;
|
||||
const payload = {
|
||||
...newMatrixRow,
|
||||
max_score: newMatrixRow.max_score === '' ? 100 : newMatrixRow.max_score
|
||||
};
|
||||
await api.post('/admin/result-model', payload);
|
||||
setNewMatrixRow({ dimension: '', variable: '', subvariable: '', max_score: '', sort_order: '' });
|
||||
await loadResultModel();
|
||||
};
|
||||
|
||||
const saveMatrixRow = async (row) => {
|
||||
await api.patch(`/admin/result-model/${row.id}`, {
|
||||
max_score: row.max_score === '' ? null : row.max_score,
|
||||
sort_order: row.sort_order === '' ? null : row.sort_order
|
||||
});
|
||||
await loadResultModel();
|
||||
};
|
||||
|
||||
const createDimension = async () => {
|
||||
if (!newDimName) return;
|
||||
const res = await api.post('/admin/dimensions', { name: newDimName });
|
||||
@@ -116,6 +165,9 @@ export default function QuestionBank() {
|
||||
<div className="card">
|
||||
<h2>Preguntas</h2>
|
||||
<p>Selecciona una empresa para ver las versiones de encuesta.</p>
|
||||
<button className="action-btn action-save" title="Ir a Empresas" aria-label="Ir a Empresas" onClick={() => navigate('/superadmin')}>
|
||||
<i className="fa-solid fa-building" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,6 +177,7 @@ export default function QuestionBank() {
|
||||
<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 === 'matrix' ? 'active' : ''} onClick={() => { setView('matrix'); setMsg(''); loadResultModel(); }} title="Matriz" aria-label="Matriz"><i className="fa-solid fa-table-cells" /></button>
|
||||
<button
|
||||
className={view === 'newset' ? 'active' : ''}
|
||||
onClick={() => {
|
||||
@@ -269,6 +322,56 @@ export default function QuestionBank() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'matrix' && (
|
||||
<div className="card">
|
||||
<h2>Matriz de Clima Laboral</h2>
|
||||
<div>
|
||||
<button className="action-btn action-report" title="Template Matriz" aria-label="Template Matriz" onClick={downloadMatrixTemplate}>
|
||||
<i className="fa-solid fa-file-arrow-down" />
|
||||
</button>
|
||||
{' '}
|
||||
<input type="file" onChange={(e) => setMatrixFile(e.target.files[0])} />
|
||||
<button className="action-btn action-report" title="Importar Matriz" aria-label="Importar Matriz" onClick={importMatrix}>
|
||||
<i className="fa-solid fa-file-import" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<input placeholder="Dimensión" value={newMatrixRow.dimension} onChange={(e) => setNewMatrixRow({ ...newMatrixRow, dimension: e.target.value })} />
|
||||
<input placeholder="Variable" value={newMatrixRow.variable} onChange={(e) => setNewMatrixRow({ ...newMatrixRow, variable: e.target.value })} />
|
||||
<input placeholder="Subvariable" value={newMatrixRow.subvariable} onChange={(e) => setNewMatrixRow({ ...newMatrixRow, subvariable: e.target.value })} />
|
||||
<input placeholder="Máximo" value={newMatrixRow.max_score} onChange={(e) => setNewMatrixRow({ ...newMatrixRow, max_score: e.target.value })} />
|
||||
<input placeholder="Orden" value={newMatrixRow.sort_order} onChange={(e) => setNewMatrixRow({ ...newMatrixRow, sort_order: e.target.value })} />
|
||||
<button className="action-btn action-save" title="Agregar" aria-label="Agregar" onClick={addMatrixRow}>
|
||||
<i className="fa-solid fa-plus" />
|
||||
</button>
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Dimensión</th><th>Variable</th><th>Subvariable</th><th>Máximo</th><th>Orden</th><th>Guardar</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{resultModel.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{row.dimension}</td>
|
||||
<td>{row.variable}</td>
|
||||
<td>{row.subvariable}</td>
|
||||
<td><input value={row.max_score ?? ''} onChange={(e) => setResultModel(resultModel.map(r => r.id === row.id ? { ...r, max_score: e.target.value } : r))} /></td>
|
||||
<td><input value={row.sort_order ?? ''} onChange={(e) => setResultModel(resultModel.map(r => r.id === row.id ? { ...r, sort_order: e.target.value } : r))} /></td>
|
||||
<td>
|
||||
<button className="action-btn action-save" title="Guardar" aria-label="Guardar" onClick={() => saveMatrixRow(row)}>
|
||||
<i className="fa-solid fa-floppy-disk" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{msg && <p>{msg}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'editset' && newSetInfo && (
|
||||
<div className="card">
|
||||
<h2>Editar set: {newSetInfo.code}</h2>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function SuperadminCompanies() {
|
||||
</div>
|
||||
|
||||
{view === 'new' && (
|
||||
<div className="grid">
|
||||
<div className="grid-layout">
|
||||
<div className="card">
|
||||
<h2>Nueva Empresa</h2>
|
||||
<label>Nombre</label>
|
||||
|
||||
@@ -31,7 +31,7 @@ export default function SurveyPeriods() {
|
||||
</div>
|
||||
|
||||
{view === 'new' && (
|
||||
<div className="grid">
|
||||
<div className="grid-layout">
|
||||
<div className="card">
|
||||
<h2>Nueva Medición</h2>
|
||||
<label>Año</label>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
@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,198 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Work+Sans:wght@400;500;600&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--brand-50: #f4f8ff;
|
||||
--brand-100: #dfeafe;
|
||||
--brand-200: #b9d3ff;
|
||||
--brand-300: #8ab3ff;
|
||||
--brand-400: #5d91ff;
|
||||
--brand-500: #3b6cff;
|
||||
--brand-600: #2c53d3;
|
||||
--brand-700: #233da3;
|
||||
--brand-800: #1f2f73;
|
||||
--brand-900: #1a2552;
|
||||
--erp-font-family-base: 'Work Sans', sans-serif;
|
||||
--erp-title-size: 20px;
|
||||
--erp-title-weight: 600;
|
||||
--erp-title-style: normal;
|
||||
--erp-subtitle-size: 14px;
|
||||
--erp-subtitle-weight: 400;
|
||||
--erp-subtitle-style: normal;
|
||||
--erp-field-label-size: 12px;
|
||||
--erp-field-label-weight: 500;
|
||||
--erp-field-label-style: normal;
|
||||
--erp-field-input-size: 12px;
|
||||
--erp-field-input-weight: 400;
|
||||
--erp-field-input-style: normal;
|
||||
--erp-table-header-size: 11px;
|
||||
--erp-table-header-weight: 600;
|
||||
--erp-table-header-style: normal;
|
||||
--erp-table-cell-size: 12px;
|
||||
--erp-table-cell-weight: 400;
|
||||
--erp-table-cell-style: normal;
|
||||
--erp-button-size: 12px;
|
||||
--erp-button-weight: 600;
|
||||
--erp-button-style: normal;
|
||||
--erp-menu-size: 12px;
|
||||
--erp-menu-weight: 600;
|
||||
--erp-menu-style: normal;
|
||||
--erp-card-title-size: 13px;
|
||||
--erp-card-title-weight: 600;
|
||||
--erp-card-title-style: normal;
|
||||
--erp-body-size: 12px;
|
||||
--erp-body-weight: 400;
|
||||
--erp-body-style: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--erp-font-family-base);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 108, 255, 0.2), transparent 40%),
|
||||
radial-gradient(circle at bottom right, rgba(26, 37, 82, 0.25), transparent 45%),
|
||||
linear-gradient(120deg, rgba(248, 250, 252, 0.9), rgba(241, 245, 249, 0.85));
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.layout {
|
||||
@apply min-h-screen;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
@apply sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white/90 px-7 py-4 backdrop-blur;
|
||||
}
|
||||
|
||||
.brand {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: var(--erp-title-size);
|
||||
font-weight: var(--erp-title-weight);
|
||||
font-style: var(--erp-title-style);
|
||||
}
|
||||
|
||||
.topbar nav a,
|
||||
.topbar nav button {
|
||||
@apply ml-3 inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm transition;
|
||||
}
|
||||
|
||||
.topbar nav a.active {
|
||||
@apply bg-brand-500 text-white;
|
||||
}
|
||||
|
||||
.content {
|
||||
@apply px-7 py-6;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply mb-4 rounded-2xl border border-slate-200 bg-white/90 p-5 shadow-sm;
|
||||
}
|
||||
|
||||
.login-image {
|
||||
@apply flex min-h-[240px] items-center justify-center;
|
||||
}
|
||||
|
||||
.login-image img {
|
||||
@apply w-full max-w-sm rounded-xl border border-slate-200 bg-white;
|
||||
}
|
||||
|
||||
.grid-layout {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-4;
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: var(--erp-button-size);
|
||||
font-weight: var(--erp-button-weight);
|
||||
font-style: var(--erp-button-style);
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-size: var(--erp-field-input-size);
|
||||
font-weight: var(--erp-field-input-weight);
|
||||
font-style: var(--erp-field-input-style);
|
||||
@apply w-full rounded-xl border border-slate-200 px-3 py-2;
|
||||
}
|
||||
|
||||
label {
|
||||
font-size: var(--erp-field-label-size);
|
||||
font-weight: var(--erp-field-label-weight);
|
||||
font-style: var(--erp-field-label-style);
|
||||
@apply text-slate-500;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-size: var(--erp-table-header-size);
|
||||
font-weight: var(--erp-table-header-weight);
|
||||
font-style: var(--erp-table-header-style);
|
||||
@apply border-b border-slate-200 px-3 py-2 text-left text-slate-500;
|
||||
}
|
||||
|
||||
.table td {
|
||||
font-size: var(--erp-table-cell-size);
|
||||
font-weight: var(--erp-table-cell-weight);
|
||||
font-style: var(--erp-table-cell-style);
|
||||
@apply border-b border-slate-100 px-3 py-2 text-left;
|
||||
}
|
||||
|
||||
.subnav {
|
||||
@apply mb-4 flex gap-3 rounded-2xl border border-slate-200 bg-white/90 px-4 py-3 shadow-sm;
|
||||
}
|
||||
|
||||
.subnav button {
|
||||
@apply inline-flex h-9 w-9 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-700 shadow-sm;
|
||||
}
|
||||
|
||||
.subnav button.active {
|
||||
@apply bg-brand-500 text-white;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@apply inline-flex h-10 w-10 items-center justify-center rounded-full border border-transparent shadow-sm;
|
||||
}
|
||||
|
||||
.action-btn i {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.action-suspend {
|
||||
@apply bg-amber-400 text-amber-950;
|
||||
}
|
||||
|
||||
.action-activate {
|
||||
@apply bg-emerald-400 text-emerald-950;
|
||||
}
|
||||
|
||||
.action-save {
|
||||
@apply bg-brand-500 text-white;
|
||||
}
|
||||
|
||||
.action-report {
|
||||
@apply bg-sky-600 text-white;
|
||||
}
|
||||
|
||||
.action-delete {
|
||||
@apply bg-rose-600 text-white;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply rounded-full bg-blue-50 px-2 py-1 text-xs font-semibold text-blue-700;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
@apply fixed inset-0 z-50 flex items-center justify-center bg-black/40;
|
||||
}
|
||||
|
||||
.modal {
|
||||
@apply w-[min(420px,90vw)] rounded-2xl border border-slate-200 bg-white p-6 shadow-xl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
display: ['"Space Grotesk"', 'sans-serif'],
|
||||
body: ['"Work Sans"', 'sans-serif']
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: 'var(--brand-50)',
|
||||
100: 'var(--brand-100)',
|
||||
200: 'var(--brand-200)',
|
||||
300: 'var(--brand-300)',
|
||||
400: 'var(--brand-400)',
|
||||
500: 'var(--brand-500)',
|
||||
600: 'var(--brand-600)',
|
||||
700: 'var(--brand-700)',
|
||||
800: 'var(--brand-800)',
|
||||
900: 'var(--brand-900)'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
Binary file not shown.
Reference in New Issue
Block a user