diff --git a/.hintrc b/.hintrc new file mode 100644 index 0000000..7c59e66 --- /dev/null +++ b/.hintrc @@ -0,0 +1,17 @@ +{ + "extends": [ + "development" + ], + "hints": { + "compat-api/css": [ + "default", + { + "ignore": [ + "scrollbar-color", + "scrollbar-width", + "scrollbar-width: thin" + ] + } + ] + } +} \ No newline at end of file diff --git a/README.md b/README.md index 5694b7a..712999b 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ Durante el desarrollo del proyecto, se utilizaron las siguientes librerías: ```sh npm install react-router-dom npm install axios +npm install d3 +npm install jwt-decode npm install framer-motion npm install react-datepicker npm install date-fns diff --git a/package.json b/package.json index 5c4ae16..9cd9e7b 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "@testing-library/react": "^16.2.0", "@testing-library/user-event": "^13.5.0", "axios": "^1.8.4", + "d3": "^7.9.0", + "jwt-decode": "^4.0.0", "framer-motion": "^12.6.2", "react": "^19.0.0", "date-fns": "^4.1.0", @@ -17,6 +19,7 @@ "react-router-dom": "^7.4.0", "react-scripts": "^5.0.1", "styled-components": "^6.1.16", + "styled-system": "^5.1.5", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/App.css b/src/App.css index 9624b22..a86cb74 100644 --- a/src/App.css +++ b/src/App.css @@ -94,21 +94,42 @@ ul { } .content .panel .header { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + height: 20%; + margin-right: 5%; +} + +.content .panel .header .info { display: flex; flex-direction: column; - justify-content: center; + gap: 5px; width: 100%; - height: 20%; } -.content .panel .header .title { +.content .panel .header .info .title { font-weight: bold; } -.content .panel .header .title, .content .panel .header .greetings { +.content .panel .header .info .title, .content .panel .header .greetings { font-size: 21px; } +.content .panel .header .user-info { + display: flex; + flex-direction: row; + gap: 20px; + width: 100%; + align-items: center; + justify-content: flex-end; +} + +.content .panel .header .user-info span { + font-size: 18px; +} + .content .panel .container { flex: 1; display: flex; diff --git a/src/App.js b/src/App.js index 6132b5b..7095015 100644 --- a/src/App.js +++ b/src/App.js @@ -1,3 +1,4 @@ +// App.js import React, { useState, useEffect } from "react"; import { BrowserRouter as Router, @@ -5,118 +6,227 @@ import { Routes, Link, useLocation, - Navigate + Navigate, + useNavigate, } from "react-router-dom"; -import { ReactComponent as House } from './assets/icons/house-user_11269953 1.svg'; -import { ReactComponent as Room } from './assets/icons/workshop_14672030 1.svg'; -import { ReactComponent as User } from './assets/icons/User.svg'; -import Home from './pages/Home/Home.js'; +import { ReactComponent as House } from "./assets/icons/house-user_11269953 1.svg"; +import { ReactComponent as Room } from "./assets/icons/workshop_14672030 1.svg"; +import { ReactComponent as UserIcon } from "./assets/icons/User.svg"; +import { consultarUsuarioPorCorreo } from "./api/usuario"; +import LoginPage from "./pages/Login/LoginPage"; +import Home from "./pages/Home/Home"; +import AdministratorHome from "./pages/Administrator/AdministratorHome"; import GestionarSalones from './pages/Salones/GestionarSalones'; import GestionarUsuarios from './pages/Admin/GestionarUsuarios'; - -import './App.css'; +import styled from "styled-components"; +import { jwtDecode } from "jwt-decode"; +import "./App.css"; const routesConfig = { admin: [ { path: "/administrador", name: "Panel de Control", icon: }, { path: "/administrador/salones", name: "Gestión de Salones", icon: }, - { path: "/administrador/usuarios", name: "Gestión de Usuarios", icon: }, + { path: "/administrador/usuarios", name: "Gestión de Usuarios", icon: }, ], profe: [ { path: "/home", name: "Gestión de Reservas", icon: }, - ], + ] }; -const Menu = ({ role }) => ( -
    - {routesConfig[role]?.map((item, index) => ( -
  • - - {item.icon} - {item.name} - -
  • - ))} -
-); - -/** - * Componente de encabezado que muestra el título de la página y el saludo al usuario. - */ -const Header = ({ role }) => { - const location = useLocation(); - const currentPage = routesConfig[role]?.find( - (route) => route.path === location.pathname +const Menu = ({ user }) => { + if (!user) return null; + const userRoutes = routesConfig[user.isAdmin ? "admin" : "profe"] || []; + + return ( +
    + {userRoutes.map((item, index) => ( +
  • + + {item.icon} + {item.name} + +
  • + ))} +
); - const title = currentPage ? currentPage.name : "Elysium"; +}; + +// +// HEADER: muestra título, saludo, avatar y botón de logout +// +const Header = ({ user, onLogout }) => { + const location = useLocation(); + const navigate = useNavigate(); + let title = "Elysium"; useEffect(() => { - document.title = title; - }, [title]); + if (user) { + const currentPage = routesConfig[user.isAdmin ? "admin" : "profe"]?.find( + (route) => route.path === location.pathname + ); + document.title = currentPage ? currentPage.name : "Elysium"; + } + }, [location.pathname, user]); + + if (!user) return null; return (
- {title} - - Buen día, {role === "admin" ? "Admin" : "Profe"} - - - Gestiona las reservas que has agendado últimamente - +
+ {title} + + Buen día, {user.isAdmin ? "Admin" : "Profe"} {user.nombre} + + + Gestiona las reservas que has agendado últimamente + +
+
+ + {user.nombre} {user.apellido} + { + onLogout(); + navigate("/"); + }} + /> +
); }; -/** - * Componente principal de la aplicación. - */ -function App() { - const [role, setRole] = useState(""); +const UserAvatar = styled.img` + width: 40px; + height: 40px; + object-fit: contain; + border-radius: 50%; +`; + +const LogoutIcon = styled.img` + width: 32px; + height: 32px; + cursor: pointer; + fill: var(--variable-collection-current-color); +`; + +const obtenerCorreoDesdeToken = (token) => { + try { + const decoded = jwtDecode(token); + return decoded.sub; + } catch (error) { + return null; + } +}; + + + +// +// Componente principal de rutas +// +function AppRoutes({ user, setUser }) { + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); useEffect(() => { - const randomRole = Math.random() > 0.5 ? "admin" : "profe"; // Simulación de login - setRole(randomRole); - const colorVariable = - randomRole === "admin" - ? "var(--variable-collection-user-admin)" - : "var(--variable-collection-user-estandar)"; - document.documentElement.style.setProperty( - "--variable-collection-current-color", - colorVariable - ); + const fetchUser = async () => { + const token = localStorage.getItem("token"); + if (token) { + try { + const correoGuardado = obtenerCorreoDesdeToken(token); + if (correoGuardado) { + const usuario = await consultarUsuarioPorCorreo(correoGuardado); + setUser(usuario); + + if (usuario.isAdmin) { + document.documentElement.style.setProperty("--variable-collection-current-color", "var(--variable-collection-user-admin)"); + navigate("/administrador"); + } else { + document.documentElement.style.setProperty("--variable-collection-current-color", "var(--variable-collection-user-estandar)"); + navigate("/home"); + } + } else { + localStorage.removeItem("token"); + } + } catch (error) { + console.error("Error obteniendo usuario:", error); + localStorage.removeItem("token"); + } + } + setLoading(false); + }; + + fetchUser(); }, []); + const handleLogout = () => { + localStorage.removeItem("token"); + setUser(null); + }; + + if (loading) return
Cargando...
; + + return ( + + {/* Si no hay usuario autenticado, se muestra LoginPage */} + {!user ? ( + <> + } /> + } /> + + ) : ( + // Una vez autenticado, se muestra la aplicación completa (Header, Menu, contenido) + +
+ +
+
+
+
+ + {user.isAdmin ? ( + <> + } /> + } /> + } /> + } /> + + ) : ( + <> + } /> + } /> + + )} + +
+
+ + } + /> + )} +
+ ); +} + +// +// Componente principal de la aplicación con en el nivel más alto +// +function App() { + const [user, setUser] = useState(null); + return ( -
-
- -
-
-
-
- - {role === "admin" ? ( - <> - } /> - } /> - } /> - - - } /> - - ) : ( - <> - } /> - } /> - - )} - -
-
-
+
); } + export default App; diff --git a/src/api/usuario/administrador.jsx b/src/api/administrador.jsx similarity index 100% rename from src/api/usuario/administrador.jsx rename to src/api/administrador.jsx diff --git a/src/api/auth.jsx b/src/api/auth.jsx new file mode 100644 index 0000000..f37831e --- /dev/null +++ b/src/api/auth.jsx @@ -0,0 +1,27 @@ +import axios from "axios"; +import { BASE_URL } from "../config/config.js"; + +export async function login(correo="", contraseña="") { + if (correo === "" || contraseña === "") { + throw new Error("El correo y la contraseña son obligatorios."); + } + try { + const data = { + correoInstitucional: correo, + password: contraseña + } + const response = await axios.post(`${BASE_URL}/login`, data); + return response.data; + } catch (error) { + throw new Error(error.response ? error.response.data.message : error.message); + } +} + +export async function register(usuario = {}) { + try { + const response = await axios.post(`${BASE_URL}/register`, usuario ); + return response.data; + } catch (error) { + throw new Error(error.response ? error.response.data.message : error.message); + } +} \ No newline at end of file diff --git a/src/api/reserva.jsx b/src/api/reserva.jsx index cbecd91..58f6ed4 100644 --- a/src/api/reserva.jsx +++ b/src/api/reserva.jsx @@ -15,9 +15,15 @@ const RESERVA_API = `${BASE_URL}/reserva`; * @returns {Promise} Lista de reservas encontradas. * @throws {Error} Si ocurre un error en la solicitud. */ -export async function getReservas (filtros) { +export async function getReservas (filtros = {}, token) { try { - const response = await axios.get(RESERVA_API, { params: filtros }); + const response = await axios.get(RESERVA_API, { + params: filtros, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -30,9 +36,14 @@ export async function getReservas (filtros) { * @returns {Promise} Datos de la reserva. * @throws {Error} Si la reserva no existe o hay un error en la solicitud. */ -export async function consultarReserva (idReserva) { +export async function consultarReserva (idReserva, token) { try { - const response = await axios.get(`${RESERVA_API}/${idReserva}/reserva`); + const response = await axios.get(`${RESERVA_API}/${idReserva}/reserva`, { + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -45,9 +56,15 @@ export async function consultarReserva (idReserva) { * @returns {Promise} Mensaje de confirmación. * @throws {Error} Si ocurre un error en la solicitud. */ -export async function crearReserva (reserva) { +export async function crearReserva (reserva, token) { try { - const response = await axios.post(RESERVA_API, reserva); + const response = await axios.post(RESERVA_API, { + params: reserva, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -61,9 +78,15 @@ export async function crearReserva (reserva) { * @returns {Promise} * @throws {Error} Si la reserva no existe o hay un error en la solicitud. */ -export async function actualizarReserva (idReserva, reserva) { +export async function actualizarReserva (idReserva, reserva, token) { try { - await axios.patch(`${RESERVA_API}/${idReserva}`, reserva); + await axios.patch(`${RESERVA_API}/${idReserva}`, { + params: reserva, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); } catch (error) { throw new Error(error.response.data.message); } @@ -75,9 +98,14 @@ export async function actualizarReserva (idReserva, reserva) { * @returns {Promise} Mensaje de confirmación. * @throws {Error} Si la reserva no existe o hay un error en la solicitud. */ -export async function deleteReserva (idReserva) { +export async function deleteReserva (idReserva, token) { try { - const response = await axios.put(`${RESERVA_API}/${idReserva}/inactivo`); + const response = await axios.put(`${RESERVA_API}/${idReserva}/inactivo`, { + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); diff --git a/src/api/salon.jsx b/src/api/salon.jsx index 74d2085..a021bea 100644 --- a/src/api/salon.jsx +++ b/src/api/salon.jsx @@ -15,9 +15,15 @@ const SALON_API = `${BASE_URL}/salones`; * @returns {Promise} Lista de salones. * @throws {Error} Error al obtener los salones. */ -export async function getSalones(filtros = {}) { +export async function getSalones(filtros = {}, token) { try { - const response = await axios.get(SALON_API, { params: filtros }); + const response = await axios.get(SALON_API, { + params: filtros, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -30,9 +36,14 @@ export async function getSalones(filtros = {}) { * @returns {Promise} Datos del salón. * @throws {Error} Error al obtener el salón. */ -export async function getSalonByMnemonico(mnemonico) { +export async function getSalonByMnemonico(mnemonico, token) { try { - const response = await axios.get(`${SALON_API}/${mnemonico}`); + const response = await axios.get(`${SALON_API}/${mnemonico}`, { + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -45,9 +56,14 @@ export async function getSalonByMnemonico(mnemonico) { * @returns {Promise} Estado de disponibilidad del salón. * @throws {Error} Error al obtener la disponibilidad. */ -export async function getDisponible(mnemonico) { +export async function getDisponible(mnemonico, token) { try { - const response = await axios.get(`${SALON_API}/${mnemonico}/disponible`); + const response = await axios.get(`${SALON_API}/${mnemonico}/disponible`, { + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, + }); return response.data; } catch (error) { throw new Error(error.response.data.message); @@ -66,10 +82,14 @@ export async function getDisponible(mnemonico) { * @returns {Promise} Respuesta del servidor. * @throws {Error} Error al agregar el salón. */ -export async function agregarSalon(salon) { +export async function agregarSalon(salon, token) { try { - const response = await axios.post(SALON_API, salon, { - headers: { "Content-Type": "application/json" } + const response = await axios.post(SALON_API, { + params: salon, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, }); return response.data; } catch (error) { @@ -84,10 +104,14 @@ export async function agregarSalon(salon) { * @returns {Promise} Respuesta del servidor. * @throws {Error} Error al actualizar el salón. */ -export async function actualizarSalon(mnemonico, salon) { +export async function actualizarSalon(mnemonico, salon, token) { try { - const response = await axios.patch(`${SALON_API}/${mnemonico}`, salon, { - headers: { "Content-Type": "application/json" } + const response = await axios.patch(`${SALON_API}/${mnemonico}`, { + params: salon, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json" + }, }); return response.data; } catch (error) { diff --git a/src/api/usuario.jsx b/src/api/usuario.jsx new file mode 100644 index 0000000..d525e1e --- /dev/null +++ b/src/api/usuario.jsx @@ -0,0 +1,129 @@ +import axios from "axios"; +import { BASE_URL } from "../config/config.js"; + +const USUARIO_API = `${BASE_URL}/usuario`; + +/** + * Consulta usuarios con filtros opcionales. + * + * @param {Object} filtros - Filtros opcionales para la consulta. + * @param {boolean} [filtros.activo] - Filtrar usuarios activos/inactivos. + * @param {boolean} [filtros.isAdmin] - Filtrar usuarios administradores/no administradores. + * @returns {Promise} - Lista de usuarios filtrados. + * @throws {Error} - Si la consulta falla. + */ +export async function consultarUsuarios(filtros) { + try { + const response = await axios.get(`${USUARIO_API}/usuarios`, { params: filtros }); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Consulta un usuario por su correo institucional. + * @param {string} correo - Correo institucional del usuario. + * @returns {Promise} - Datos del usuario. + * @throws {Error} - Si la consulta falla. + */ +export async function consultarUsuarioPorCorreo(correo) { + try { + // Verifica la ruta y el parámetro "correo" + const response = await axios.get(`${USUARIO_API}/correo/${correo}`); + return response.data; + } catch (error) { + throw new Error(error.response?.data?.message || "Error consultando usuario"); + } + } + +/** + * Agrega un nuevo usuario al sistema. + * @param {Object} usuario - Datos del usuario a agregar. + * @returns {Promise} Promesa resuelta si el usuario se agrega correctamente. + * @throws {Error} Si hay un problema al agregar el usuario. + */ +export async function agregarUsuario(usuario) { + try { + const response = await axios.post(`${USUARIO_API}/usuario`, usuario); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Actualiza parcialmente la información de un usuario. + * @param {number} id - ID del usuario a actualizar. + * @param {Object} actualizacion - Datos a actualizar. + * @returns {Promise} Promesa resuelta si la actualización es exitosa. + * @throws {Error} Si hay un problema al actualizar el usuario. + */ +export async function actualizarInformacionUsuario(id, actualizacion) { + try { + const response = await axios.patch(`${USUARIO_API}/usuario/${id}`, actualizacion); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Agrega un nuevo salón al sistema. + * @param {number} id - ID del administrador que agrega el salón. + * @param {Object} salon - Datos del salón a agregar. + * @returns {Promise} Promesa resuelta si el salón se agrega correctamente. + * @throws {Error} Si hay un problema al agregar el salón. + */ +export async function agregarSalon(id, salon) { + try { + const response = await axios.post(`${USUARIO_API}/${id}/salon`, salon); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Crea una nueva reserva en el sistema. + * @param {number} id - ID del usuario que realiza la reserva. + * @param {Object} reserva - Datos de la reserva a crear. + * @returns {Promise} Mensaje de éxito. + * @throws {Error} Si hay un problema al crear la reserva. + */ +export async function crearReserva(id, reserva) { + try { + const response = await axios.post(`${USUARIO_API}/${id}/reserva`, reserva); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Consulta un usuario por su ID. + * @param {number} id - ID del usuario a consultar. + * @returns {Promise} - Datos del usuario. + */ +export async function consultarUsuario(id) { + try { + const response = await axios.get(`${USUARIO_API}/${id}`); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} + +/** + * Lista todas las reservas de un usuario. + * @param {number} id - ID del usuario. + * @returns {Promise} - Lista de reservas. + */ +export async function listarReservas(id) { + try { + const response = await axios.get(`${USUARIO_API}/${id}/reserva`); + return response.data; + } catch (error) { + throw new Error(error.response.data.message); + } +} \ No newline at end of file diff --git a/src/api/usuario/estandar.jsx b/src/api/usuario/estandar.jsx deleted file mode 100644 index c9e413c..0000000 --- a/src/api/usuario/estandar.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import axios from "axios"; -import { BASE_URL } from "../../config/config.js"; - -const ESTANDAR_API = `${BASE_URL}/estandar`; - -/** - * Consulta un usuario por su ID. - * @param {number} id - ID del usuario a consultar. - * @returns {Promise} - Datos del usuario. - */ -export async function consultarUsuario(id) { - try { - const response = await axios.get(`${ESTANDAR_API}/${id}`); - return response.data; - } catch (error) { - throw new Error(error.response.data.message); - } -} - - -/** - * Crea una reserva para un usuario. - * @param {number} id - ID del usuario que realiza la reserva. - * @param {Object} reservaData - Datos de la reserva. - * @returns {Promise} - Mensaje de éxito. - */ -export async function crearReserva(id, reservaData) { - try { - const response = await axios.post(`${ESTANDAR_API}/${id}/reserva`, reservaData, { - headers: { "Content-Type": "application/json" } - }); - return response.data; - } catch (error) { - throw new Error(error.response.data.message); - } -} - -/** - * Lista todas las reservas de un usuario. - * @param {number} id - ID del usuario. - * @returns {Promise} - Lista de reservas. - */ -export async function listarReservas(id) { - try { - const response = await axios.get(`${ESTANDAR_API}/${id}/reserva`); - return response.data; - } catch (error) { - throw new Error(error.response.data.message); - } -} \ No newline at end of file diff --git a/src/components/Admin/charts/DemandaChart.jsx b/src/components/Admin/charts/DemandaChart.jsx new file mode 100644 index 0000000..b44e08a --- /dev/null +++ b/src/components/Admin/charts/DemandaChart.jsx @@ -0,0 +1,78 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const DemandaChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + svg.selectAll("*").remove(); // Limpia el SVG + + if (!reservas || reservas.length === 0) return; + + const width = 600; + const height = 500; + + svg.attr("width", width).attr("height", height); + + // Agrupar reservas por salón + const reservasPorSalon = d3.rollup( + reservas, + (v) => v.length, + (d) => d.idSalon + ); + // Convertir a arreglo de objetos + const data = Array.from(reservasPorSalon, ([salon, count]) => ({ + salon, + count, + })); + + // Usamos d3.hierarchy para crear una estructura para el pack layout. + const root = d3.hierarchy({ children: data }).sum((d) => d.count); + + // Configurar el pack layout + const pack = d3.pack().size([width, height]).padding(10); + + const nodes = pack(root).leaves(); + + // Escala de colores + const color = d3.scaleOrdinal(d3.schemeSet3); + + // Dibujar las burbujas + const node = svg + .selectAll("g") + .data(nodes) + .enter() + .append("g") + .attr("transform", (d) => `translate(${d.x}, ${d.y})`); + + node + .append("circle") + .attr("r", 0) + .attr("fill", (d, i) => color(i)) + .transition() + .duration(800) + .attr("r", (d) => d.r); + + // Agregar etiquetas dentro de cada burbuja (nombre del salón y cantidad) + node + .append("text") + .attr("text-anchor", "middle") + .attr("dy", "-0.3em") + .attr("font-size", (d) => Math.min((2 * d.r) / d.data.salon.length, 18)) + .attr("fill", "#000") + .text((d) => d.data.salon); + + node + .append("text") + .attr("text-anchor", "middle") + .attr("dy", "1em") + .attr("font-size", "12px") + .attr("fill", "#000") + .text((d) => d.data.count); + }, [reservas]); + + return ; +}; + +export default DemandaChart; \ No newline at end of file diff --git a/src/components/Admin/charts/DiaSalonChart.jsx b/src/components/Admin/charts/DiaSalonChart.jsx new file mode 100644 index 0000000..3f51430 --- /dev/null +++ b/src/components/Admin/charts/DiaSalonChart.jsx @@ -0,0 +1,68 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const DiaSalonChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + svg.selectAll("*").remove(); // Limpia el contenido del SVG + + if (!reservas || reservas.length === 0) return; + + const width = 500; + const height = 500; + const radius = Math.min(width, height) / 2; + svg.attr("width", width).attr("height", height); + + const g = svg + .append("g") + .attr("transform", `translate(${width / 2}, ${height / 2})`); + + // Agrupar reservas por salón + const reservasPorSalon = d3.rollup( + reservas, + (v) => v.length, + (d) => d.idSalon + ); + const data = Array.from(reservasPorSalon, ([salon, count]) => ({ salon, count })); + + // Configurar el pie chart + const pie = d3.pie().value((d) => d.count); + const arc = d3.arc().innerRadius(0).outerRadius(radius); + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const arcs = g + .selectAll("arc") + .data(pie(data)) + .enter() + .append("g") + .attr("class", "arc"); + + arcs + .append("path") + .attr("d", arc) + .attr("fill", (d, i) => color(i)) + .transition() + .duration(800) + .attrTween("d", function (d) { + const i = d3.interpolate({ startAngle: 0, endAngle: 0 }, d); + return function (t) { + return arc(i(t)); + }; + }); + + // Agregar etiquetas para cada salón + arcs + .append("text") + .attr("transform", (d) => `translate(${arc.centroid(d)})`) + .attr("text-anchor", "middle") + .attr("font-size", "12px") + .attr("fill", "#fff") + .text((d) => d.data.salon); + }, [reservas]); + + return ; +}; + +export default DiaSalonChart; \ No newline at end of file diff --git a/src/components/Admin/charts/EstadoChart.jsx b/src/components/Admin/charts/EstadoChart.jsx new file mode 100644 index 0000000..2017bdc --- /dev/null +++ b/src/components/Admin/charts/EstadoChart.jsx @@ -0,0 +1,80 @@ +// src/components/Admin/charts/EstadoChart.jsx +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const EstadoChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + svg.selectAll("*").remove(); // Limpia el SVG + + if (!reservas || reservas.length === 0) return; + + const width = 500; + const height = 500; + const radius = Math.min(width, height) / 2; + svg.attr("width", width).attr("height", height); + + const g = svg + .append("g") + .attr("transform", `translate(${width / 2}, ${height / 2})`); + + // Agrupar reservas por estado (suponiendo que la propiedad 'estado' es una cadena, ej: "ACTIVA" o "INACTIVA") + const reservasPorEstado = d3.rollup( + reservas, + (v) => v.length, + (d) => d.estado + ); + const data = Array.from(reservasPorEstado, ([estado, count]) => ({ + estado, + count, + })); + + // Configurar el pie/donut chart + const pie = d3.pie().value((d) => d.count); + const arc = d3 + .arc() + .innerRadius(radius * 0.5) // Radio interior para efecto donut + .outerRadius(radius); + + // Escala de colores con una paleta profesional (usamos d3.schemeSet2) + const color = d3 + .scaleOrdinal() + .domain(data.map((d) => d.estado)) + .range(d3.schemeSet2); + + const arcs = g + .selectAll("arc") + .data(pie(data)) + .enter() + .append("g") + .attr("class", "arc"); + + arcs + .append("path") + .attr("d", arc) + .attr("fill", (d) => color(d.data.estado)) + .transition() + .duration(800) + .attrTween("d", function (d) { + const i = d3.interpolate({ startAngle: 0, endAngle: 0 }, d); + return function (t) { + return arc(i(t)); + }; + }); + + // Agregar etiquetas dentro del gráfico + arcs + .append("text") + .attr("transform", (d) => `translate(${arc.centroid(d)})`) + .attr("text-anchor", "middle") + .attr("font-size", "14px") + .attr("fill", "#000") + .text((d) => `${d.data.estado}: ${d.data.count}`); + }, [reservas]); + + return ; +}; + +export default EstadoChart; \ No newline at end of file diff --git a/src/components/Admin/charts/MesSalonChart.jsx b/src/components/Admin/charts/MesSalonChart.jsx new file mode 100644 index 0000000..5eeb06f --- /dev/null +++ b/src/components/Admin/charts/MesSalonChart.jsx @@ -0,0 +1,73 @@ +// src/components/Admin/charts/MesSalonChart.jsx +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const MesSalonChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + svg.selectAll("*").remove(); // Limpia el SVG + + if (!reservas || reservas.length === 0) return; + + const width = 600; + const height = 300; + const margin = { top: 20, right: 20, bottom: 60, left: 50 }; + + // Agrupar reservas por salón + const reservasPorSalon = d3.rollup( + reservas, + (v) => v.length, + (d) => d.idSalon + ); + const data = Array.from(reservasPorSalon, ([salon, count]) => ({ salon, count })); + + // Escala X: nombres de salón + const x = d3.scaleBand() + .domain(data.map(d => d.salon)) + .range([margin.left, width - margin.right]) + .padding(0.1); + + // Escala Y: cantidad de reservas + const y = d3.scaleLinear() + .domain([0, d3.max(data, d => d.count)]) + .nice() + .range([height - margin.bottom, margin.top]); + + svg.attr("width", width).attr("height", height); + + // Eje X + svg.append("g") + .attr("transform", `translate(0, ${height - margin.bottom})`) + .call(d3.axisBottom(x)) + .selectAll("text") + .attr("transform", "rotate(-45)") + .style("text-anchor", "end"); + + // Eje Y + svg.append("g") + .attr("transform", `translate(${margin.left},0)`) + .call(d3.axisLeft(y)); + + // Dibujar barras con transición + svg.selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("x", d => x(d.salon)) + .attr("y", y(0)) + .attr("width", x.bandwidth()) + .attr("height", 0) + .attr("fill", (d, i) => d3.schemeCategory10[i % 10]) + .transition() + .duration(800) + .attr("y", d => y(d.count)) + .attr("height", d => y(0) - y(d.count)); + }, [reservas]); + + return ; +}; + +export default MesSalonChart; \ No newline at end of file diff --git a/src/components/Admin/charts/PromedioPrioridadChart.jsx b/src/components/Admin/charts/PromedioPrioridadChart.jsx new file mode 100644 index 0000000..2126e5a --- /dev/null +++ b/src/components/Admin/charts/PromedioPrioridadChart.jsx @@ -0,0 +1,87 @@ +// src/components/Admin/charts/PromedioPrioridadChart.jsx +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const PromedioPrioridadChart = ({ data }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + // Limpia el contenido anterior + svg.selectAll("*").remove(); + + if (!data || data.length === 0) return; + + const width = 600; + const height = 400; + const margin = { top: 20, right: 20, bottom: 50, left: 60 }; + + svg.attr("width", width).attr("height", height); + + // Dominio de prioridades: se asume que siempre serán 1 a 5 + const priorities = data.map((d) => d.priority); + + // Escala X: categorías (prioridad) + const x = d3 + .scaleBand() + .domain(priorities) + .range([margin.left, width - margin.right]) + .padding(0.2); + + // Escala Y: promedio de reservas + const y = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.promedio)]).nice() + .range([height - margin.bottom, margin.top]); + + // Dibujar eje X + svg + .append("g") + .attr("transform", `translate(0, ${height - margin.bottom})`) + .call(d3.axisBottom(x).tickFormat((d) => `Prioridad ${d}`)); + + // Dibujar eje Y + svg + .append("g") + .attr("transform", `translate(${margin.left}, 0)`) + .call(d3.axisLeft(y)); + + // Escala de colores para cada barra + const color = d3.scaleOrdinal(d3.schemeCategory10); + + // Dibujar las barras + svg + .selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("x", (d) => x(d.priority)) + .attr("y", y(0)) + .attr("width", x.bandwidth()) + .attr("height", 0) + .attr("fill", (d, i) => color(i)) + .transition() + .duration(800) + .attr("y", (d) => y(d.promedio)) + .attr("height", (d) => y(0) - y(d.promedio)); + + // Agregar etiquetas con el promedio encima de cada barra + svg + .selectAll(".label") + .data(data) + .enter() + .append("text") + .attr("class", "label") + .attr("x", (d) => x(d.priority) + x.bandwidth() / 2) + .attr("y", (d) => y(d.promedio) - 5) + .attr("text-anchor", "middle") + .attr("font-size", "12px") + .attr("fill", "#000") + .text((d) => (d.promedio !== undefined ? d.promedio.toFixed(2) : "0.00")); + }, [data]); + + return ; +}; + +export default PromedioPrioridadChart; \ No newline at end of file diff --git a/src/components/Admin/charts/RangoFechasChart.jsx b/src/components/Admin/charts/RangoFechasChart.jsx new file mode 100644 index 0000000..e3978e2 --- /dev/null +++ b/src/components/Admin/charts/RangoFechasChart.jsx @@ -0,0 +1,82 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const RangoFechasChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + const color = d3.scaleOrdinal(d3.schemeCategory10); + + svg.selectAll("*").remove(); // Limpiar el SVG + + if (!reservas || reservas.length === 0) return; + + const width = 600; + const height = 400; + const margin = { top: 20, right: 20, bottom: 30, left: 100 }; + + // Agrupar reservas por salón + const reservasPorSalon = d3.rollup( + reservas, + (v) => v.length, + (d) => d.idSalon + ); + const data = Array.from(reservasPorSalon, ([salon, count]) => ({ + salon, + count, + })); + + svg.attr("width", width).attr("height", height); + + // Escala X: lineal para la cantidad + const x = d3.scaleLinear() + .domain([0, d3.max(data, d => d.count)]) + .range([margin.left, width - margin.right]); + + // Escala Y: banda para los nombres de los salones + const y = d3.scaleBand() + .domain(data.map(d => d.salon)) + .range([margin.top, height - margin.bottom]) + .padding(0.1); + + // Dibujar eje X + svg.append("g") + .attr("transform", `translate(0, ${height - margin.bottom})`) + .call(d3.axisBottom(x)); + + // Dibujar eje Y + svg.append("g") + .attr("transform", `translate(${margin.left}, 0)`) + .call(d3.axisLeft(y)); + + // Dibujar las barras horizontales + svg.selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("y", d => y(d.salon)) + .attr("x", margin.left) + .attr("height", y.bandwidth()) + .attr("width", d => x(d.count) - margin.left) + .attr("fill", (d, i) => color(i)); // Asigna un color diferente a cada barra + + + // Agregar etiquetas de la cantidad al final de cada barra + svg.selectAll(".label") + .data(data) + .enter() + .append("text") + .attr("class", "label") + .attr("y", d => y(d.salon) + y.bandwidth() / 2 + 4) + .attr("x", d => x(d.count) + 5) + .text(d => d.count) + .attr("font-size", "12px") + .attr("fill", "#000"); + }, [reservas]); + + return ; +}; + +export default RangoFechasChart; \ No newline at end of file diff --git a/src/components/Admin/charts/TotalSalonChart.jsx b/src/components/Admin/charts/TotalSalonChart.jsx new file mode 100644 index 0000000..21514f0 --- /dev/null +++ b/src/components/Admin/charts/TotalSalonChart.jsx @@ -0,0 +1,147 @@ +// src/components/Admin/charts/TotalSalonChart.jsx +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +const TotalSalonChart = ({ reservas }) => { + const chartRef = useRef(null); + + useEffect(() => { + const svg = d3.select(chartRef.current); + svg.selectAll("*").remove(); // Limpiar el contenido del SVG + + if (!reservas || reservas.length === 0) return; + + // Agrupar reservas por salón + const reservasPorSalon = d3.rollup( + reservas, + (v) => v.length, + (d) => d.idSalon + ); + const data = Array.from(reservasPorSalon, ([salon, count]) => ({ + salon, + count, + })); + + // Dimensiones generales + const width = 600; + const height = 400; + svg.attr("width", width).attr("height", height); + + if (data.length === 1) { + // Si solo hay un salón, mostrar gráfico de dona (donut chart) + const radius = Math.min(width, height) / 2 - 40; // Ajusta el margen interno + + const g = svg + .append("g") + .attr("transform", `translate(${width / 2}, ${height / 2})`); + + // Configurar el pie/donut chart + const pie = d3.pie().value((d) => d.count); + const arc = d3.arc().innerRadius(radius * 0.5).outerRadius(radius); + const color = d3.scaleOrdinal(d3.schemeCategory10); + + const arcs = g + .selectAll(".arc") + .data(pie(data)) + .enter() + .append("g") + .attr("class", "arc"); + + arcs + .append("path") + .attr("d", arc) + .attr("fill", (d, i) => color(i)) + .transition() + .duration(800) + .attrTween("d", function (d) { + const i = d3.interpolate({ startAngle: 0, endAngle: 0 }, d); + return function (t) { + return arc(i(t)); + }; + }); + + // Etiqueta en el centro con el total de reservas + g.append("text") + .attr("text-anchor", "middle") + .attr("font-size", "24px") + .attr("font-weight", "bold") + .attr("fill", "#000") + .text(`${data[0].count}`); + + // Etiqueta debajo del gráfico para indicar el salón + svg + .append("text") + .attr("x", width / 2) + .attr("y", height - 20) + .attr("text-anchor", "middle") + .attr("font-size", "18px") + .attr("font-weight", "bold") + .attr("fill", "#000") + .text(`Salón: ${data[0].salon}`); + } else { + // Si hay más de un salón, mostrar gráfico de barras horizontal + const margin = { top: 20, right: 20, bottom: 50, left: 100 }; + + // Escala X: lineal para la cantidad de reservas + const x = d3 + .scaleLinear() + .domain([0, d3.max(data, (d) => d.count)]) + .range([margin.left, width - margin.right]) + .nice(); + + // Escala Y: de banda para los nombres de los salones + const y = d3 + .scaleBand() + .domain(data.map((d) => d.salon)) + .range([margin.top, height - margin.bottom]) + .padding(0.1); + + // Dibujar eje X + svg + .append("g") + .attr("transform", `translate(0, ${height - margin.bottom})`) + .call(d3.axisBottom(x)); + + // Dibujar eje Y + svg + .append("g") + .attr("transform", `translate(${margin.left}, 0)`) + .call(d3.axisLeft(y)); + + const color = d3.scaleOrdinal(d3.schemeCategory10); + + // Dibujar barras horizontales con transición + svg + .selectAll(".bar") + .data(data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("y", (d) => y(d.salon)) + .attr("x", margin.left) + .attr("height", y.bandwidth()) + .attr("width", 0) + .attr("fill", (d, i) => color(i)) + .transition() + .duration(800) + .attr("width", (d) => x(d.count) - margin.left); + + // Agregar etiquetas de cantidad al final de cada barra + svg + .selectAll(".label") + .data(data) + .enter() + .append("text") + .attr("class", "label") + .attr("y", (d) => y(d.salon) + y.bandwidth() / 2 + 4) + .attr("x", (d) => x(d.count) + 5) + .text((d) => d.count) + .attr("font-size", "12px") + .attr("fill", "#000"); + } + }, [reservas]); + + return ; +}; + +export default TotalSalonChart; \ No newline at end of file diff --git a/src/components/Admin/filters/DiaSalonFilter.jsx b/src/components/Admin/filters/DiaSalonFilter.jsx new file mode 100644 index 0000000..ff841b6 --- /dev/null +++ b/src/components/Admin/filters/DiaSalonFilter.jsx @@ -0,0 +1,70 @@ +// src/components/Admin/filters/DiaSalonFilter.jsx +import React, { useState } from "react"; +import styled from "styled-components"; + +const DiaSalonFilter = ({ onBuscar }) => { + const [dia, setDia] = useState(""); + + const handleBuscar = () => { + if (!dia) { + alert("Por favor selecciona un día"); + return; + } + onBuscar({ dia }); + }; + + return ( + + + + + + + + ); +}; + +export default DiaSalonFilter; + +const Container = styled.div` + margin-bottom: 20px; +`; + +const Row = styled.div` + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 10px; +`; + +const Label = styled.label` + font-size: 16px; + font-weight: bold; + color: #1e1e1e; +`; + +const Select = styled.select` + padding: 8px 16px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; + appearance: none; +`; + +const Button = styled.button` + background-color: #52b69a; + color: #fff; + padding: 8px 16px; + border: none; + border-radius: 8px; + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/Admin/filters/MesSalonFilter.jsx b/src/components/Admin/filters/MesSalonFilter.jsx new file mode 100644 index 0000000..2ee0075 --- /dev/null +++ b/src/components/Admin/filters/MesSalonFilter.jsx @@ -0,0 +1,71 @@ +// src/components/Admin/filters/MesSalonFilter.jsx +import React, { useState } from "react"; +import styled from "styled-components"; + +const MesSalonFilter = ({ onBuscar }) => { + const [mes, setMes] = useState(""); + + const handleBuscar = () => { + if (!mes) return; + // El valor seleccionado ya viene en formato "YYYY-MM" + onBuscar({ mes }); + }; + + return ( + + + + + + + + ); +}; + +export default MesSalonFilter; + +/* Estilos */ +const Container = styled.div` + margin-bottom: 20px; +`; +const Row = styled.div` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +`; +const Label = styled.label` + font-size: 16px; + font-weight: bold; + color: #1e1e1e; +`; +const Select = styled.select` + padding: 8px 16px; + font-size: 16px; + font-weight: bold; + border: 1px solid #ccc; + border-radius: 4px; + appearance: none; +`; +const Button = styled.button` + background-color: #52b69a; + color: #fff; + padding: 8px 16px; + border: none; + border-radius: 8px; + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/Admin/filters/RangoFechasFilter.jsx b/src/components/Admin/filters/RangoFechasFilter.jsx new file mode 100644 index 0000000..b5df384 --- /dev/null +++ b/src/components/Admin/filters/RangoFechasFilter.jsx @@ -0,0 +1,69 @@ +// src/components/Admin/filters/RangoFechasFilter.jsx +import React, { useState } from "react"; +import styled from "styled-components"; + +const RangoFechasFilter = ({ onBuscar }) => { + const [fechaInicio, setFechaInicio] = useState(""); + const [fechaFin, setFechaFin] = useState(""); + + const handleBuscar = () => { + if (!fechaInicio || !fechaFin) { + alert("Por favor, selecciona ambas fechas."); + return; + } + onBuscar({ fechaInicio, fechaFin }); + }; + + return ( + + + + setFechaInicio(e.target.value)} + /> + + + + setFechaFin(e.target.value)} + /> + + + + ); +}; + +export default RangoFechasFilter; + +const Container = styled.div` + margin-bottom: 20px; +`; +const Row = styled.div` + display: flex; + align-items: center; + margin-bottom: 10px; + gap: 10px; +`; +const Label = styled.label` + font-size: 16px; + font-weight: bold; + color: #1e1e1e; +`; +const Input = styled.input` + padding: 8px 16px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; +`; +const Button = styled.button` + background-color: #52b69a; + color: #fff; + padding: 8px 16px; + border: none; + border-radius: 8px; + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/Admin/filters/TotalSalonFilter.jsx b/src/components/Admin/filters/TotalSalonFilter.jsx new file mode 100644 index 0000000..7203e8a --- /dev/null +++ b/src/components/Admin/filters/TotalSalonFilter.jsx @@ -0,0 +1,81 @@ +// src/components/Admin/filters/TotalSalonFilter.jsx +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import { getSalones } from "../../../api/salon"; // Ajusta la ruta según tu estructura + +/** + * Filtro para seleccionar un salón (opcional). + * Si se deja en "Todos", se interpretará que se desean reservas de todos los salones. + */ +const TotalSalonFilter = ({ onBuscar, token }) => { + const [salon, setSalon] = useState(""); + const [listaSalones, setListaSalones] = useState([]); + + useEffect(() => { + // Cargar la lista de salones activos + getSalones({ activo: true }, token) + .then((salones) => setListaSalones(salones)) + .catch((err) => console.error("Error al obtener salones:", err)); + }, []); + + const handleBuscar = () => { + // Si no se selecciona ningún salón se enviará el filtro vacío para traer todas las reservas + onBuscar({ ...(salon && { idSalon: salon }) }); + }; + + return ( + + + + + + + + ); +}; + +export default TotalSalonFilter; + +const Container = styled.div` + margin-bottom: 20px; + text-align: center; +`; + +const Row = styled.div` + margin-bottom: 10px; + display: flex; + justify-content: center; + align-items: center; + gap: 10px; +`; + +const Label = styled.label` + font-size: 16px; + font-weight: bold; + color: #1e1e1e; +`; + +const Select = styled.select` + padding: 8px 16px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; + appearance: none; +`; + +const Button = styled.button` + background-color: #52b69a; + color: #fff; + padding: 10px 20px; + border: none; + border-radius: 8px; + font-size: 16px; + cursor: pointer; +`; \ No newline at end of file diff --git a/src/components/Login/FormInput.jsx b/src/components/Login/FormInput.jsx new file mode 100644 index 0000000..38f5aef --- /dev/null +++ b/src/components/Login/FormInput.jsx @@ -0,0 +1,58 @@ +import React from "react"; +import styled from "styled-components"; + +const FormInput = ({ label, type, onChange, value }) => { + return ( + + + + {label} + + ); +}; + +const InputWrapper = styled.div` + margin-bottom: 60px; /* Ajusta el espacio vertical entre los campos */ + width: 100%; + @media (max-width: 640px) { + margin-bottom: 40px; + } +`; + +const InputLabel = styled.label` + color: rgb(107, 155, 61); /* Negro */ + font-family: "Inter", sans-serif; + font-size: 14px; + font-weight: 400; + margin-top: 10px; + display: block; +`; + +const StyledInput = styled.input` + width: 100%; + border: none; + background: transparent; + font-family: "Inter", sans-serif; + font-size: 16px; /* Tamaño mayor */ + outline: none; + padding: 0; + color: #000; /* Texto en negro */ + + ::placeholder { + color: #999; /* Color del placeholder */ + } +`; + +const InputUnderline = styled.div` + width: 100%; + height: 1px; + margin-top: 10px; + background-color: #aaba70; +`; + +export default FormInput; \ No newline at end of file diff --git a/src/components/Login/ImageSection.jsx b/src/components/Login/ImageSection.jsx new file mode 100644 index 0000000..a24d864 --- /dev/null +++ b/src/components/Login/ImageSection.jsx @@ -0,0 +1,55 @@ +import React from "react"; +import styled from "styled-components"; + +const ImageSection = () => { + return ( + + + + + ); +}; + +const ImageContainer = styled.section` + width: 50%; + position: relative; + overflow: hidden; + @media (max-width: 991px) { + width: 100%; + height: 400px; + } + @media (max-width: 640px) { + height: 300px; + } +`; + +const BackgroundImage = styled.img` + width: 100%; + height: 100%; + object-fit: cover; + margin-bottom: 200px; + margin-left: 1px; +`; + +const SchoolLogo = styled.img` + position: absolute; + bottom: 25px; + right: 25px; + width: 468px; + height: 240px; + @media (max-width: 991px) { + width: 300px; + height: auto; + } + @media (max-width: 640px) { + width: 200px; + } +`; + +export default ImageSection; \ No newline at end of file diff --git a/src/components/Login/LoginForm.jsx b/src/components/Login/LoginForm.jsx new file mode 100644 index 0000000..b55a4a3 --- /dev/null +++ b/src/components/Login/LoginForm.jsx @@ -0,0 +1,133 @@ +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styled from "styled-components"; +import FormInput from "./FormInput"; +import { login } from "../../api/auth"; +import { consultarUsuarioPorCorreo } from "../../api/usuario"; + +function LoginForm({ onLogin }) { + const [correo, setCorreo] = useState(""); + const [password, setPassword] = useState(""); + const [errorMsg, setErrorMsg] = useState(""); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setErrorMsg(""); + try { + const token = await login(correo, password); + + if (token) { + localStorage.setItem("token", token); + + try { + const usuario = await consultarUsuarioPorCorreo(correo); + onLogin(usuario); + + if (usuario.isAdmin) { + navigate("/administrador"); + } else { + navigate("/home"); + } + } catch (error) { + + } + } else { + setErrorMsg("Usuario no encontrado o contraseña incorrecta"); + } + } catch (error) { + setErrorMsg("Usuario no encontrado o contraseña incorrecta"); + console.error(error); + } + }; + + return ( + + +
+ setCorreo(e.target.value)} + value={correo} + /> + setPassword(e.target.value)} + value={password} + /> + Ingresar + + {errorMsg && {errorMsg}} + +
+ ); +}; + +const FormContainer = styled.section` + display: flex; + flex-direction: column; + padding: 155px 153px; + width: 50%; + position: relative; + @media (max-width: 991px) { + width: 100%; + padding: 40px 20px; + } + @media (max-width: 640px) { + padding: 20px; + } +`; + +const LogoImage = styled.img` + width: 226px; + height: 151px; + margin: 0 auto 137px; + @media (max-width: 640px) { + width: 180px; + height: 120px; + margin-bottom: 60px; + } +`; + +const LoginButton = styled.button` + width: 246px; + height: 54px; + border-radius: 10px; + border: none; + color:white; + font-family: "Inter", sans-serif; + font-size: 19px; + font-weight: 700; + margin: 0 auto; + margin-top: 100px; + cursor: pointer; + background-color: rgb(107, 155, 61); + display: block; + @media (max-width: 640px) { + width: 100%; + } +`; + +const ErrorMessage = styled.div` + color: red; + font-size: 16px; + font-weight: bold; + text-align: center; + margin-top: 20px; +`; + +const CvdsLogo = styled.img` + width: 59px; + height: 63px; + margin: 130px auto 0; +`; + +export default LoginForm; \ No newline at end of file diff --git a/src/pages/Administrator/AdministratorHome.jsx b/src/pages/Administrator/AdministratorHome.jsx new file mode 100644 index 0000000..0bffb6a --- /dev/null +++ b/src/pages/Administrator/AdministratorHome.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import styled from "styled-components"; +import ConsultaMesSalon from "./consultaModal/ConsultaMesSalon"; +import ConsultaDiaSalon from "./consultaModal/ConsultaDiaSalon"; +import ConsultaRangoFechas from "./consultaModal/ConsultaRangoFechas"; +import ConsultaEstado from "./consultaModal/ConsultaEstado"; +import ConsultaTotalSalon from "./consultaModal/ConsultaTotalSalon"; +import ConsultaPrioridad from "./consultaModal/ConsultaPrioridad"; +import ConsultaDemanda from "./consultaModal/ConsultaDemanda"; + +const CONSULTAS = [ + { id: 1, titulo: "Reservas por Mes y Salón", componente: (token) => }, + { id: 2, titulo: "Reservas por Día y Salón", componente: (token) => }, + { id: 3, titulo: "Reservas por Rango de Fechas", componente: (token) => }, + { id: 4, titulo: "Comparativo Activas vs Inactivas", componente: (token) => }, + { id: 5, titulo: "Reservas Totales por Salón", componente: (token) => }, + { id: 6, titulo: "Promedio de Reservas por Prioridad", componente: (token) => }, + { id: 7, titulo: "Demanda por Laboratorios", componente: (token) => }, +]; + +const AdministratorHome = ({ token }) => { + return ( + + Centro de Insights + + {CONSULTAS.map((consulta) => ( + +

{consulta.titulo}

+ {consulta.componente(token)} +
+ ))} +
+
+ ); +}; + +export default AdministratorHome; + +/* Estilos */ +const MainContainer = styled.main` + display: flex; + flex-direction: column; + width: 100%; + color: var(--variable-collection-current-color); +`; + +const TitleSection = styled.h2` + font-size: 18px; + font-weight: 600; + text-align: flex-start; +`; + +const GridContainer = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 20px; + + @media (max-width: 768px) { + grid-template-columns: repeat(2, 1fr); + } + @media (max-width: 480px) { + grid-template-columns: 1fr; + } +`; + +const ConsultaCard = styled.div` + padding: 15px; + border-radius: 10px; + font-weight: 600; + text-align: flex-start; + border: 1px solid #D9D9D9; + transition: background-color 0.3s ease; + box-shadow: 0px 4px 6px var(--variable-collection-shadow); +`; + +const ContentContainer = styled.div` +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaDemanda.jsx b/src/pages/Administrator/consultaModal/ConsultaDemanda.jsx new file mode 100644 index 0000000..1378683 --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaDemanda.jsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import DemandaChart from "../../../components/Admin/charts/DemandaChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaDemanda = ({ token }) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + handleBuscar(); + }, []); + + const handleBuscar = async () => { + try { + setErrorMsg(""); + setReservas([]); + + // No se envían filtros; se obtiene el total de reservas + const data = await getReservas({}, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas para mostrar la demanda."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + {errorMsg && {errorMsg}} + + + + ); +}; + +export default ConsultaDemanda; + +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaDiaSalon.jsx b/src/pages/Administrator/consultaModal/ConsultaDiaSalon.jsx new file mode 100644 index 0000000..ead58e5 --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaDiaSalon.jsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import DiaSalonFilter from "../../../components/Admin/filters/DiaSalonFilter"; +import DiaSalonChart from "../../../components/Admin/charts/DiaSalonChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaDiaSalon = ({ token }) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + const handleBuscar = async (filtros) => { + try { + setErrorMsg(""); + setReservas([]); + if (!filtros.dia) { + setErrorMsg("Por favor selecciona un día."); + return; + } + // Llama al endpoint de reservas filtrando solo por día de la semana + const data = await getReservas({diaSemana: filtros.dia}, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas para el día seleccionado."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + + {errorMsg && {errorMsg}} + + + + ); +}; + +export default ConsultaDiaSalon; + +/* Estilos del contenedor */ +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaEstado.jsx b/src/pages/Administrator/consultaModal/ConsultaEstado.jsx new file mode 100644 index 0000000..05316af --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaEstado.jsx @@ -0,0 +1,55 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; +import EstadoChart from "../../../components/Admin/charts/EstadoChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaEstado = ({ token }) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + + useEffect(() => { + handleBuscar(); + }, []); + + const handleBuscar = async () => { + try { + setErrorMsg(""); + setReservas([]); + // Llamar al endpoint de reservas sin filtro específico de estado + const data = await getReservas({}, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + {errorMsg && {errorMsg}} + {reservas.length > 0 && } + + + ); +}; + +export default ConsultaEstado; + +/* Estilos */ +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaMesSalon.jsx b/src/pages/Administrator/consultaModal/ConsultaMesSalon.jsx new file mode 100644 index 0000000..fbcd017 --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaMesSalon.jsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import MesSalonFilter from "../../../components/Admin/filters/MesSalonFilter"; +import MesSalonChart from "../../../components/Admin/charts/MesSalonChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaMesSalon = ({ token }) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + const handleBuscar = async (filtros) => { + try { + setErrorMsg(""); + setReservas([]); + + if (!filtros.mes) { + setErrorMsg("Por favor selecciona un mes."); + return; + } + const data = await getReservas({ mes: filtros.mes }, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas para el mes seleccionado."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + + {errorMsg && {errorMsg}} + + + + ); +}; + +export default ConsultaMesSalon; + +/* Estilos */ +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaPrioridad.jsx b/src/pages/Administrator/consultaModal/ConsultaPrioridad.jsx new file mode 100644 index 0000000..9dd6823 --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaPrioridad.jsx @@ -0,0 +1,55 @@ +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import PromedioPrioridadChart from "../../../components/Admin/charts/PromedioPrioridadChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaPrioridad = ({ token }) => { + const [data, setData] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + handleBuscar(); + }, []); + + const handleBuscar = async () => { + try { + setErrorMsg(""); + setData([]); + // Se asume que el endpoint interpreta el parámetro "consultarPorPrioridad" + const filtros = { consultarPorPrioridad: true }; + const result = await getReservas(filtros, token); + if (!result || result.length === 0) { + setErrorMsg("No se encontraron datos para el promedio de reservas por prioridad."); + } else { + // Se asume que cada objeto viene con { priority, promedio } + setData(result); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando datos."); + } + }; + + return ( + + + {errorMsg && {errorMsg}} + + + + ); +}; + +export default ConsultaPrioridad; + +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaRangoFechas.jsx b/src/pages/Administrator/consultaModal/ConsultaRangoFechas.jsx new file mode 100644 index 0000000..015b4ce --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaRangoFechas.jsx @@ -0,0 +1,59 @@ +import React, { useState } from "react"; +import styled from "styled-components"; +import RangoFechasFilter from "../../../components/Admin/filters/RangoFechasFilter"; +import RangoFechasChart from "../../../components/Admin/charts/RangoFechasChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaRangoFechas = ({ token }) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + const handleBuscar = async (filtros) => { + try { + setErrorMsg(""); + setReservas([]); + if (!filtros.fechaInicio || !filtros.fechaFin) { + setErrorMsg("Por favor, selecciona ambas fechas."); + return; + } + // Se llama al endpoint con los parámetros fechaInicio y fechaFin + const data = await getReservas({ + fechaInicio: filtros.fechaInicio, + fechaFin: filtros.fechaFin + }, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas en el rango de fechas seleccionado."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + + {errorMsg && {errorMsg}} + + + + ); +}; + +export default ConsultaRangoFechas; + +/* Estilos */ +const Container = styled.div` +`; + +const Body = styled.div` + margin-top: 10px; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Administrator/consultaModal/ConsultaTotalSalon.jsx b/src/pages/Administrator/consultaModal/ConsultaTotalSalon.jsx new file mode 100644 index 0000000..08162f1 --- /dev/null +++ b/src/pages/Administrator/consultaModal/ConsultaTotalSalon.jsx @@ -0,0 +1,53 @@ +import React, { useState, useEffect } from "react"; +import styled from "styled-components"; +import TotalSalonFilter from "../../../components/Admin/filters/TotalSalonFilter"; +import TotalSalonChart from "../../../components/Admin/charts/TotalSalonChart"; +import { getReservas } from "../../../api/reserva"; + +const ConsultaTotalSalon = ({token}) => { + const [reservas, setReservas] = useState([]); + const [errorMsg, setErrorMsg] = useState(""); + + useEffect(() => { + handleBuscar(""); + }, []); + + const handleBuscar = async (filtros) => { + try { + setErrorMsg(""); + setReservas([]); + // Si se envía un filtro de idSalon, se usa; si no, se obtiene la data de todos los salones. + const data = await getReservas({ ...(filtros.idSalon && { idSalon: filtros.idSalon }) }, token); + if (!data || data.length === 0) { + setErrorMsg("No se encontraron reservas para el salón seleccionado."); + } else { + setReservas(data); + } + } catch (error) { + setErrorMsg(error.message || "Error consultando reservas"); + } + }; + + return ( + + + {errorMsg && {errorMsg}} + {reservas.length > 0 && } + + ); +}; + +export default ConsultaTotalSalon; + +/* Estilos */ +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; +`; + +const ErrorMessage = styled.div` + color: red; + font-weight: bold; + margin: 10px 0; +`; \ No newline at end of file diff --git a/src/pages/Login/LoginPage.jsx b/src/pages/Login/LoginPage.jsx new file mode 100644 index 0000000..c07517b --- /dev/null +++ b/src/pages/Login/LoginPage.jsx @@ -0,0 +1,25 @@ +import React from "react"; +import styled from "styled-components"; +import LoginForm from "../../components/Login/LoginForm"; +import ImageSection from "../../components/Login/ImageSection"; + +function LoginPage({ onLogin }) { + return ( + + + + + ); +}; + +const PageContainer = styled.main` + display: flex; + width: 100%; + height: 100vh; + background-color:rgb(242, 245, 229); + @media (max-width: 991px) { + flex-direction: column; + } +`; + +export default LoginPage; \ No newline at end of file