feat: initialize project with Supabase authentication, routing, and dashboard screens for project data visualization

This commit is contained in:
2026-07-13 15:36:58 -04:00
parent 2e4031e14a
commit 210063198b
10 changed files with 590 additions and 106 deletions
+21 -4
View File
@@ -2,23 +2,40 @@ import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Dashboard from './screens/DashboardTerminacion';
import DashboardCarnet from './screens/DashboardCarnet';
import Login from './screens/Login';
import ProtectedRoute from './components/ProtectedRoute';
export default function App() {
return (
<Router>
<Routes>
<Route path="/dashboard/login" element={<Login />} />
<Route
path="/dashboard/encuesta-terminacion"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
<Route
path="/dashboard/carnet"
element={
<ProtectedRoute>
<DashboardCarnet />
</ProtectedRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard/encuesta-terminacion" replace />} />
<Route path="/dashboard/encuesta-terminacion" element={<Dashboard />} />
<Route path="/dashboard/carnet" element={<DashboardCarnet />} />
<Route
path="*"
element={
<div className="flex flex-col items-center justify-center min-h-screen bg-gris-claro text-gris-oscuro p-6">
<div className="bg-white p-8 rounded-2xl shadow-sm border border-gris-medio/30 text-center max-w-md w-full">
<h1 className="text-6xl font-extrabold text-azul-glm mb-4">404</h1>
<h2 className="text-xl font-bold text-gris-oscuro mb-2">Página no encontrada</h2>
<h2 className="text-xl font-bold text-gris-oscuro mb-2">Pagina no encontrada</h2>
<p className="text-acero mb-6">
La página que buscas no existe o ha sido movida.
La pagina que buscas no existe o ha sido movida.
</p>
<a
href="/dashboard/encuesta-terminacion"
+5 -1
View File
@@ -1,13 +1,16 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
const linkBase = 'font-semibold text-sm py-2.5 px-5 rounded-lg transition-colors text-center flex-1';
const linkActive = 'bg-azul-glm text-white';
const linkInactive = 'bg-verde-bg text-azul-glm hover:bg-verde-glm hover:text-white border border-verde-glm/40';
export default function NavBar() {
const { signOut, user } = useAuth();
return (
<nav className="flex gap-3 bg-white p-3 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
<nav className="flex items-center gap-3 bg-white p-3 rounded-xl border border-gris-medio/30 shadow-sm mb-8">
<NavLink
to="/dashboard/encuesta-terminacion"
className={({ isActive }) => `${linkBase} ${isActive ? linkActive : linkInactive}`}
@@ -20,6 +23,7 @@ export default function NavBar() {
>
Carnets
</NavLink>
</nav>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { Navigate } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext';
export default function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gris-claro">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin" />
<p className="text-acero font-medium">Cargando...</p>
</div>
</div>
);
}
if (!user) {
return <Navigate to="/dashboard/login" replace />;
}
return children;
}
+73
View File
@@ -0,0 +1,73 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '../services/supabaseClient';
const AuthContext = createContext(null);
const ALLOWED_DOMAIN = 'gomezleemarketing.com';
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user) {
const email = session.user.email || '';
if (email.endsWith(`@${ALLOWED_DOMAIN}`)) {
setUser(session.user);
} else {
supabase.auth.signOut();
setUser(null);
}
}
setLoading(false);
});
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
if (session?.user) {
const email = session.user.email || '';
if (email.endsWith(`@${ALLOWED_DOMAIN}`)) {
setUser(session.user);
} else {
supabase.auth.signOut();
setUser(null);
}
} else {
setUser(null);
}
setLoading(false);
});
return () => subscription.unsubscribe();
}, []);
const signInWithGoogle = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/dashboard/encuesta-terminacion`,
queryParams: {
hd: ALLOWED_DOMAIN,
},
},
});
if (error) throw error;
};
const signOut = async () => {
await supabase.auth.signOut();
setUser(null);
};
return (
<AuthContext.Provider value={{ user, loading, signInWithGoogle, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within AuthProvider');
return context;
}
+3
View File
@@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
import { AuthProvider } from './contexts/AuthContext'
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)
+38 -67
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '../services/supabaseClient';
import { supabaseData } from '../services/supabaseClient';
import { BarChart, Bar, Cell, XAxis, YAxis, Tooltip, Legend, ResponsiveContainer, CartesianGrid } from 'recharts';
import NavBar from '../components/NavBar';
@@ -8,31 +8,40 @@ const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#
export default function DashboardCarnet() {
const [rawData, setRawData] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [clientes, setClientes] = useState([]);
const [filtroCliente, setFiltroCliente] = useState('');
const [paginaActual, setPaginaActual] = useState(1);
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
setPaginaActual(1);
const uniqueClientes = [...new Set(rawData.map(item => item.cliente_proyecto).filter(Boolean))];
setClientes(uniqueClientes);
}, [rawData]);
const fetchData = async () => {
setLoading(true);
const { data, error } = await supabase
setError(null);
console.log('[DashboardCarnet] Iniciando fetch...');
const { data, error: fetchError } = await supabaseData
.from('carnet_empleados_creados_glm')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('Error fetching data:', error);
if (fetchError) {
console.error('[DashboardCarnet] Error de Supabase:', {
message: fetchError.message,
details: fetchError.details,
hint: fetchError.hint,
code: fetchError.code,
});
setError(fetchError.message || 'Error al cargar los datos de carnets.');
} else {
setRawData(data);
console.log('[DashboardCarnet] Datos recibidos:', data?.length ?? 0, 'registros');
setRawData(data ?? []);
}
setLoading(false);
};
@@ -55,12 +64,29 @@ export default function DashboardCarnet() {
fill: CHART_PALETTE[index % CHART_PALETTE.length]
}));
const filasPorPagina = 20;
const totalPaginas = Math.ceil(datosFiltrados.length / filasPorPagina) || 1;
const indiceUltimoItem = paginaActual * filasPorPagina;
const indicePrimerItem = indiceUltimoItem - filasPorPagina;
if (loading) return (
<div className="p-10 text-center">
<div className="inline-block w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-xl font-semibold text-azul-glm">Cargando datos...</p>
</div>
);
if (loading) return <div className="p-10 text-center text-xl font-semibold text-azul-glm">Cargando datos...</div>;
if (error) return (
<div className="p-10 max-w-xl mx-auto min-h-screen flex items-center justify-center">
<div className="bg-white p-8 rounded-2xl border border-red-200 shadow-sm text-center w-full">
<div className="text-red-500 text-4xl mb-4"></div>
<h2 className="text-xl font-bold text-red-700 mb-2">Error al cargar datos</h2>
<p className="text-sm text-gray-600 mb-1">{error}</p>
<p className="text-xs text-gray-400 mb-6">Revisa la consola del navegador (F12) para más detalles.</p>
<button
onClick={fetchData}
className="bg-azul-glm hover:bg-azul-medio text-white font-semibold py-2.5 px-6 rounded-lg transition-colors"
>
Reintentar
</button>
</div>
</div>
);
return (
<div className="p-6 max-w-7xl mx-auto min-h-screen">
@@ -148,61 +174,6 @@ export default function DashboardCarnet() {
</ResponsiveContainer>
</div>
</div>
{/* TABLA GLM */}
<div className="bg-white rounded-xl border border-gris-medio/30 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg">
<h3 className="text-base font-bold text-azul-glm">Detalle de Carnets</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gris-medio/50 text-sm">
<thead className="bg-azul-glm">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Fecha</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Cliente / Proyecto</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Carnets</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gris-medio/30">
{chartDataCliente.map((row, idx) => (
<tr key={idx} className={`${idx % 2 === 0 ? 'bg-white' : 'bg-gris-claro'} hover:bg-verde-bg/50 transition-colors`}>
<td className="px-6 py-4 whitespace-nowrap text-acero" colSpan={2}>{row.name}</td>
<td className="px-6 py-4 whitespace-nowrap font-semibold text-azul-glm">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Controles de Pagina */}
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-6 py-4 border-t border-gris-medio/30 bg-gris-claro/50">
<div className="text-xs text-acero font-medium">
{datosFiltrados.length > 0
? `Mostrando registros ${indicePrimerItem + 1} - ${Math.min(indiceUltimoItem, datosFiltrados.length)} de ${datosFiltrados.length}`
: 'No hay registros para mostrar'
}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPaginaActual(prev => Math.max(prev - 1, 1))}
disabled={paginaActual === 1}
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
>
Atrás
</button>
<span className="text-xs text-gris-oscuro font-bold">
Página {paginaActual} de {totalPaginas}
</span>
<button
onClick={() => setPaginaActual(prev => Math.min(prev + 1, totalPaginas))}
disabled={paginaActual === totalPaginas}
className="px-3 py-1.5 rounded-lg border border-gris-medio text-xs font-semibold text-azul-glm bg-white hover:bg-gris-claro disabled:opacity-50 disabled:cursor-not-allowed transition-all cursor-pointer"
>
Siguiente
</button>
</div>
</div>
</div>
</div>
);
}
+317 -29
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { supabase } from '../services/supabaseClient';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import React, { useState, useEffect, useMemo } from 'react';
import { supabaseData } from '../services/supabaseClient';
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid } from 'recharts';
import NavBar from '../components/NavBar';
const GLM_COLORS = { positivo: '#6CC24A', neutro: '#F0C75E', negativo: '#FF6A13' };
@@ -9,15 +9,30 @@ const CHART_PALETTE = ['#6CC24A', '#4F758B', '#A4D65E', '#5B7F95', '#C4D600', '#
export default function Dashboard() {
const [rawData, setRawData] = useState([]);
const [filteredData, setFilteredData] = useState([]);
const [encuestasEnviadas, setEncuestasEnviadas] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [posiciones, setPosiciones] = useState([]);
const [homologaciones, setHomologaciones] = useState([]);
const [paises, setPaises] = useState([]);
const opcionesUnicas = (campo) => [
...new Set(
rawData
.map(item => (item?.[campo] ?? '').toString().trim())
.filter(v => v !== '')
),
].sort((a, b) => a.localeCompare(b, 'es'));
const posiciones = useMemo(() => opcionesUnicas('posicion'), [rawData]);
const homologaciones = useMemo(() => opcionesUnicas('homologacion_ia'), [rawData]);
const paises = useMemo(() => opcionesUnicas('pais'), [rawData]);
const cuentas = useMemo(() => opcionesUnicas('cuenta_proyecto'), [rawData]);
const jefes = useMemo(() => opcionesUnicas('jefe_directo'), [rawData]);
const [filtroPosicion, setFiltroPosicion] = useState('');
const [filtroHomologacion, setFiltroHomologacion] = useState('');
const [filtroPais, setFiltroPais] = useState('');
const [filtroCuenta, setFiltroCuenta] = useState('');
const [filtroJefe, setFiltroJefe] = useState('');
const [busquedaNombre, setBusquedaNombre] = useState('');
const [paginaActual, setPaginaActual] = useState(1);
@@ -28,26 +43,45 @@ export default function Dashboard() {
useEffect(() => {
aplicarFiltros();
setPaginaActual(1);
}, [filtroPosicion, filtroHomologacion, filtroPais, rawData]);
}, [filtroPosicion, filtroHomologacion, filtroPais, filtroCuenta, filtroJefe, rawData]);
const fetchData = async () => {
setLoading(true);
const { data, error } = await supabase
setError(null);
console.log('[DashboardTerminacion] Iniciando fetch...');
const { data, error: fetchError } = await supabaseData
.from('encuesta_terminacion_glm')
.select('*')
.order('timestamp', { ascending: false });
if (error) {
console.error('Error fetching data:', error);
if (fetchError) {
console.error('[DashboardTerminacion] Error de Supabase:', {
message: fetchError.message,
details: fetchError.details,
hint: fetchError.hint,
code: fetchError.code,
});
setError(fetchError.message || 'Error al cargar los datos de encuestas.');
} else {
setRawData(data);
const uniquePosiciones = [...new Set(data.map(item => item.posicion).filter(Boolean))];
const uniqueHomologaciones = [...new Set(data.map(item => item.homologacion_ia).filter(Boolean))];
const uniquePaises = [...new Set(data.map(item => item.pais).filter(Boolean))];
setPosiciones(uniquePosiciones);
setHomologaciones(uniqueHomologaciones);
setPaises(uniquePaises);
console.log('[DashboardTerminacion] Datos recibidos:', data?.length ?? 0, 'registros');
setRawData(data ?? []);
}
const { data: kpiData, error: kpiError } = await supabaseData
.from('encuesta_terminacion_kpi_glm')
.select('encuestas_enviadas');
if (kpiError) {
console.error('[DashboardTerminacion] Error al cargar KPI:', kpiError.message);
} else {
const totalEnviadas = (kpiData ?? []).reduce(
(acc, curr) => acc + (Number(curr.encuestas_enviadas) || 0),
0
);
setEncuestasEnviadas(totalEnviadas);
}
setLoading(false);
};
@@ -56,6 +90,8 @@ export default function Dashboard() {
if (filtroPosicion) result = result.filter(item => item.posicion === filtroPosicion);
if (filtroHomologacion) result = result.filter(item => item.homologacion_ia === filtroHomologacion);
if (filtroPais) result = result.filter(item => item.pais === filtroPais);
if (filtroCuenta) result = result.filter(item => item.cuenta_proyecto === filtroCuenta);
if (filtroJefe) result = result.filter(item => item.jefe_directo === filtroJefe);
// Ordenar por fecha de más reciente a más vieja
result = [...result].sort((a, b) => {
@@ -67,15 +103,26 @@ export default function Dashboard() {
setFilteredData(result);
};
const filasPorPagina = 20;
const filasPorPagina = 10;
const totalPaginas = Math.ceil(filteredData.length / filasPorPagina) || 1;
const indiceUltimoItem = paginaActual * filasPorPagina;
const indicePrimerItem = indiceUltimoItem - filasPorPagina;
const datosPaginados = filteredData.slice(indicePrimerItem, indiceUltimoItem);
const datosFiltradosTabla = busquedaNombre.trim()
? filteredData.filter(item =>
(item?.nombre ?? '').toString().toLowerCase().includes(busquedaNombre.trim().toLowerCase())
)
: filteredData;
const totalRegistrosTabla = datosFiltradosTabla.length;
const datosPaginados = datosFiltradosTabla.slice(indicePrimerItem, indiceUltimoItem);
const totalRegistros = filteredData.length;
const regresarian = filteredData.filter(d => d.considerar_regresar).length;
const recomendarian = filteredData.filter(d => d.siento_comodo_recomendando).length;
const porcentajeAprobacion = encuestasEnviadas > 0
? Math.round((totalRegistros / encuestasEnviadas) * 100)
: 0;
const esVerdadero = (v) => v === true || String(v).toLowerCase().trim() === 'true';
const regresarian = filteredData.filter(d => esVerdadero(d.considerar_regresar)).length;
const recomendarian = filteredData.filter(d => esVerdadero(d.siento_comodo_recomendando)).length;
const sentimientoCount = filteredData.reduce((acc, curr) => {
const sent = curr.sentimiento_ia || 'neutro';
@@ -88,7 +135,66 @@ export default function Dashboard() {
value: sentimientoCount[key]
}));
if (loading) return <div className="p-10 text-center text-xl font-semibold text-azul-glm">Cargando datos...</div>;
const pendientes = Math.max(encuestasEnviadas - totalRegistros, 0);
const chartDataAprobacion = [
{ name: 'Completadas', value: totalRegistros },
{ name: 'Pendientes', value: pendientes },
];
const esFalso = (v) => v === false || String(v).toLowerCase().trim() === 'false';
const chartDataRegresar = [
{ name: 'Sí', value: filteredData.filter(d => esVerdadero(d.considerar_regresar)).length },
{ name: 'No', value: filteredData.filter(d => esFalso(d.considerar_regresar)).length },
];
const chartDataRecomendar = [
{ name: 'Sí', value: filteredData.filter(d => esVerdadero(d.siento_comodo_recomendando)).length },
{ name: 'No', value: filteredData.filter(d => esFalso(d.siento_comodo_recomendando)).length },
];
const contarPorCampo = (campo) => {
const conteo = filteredData.reduce((acc, curr) => {
const valor = (curr?.[campo] ?? '').toString().trim() || 'Sin especificar';
acc[valor] = (acc[valor] || 0) + 1;
return acc;
}, {});
return Object.keys(conteo)
.sort((a, b) => conteo[b] - conteo[a])
.map((key, index) => ({
name: key,
value: conteo[key],
fill: CHART_PALETTE[index % CHART_PALETTE.length],
}));
};
const chartDataMotivoSalida = contarPorCampo('homologacion_ia');
const chartDataPorPais = contarPorCampo('pais');
const chartDataPorProyecto = contarPorCampo('cuenta_proyecto');
if (loading) return (
<div className="p-10 text-center">
<div className="inline-block w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin mb-4" />
<p className="text-xl font-semibold text-azul-glm">Cargando datos...</p>
</div>
);
if (error) return (
<div className="p-10 max-w-xl mx-auto min-h-screen flex items-center justify-center">
<div className="bg-white p-8 rounded-2xl border border-red-200 shadow-sm text-center w-full">
<div className="text-red-500 text-4xl mb-4"></div>
<h2 className="text-xl font-bold text-red-700 mb-2">Error al cargar datos</h2>
<p className="text-sm text-gray-600 mb-1">{error}</p>
<p className="text-xs text-gray-400 mb-6">Revisa la consola del navegador (F12) para más detalles.</p>
<button
onClick={fetchData}
className="bg-azul-glm hover:bg-azul-medio text-white font-semibold py-2.5 px-6 rounded-lg transition-colors"
>
Reintentar
</button>
</div>
</div>
);
return (
<div className="p-6 max-w-7xl mx-auto min-h-screen">
@@ -112,7 +218,7 @@ export default function Dashboard() {
<NavBar />
{/* FILTROS GLM */}
<div className="flex flex-col md:flex-row gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8 bg-white p-5 rounded-xl border border-gris-medio/30 shadow-sm">
<div className="flex-1">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Posición</label>
<select
@@ -155,10 +261,38 @@ export default function Dashboard() {
</select>
</div>
<div className="flex-1">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Cuenta/Proyecto</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroCuenta}
onChange={(e) => setFiltroCuenta(e.target.value)}
>
<option value="">Todas las cuentas/proyectos</option>
{cuentas.map((cuenta, idx) => (
<option key={idx} value={cuenta}>{cuenta}</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-xs font-bold text-azul-glm uppercase tracking-wider mb-1">Filtrar por Jefe Directo</label>
<select
className="w-full rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
value={filtroJefe}
onChange={(e) => setFiltroJefe(e.target.value)}
>
<option value="">Todos los jefes directos</option>
{jefes.map((jefe, idx) => (
<option key={idx} value={jefe}>{jefe}</option>
))}
</select>
</div>
<div className="flex items-end">
<button
onClick={() => { setFiltroPosicion(''); setFiltroHomologacion(''); setFiltroPais(''); }}
className="w-full md:w-auto bg-verde-bg hover:bg-verde-glm hover:text-white text-azul-glm font-semibold text-sm py-2.5 px-5 rounded-lg border border-verde-glm/40 hover:border-verde-glm transition-colors"
onClick={() => { setFiltroPosicion(''); setFiltroHomologacion(''); setFiltroPais(''); setFiltroCuenta(''); setFiltroJefe(''); }}
className="w-full bg-verde-bg hover:bg-verde-glm hover:text-white text-azul-glm font-semibold text-sm py-2.5 px-5 rounded-lg border border-verde-glm/40 hover:border-verde-glm transition-colors"
>
Limpiar Filtros
</button>
@@ -166,9 +300,13 @@ export default function Dashboard() {
</div>
{/* KPIs GLM */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-naranja">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Total Encuestas Enviadas</h2>
<p className="text-3xl font-extrabold text-naranja mt-1">{encuestasEnviadas}</p>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-azul-glm">
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Total Registros</h2>
<h2 className="text-xs text-acero uppercase font-bold tracking-wider">Total Encuestas Completadas</h2>
<p className="text-3xl font-extrabold text-azul-glm mt-1">{totalRegistros}</p>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm border-l-[6px] border-verde-glm">
@@ -213,20 +351,165 @@ export default function Dashboard() {
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">% de Aprobación</h3>
<div className="h-64 relative">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={chartDataAprobacion}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
dataKey="value"
startAngle={90}
endAngle={-270}
>
<Cell fill="#6CC24A" />
<Cell fill="#E2E8F0" />
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none" style={{ top: '-2rem' }}>
<span className="text-4xl font-extrabold text-verde-glm">{porcentajeAprobacion}%</span>
<span className="text-xs text-acero">{totalRegistros} de {encuestasEnviadas}</span>
</div>
</div>
</div>
</div>
{/* GRÁFICAS SÍ / NO */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">¿Regresaría a la organización en el futuro?</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDataRegresar} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 13, fill: '#4F758B' }} width={40} />
<Tooltip />
<Bar dataKey="value" name="Respuestas" radius={[0, 4, 4, 0]}>
{chartDataRegresar.map((entry) => (
<Cell key={entry.name} fill={entry.name === 'Sí' ? '#6CC24A' : '#FF6A13'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Recomendaría a otra persona para trabajar en la organización</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDataRecomendar} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis type="number" allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 13, fill: '#4F758B' }} width={40} />
<Tooltip />
<Bar dataKey="value" name="Respuestas" radius={[0, 4, 4, 0]}>
{chartDataRecomendar.map((entry) => (
<Cell key={entry.name} fill={entry.name === 'Sí' ? '#6CC24A' : '#FF6A13'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* GRÁFICAS DE SALIDA LABORAL */}
<div className="grid grid-cols-1 gap-6 mb-8">
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Motivo de Salida Laboral</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDataMotivoSalida} margin={{ top: 10, right: 20, left: 10, bottom: 22 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<Tooltip />
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
{chartDataMotivoSalida.map((entry, index) => (
<Cell key={`motivo-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Salida Laboral Según País</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDataPorPais} margin={{ top: 10, right: 20, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<Tooltip />
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
{chartDataPorPais.map((entry, index) => (
<Cell key={`pais-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
<div className="bg-white p-6 rounded-xl border border-gris-medio/30 shadow-sm">
<h3 className="text-base font-bold text-azul-glm mb-4 pb-2 border-b-2 border-verde-glm">Salida Laboral Según Proyecto</h3>
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartDataPorProyecto} margin={{ top: 10, right: 20, left: 10, bottom: 10 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#E2E8F0" />
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#4F758B' }} interval={0} angle={-20} textAnchor="end" height={60} />
<YAxis allowDecimals={false} tick={{ fontSize: 12, fill: '#4F758B' }} />
<Tooltip />
<Bar dataKey="value" name="Salidas" radius={[4, 4, 0, 0]}>
{chartDataPorProyecto.map((entry, index) => (
<Cell key={`proyecto-${index}`} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* TABLA GLM */}
<div className="bg-white rounded-xl border border-gris-medio/30 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg">
<div className="px-6 py-4 border-b border-gris-medio/30 bg-verde-bg flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-base font-bold text-azul-glm">Detalle de Registros</h3>
<div className="flex items-center gap-2">
<label className="text-xs font-bold text-azul-glm uppercase tracking-wider">Buscar empleado</label>
<input
type="text"
value={busquedaNombre}
onChange={(e) => { setBusquedaNombre(e.target.value); setPaginaActual(1); }}
placeholder="Escriba el nombre..."
className="w-56 rounded-lg p-2.5 border text-sm text-gris-oscuro bg-amarillo-input focus:bg-white focus:ring-2 focus:ring-verde-glm focus:outline-none"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gris-medio/50 text-sm">
<thead className="bg-azul-glm">
<tr>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Fecha</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Nombre</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">País</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Cliente/Proyecto</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Posición</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Jefe Directo</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">PDV</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Trade Partner</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Motivo</th>
<th className="px-6 py-3 text-left text-xs font-bold text-white uppercase tracking-wider">Sentimiento</th>
</tr>
@@ -237,8 +520,13 @@ export default function Dashboard() {
<td className="px-6 py-4 whitespace-nowrap text-acero">
{new Date(item.timestamp).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap font-semibold text-gris-oscuro">{item.pais}</td>
<td className="px-6 py-4 whitespace-nowrap font-semibold text-gris-oscuro">{item.nombre}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.pais}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.cuenta_proyecto}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.posicion}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.jefe_directo}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.pdv}</td>
<td className="px-6 py-4 whitespace-nowrap text-gris-oscuro">{item.trade_partner}</td>
<td className="px-6 py-4 text-gris-oscuro max-w-xs truncate">{item.homologacion_ia}</td>
<td className="px-6 py-4 whitespace-nowrap">
{(() => {
+85
View File
@@ -0,0 +1,85 @@
import { useState } from 'react';
import { useAuth } from '../contexts/AuthContext';
import { Navigate } from 'react-router-dom';
export default function Login() {
const { user, loading, signInWithGoogle } = useAuth();
const [error, setError] = useState(null);
const [signingIn, setSigningIn] = useState(false);
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gris-claro">
<div className="w-10 h-10 border-4 border-verde-glm border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (user) {
return <Navigate to="/dashboard/encuesta-terminacion" replace />;
}
const handleLogin = async () => {
setError(null);
setSigningIn(true);
try {
await signInWithGoogle();
} catch (err) {
setError('Error al iniciar sesion. Intenta de nuevo.');
setSigningIn(false);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gris-claro px-4">
<div className="flex flex-col items-center bg-white p-10 rounded-2xl shadow-sm border border-gris-medio/30 max-w-md w-full">
<img
src="https://dbit.digitalcompass.agency/storage/v1/object/public/public-assets/GLM_completo.png"
alt="GomezLee Marketing"
className="w-64 mb-10"
/>
<button
onClick={handleLogin}
disabled={signingIn}
className="flex items-center justify-center gap-3 w-full bg-white border-2 border-gris-medio/40 hover:border-azul-glm hover:shadow-md text-gris-oscuro font-semibold py-3 px-6 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{signingIn ? (
<div className="w-5 h-5 border-2 border-azul-glm border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-5 h-5" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
{signingIn ? 'Conectando...' : 'Login with Google'}
</button>
{error && (
<p className="mt-4 text-sm text-red-600 bg-rojo-suave py-2 px-4 rounded-lg text-center w-full">
{error}
</p>
)}
<p className="mt-6 text-xs text-acero text-center leading-relaxed">
Solo se permiten correos corporativos<br />
<span className="font-semibold text-gris-medio">@gomezleemarketing.com</span>
</p>
</div>
</div>
);
}
+23 -3
View File
@@ -1,7 +1,27 @@
// src/supabaseClient.js
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_SERVICE_KEY;
const supabaseKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
if (!supabaseUrl || !supabaseKey) {
console.error('[Supabase] Faltan variables de entorno:', {
url: !!supabaseUrl,
key: !!supabaseKey,
});
}
export const supabase = createClient(supabaseUrl, supabaseKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true,
storageKey: 'glm-dashboard-auth',
},
global: {
headers: {
'x-application-name': 'glm-dashboard',
},
},
});
export const supabaseData = supabase;
+1 -1
View File
@@ -5,5 +5,5 @@ import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [tailwindcss(), react()],
base: '/dashboard/'
base: '/dashboard/encuesta-terminacion'
})