Initial commit
This commit is contained in:
@@ -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'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user