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
+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: []
};