feat(ui): migrate to Tailwind UI and add result matrix tooling

This commit is contained in:
2026-03-03 22:54:06 -05:00
parent 1592434192
commit 87f52777c4
21 changed files with 1575 additions and 263 deletions
+61
View File
@@ -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
View File
@@ -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;
+11
View File
@@ -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
);
+80
View File
@@ -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. citeturn0search0
## Principios base
- La comunicación de resultados debe ir **de lo general a lo específico** y con lenguaje constructivo (enfoque “sándwich”). citeturn0search0
- Siempre presentar **participación**, **resultados globales**, **ranking de mejores/peores**, y **prioridades de acción**. citeturn0search0
- Los reportes deben ser **agregados** (sin identificación individual) y con **mínimos de respuestas** si aplica. citeturn0search2turn0search5
## 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.
citeturn0search0
2) **Recordar objetivos de la encuesta**
- Sección fija en el PDF y en el dashboard (texto corto).
citeturn0search0
3) **Informar participación**
- Usar cálculo automático ya implementado (tasa de participación).
- Mostrar en dashboard y PDF.
citeturn0search0
4) **Resumen del proceso / histórico**
- Mostrar comparativo con medición anterior (si existe).
- En PDF: bloque “Histórico y acciones previas”.
citeturn0search0
5) **Resultados generales**
- Índice global, promedio por dimensión, clasificación (Bajo/Medio/Alto).
- Se explica brevemente cómo se calculó (promedios).
citeturn0search0
6) **Mejor y peor evaluado**
- Ranking de dimensiones y/o subvariables.
- En PDF: “Top fortalezas” y “Principales oportunidades”.
citeturn0search0
7) **Prioridades / plan de acción**
- En PDF: listado de 3–5 prioridades sugeridas según resultados.
- Deben ser medibles y trazables.
citeturn0search0
## 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). citeturn0search1turn0search5
+12
View File
@@ -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.
+914
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
-1
View File
@@ -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 -1
View File
@@ -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 />);
+1 -1
View File
@@ -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>
+104 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
-257
View File
@@ -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);
}
+198
View File
@@ -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;
}
}
+27
View File
@@ -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.