37 Commits

Author SHA1 Message Date
Emilio-11 12d4031281 cambio a la hora de mostrar el objeto 2026-02-10 12:43:18 -06:00
IO 7ee5c28208 se agregò check box 2026-02-04 13:04:46 -06:00
evenegas 393c25da87 Se actualizo la version de next 2026-01-09 09:17:47 -06:00
evenegas ff33729aba Error 2025-09-12 12:35:33 -06:00
evenegas ae4029ac4e Se agregaron usuarios externos 2025-09-09 11:03:41 -06:00
evenegas 7475ca2fbd Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/cargaMasiva_front into develop 2025-09-03 12:27:48 -06:00
evenegas adb11c1965 agregar botón google 2025-09-03 12:22:42 -06:00
evenegas a9fb5d5a88 Se agregaró inicio de Google y contraseña 2025-09-02 14:00:07 -06:00
stern cb9d15a103 Uso de NodeJS 22 en script start 2025-08-18 11:01:02 -06:00
evenegas 76ff60304a Se agregaron condiciones de texto 2025-08-18 10:43:47 -06:00
evenegas 09d9e56c93 Se agregaron notas 2025-08-15 10:28:18 -06:00
evenegas f155313b82 Se agregaron notas 2025-08-15 10:18:15 -06:00
evenegas 5f8c06dd6a se agregaron textos y cambio de orden de componentes 2025-08-14 10:16:23 -06:00
evenegas 5d5210e48a cambio de estilo 2025-07-31 13:58:00 -06:00
evenegas 6859fca06d Se cambiaron estilos 2025-07-30 10:25:09 -06:00
evenegas 5f1c6cee9e se quitó un console.log 2025-07-04 13:44:18 -06:00
evenegas fa63017016 se agregó un console.log 2025-07-04 13:33:00 -06:00
evenegas d5da232366 se agregó un console.log 2025-07-04 13:26:29 -06:00
evenegas af1e926e71 se hicieron cambios en el diseño 2025-07-04 11:25:58 -06:00
evenegas 0790cda760 se hicieron cambios en el diseño 2025-07-04 10:19:48 -06:00
evenegas de19f41436 se hicieron cambios en como se muestra la información 2025-07-03 11:16:43 -06:00
evenegas e99376ebdd Se hicieron cabios en el diseño 2025-07-02 15:46:51 -06:00
evenegas 8a349d8259 Se hicieron cabios en el diseño 2025-07-02 15:25:49 -06:00
evenegas c59ee182c2 Cambio en diseños 2025-07-01 18:14:03 -06:00
evenegas 148c84483a Cambio en diseños 2025-07-01 17:15:01 -06:00
evenegas 3df7ef428b Cambio de direcciones 2025-06-30 14:03:25 -06:00
evenegas b18fd46acf Cambios de estilo 2025-06-30 12:55:08 -06:00
jorgemike 6b8d7ba585 Refactor code structure for improved readability and maintainability 2025-06-27 14:16:30 -06:00
miguel cab84e15cb Refactor landing page components and improve session management
- Updated the Dashboard component to simplify layout and styling.
- Enhanced IconCircle component with improved CSS for better alignment and display.
- Refactored global styles for consistency and clarity.
- Improved LandingBody component to utilize Link for navigation.
- Revamped Header component to manage navigation state more effectively.
- Implemented a new LoginPage component for user authentication.
- Introduced useSession hook for managing user session state.
- Updated CargaArchivo component to use a consistent Button component.
- Enhanced Consulta component for better error handling and display.
- Cleaned up Footer component styling and structure.
2025-06-25 16:21:33 -06:00
evenegas fa3f3da9f9 configuración de back 2025-06-25 12:28:14 -06:00
Your Name 20f01dccb3 GUI validada final 2025-06-25 06:20:58 -06:00
Your Name f27effa389 Merge branch 'develop' of https://repositorio.acatlan.unam.mx/CIDWA/cargaMasiva_front into eithan 2025-06-25 05:19:07 -06:00
Your Name c97af64e4c eliminar los border en los componentes 2025-06-25 05:07:14 -06:00
Your Name 43502cb234 componentes en la pagina landing 2025-06-24 12:49:40 -06:00
Your Name 1628ab796e landing page y login listos en estilos 2025-06-20 21:29:52 -06:00
evenegas 7f8ff21d13 se agregaron conexiones 2025-06-20 13:14:52 -06:00
Your Name 2b22368d5e header y footer a layout de carga y visualizacion, bg azul removido 2025-06-20 10:04:20 -06:00
31 changed files with 2959 additions and 574 deletions
+914 -392
View File
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -5,14 +5,16 @@
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"start": "source ~/.nvm/nvm.sh && nvm use 22.10.0 && npm install && next build && next start -p 3256",
"lint": "next lint"
},
"dependencies": {
"axios": "^1.9.0",
"bootstrap": "^5.3.6",
"bootstrap-icons": "^1.13.1",
"next": "15.3.3",
"framer-motion": "^12.18.1",
"jwt-decode": "^4.0.0",
"next": "^16.0.7",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -21,7 +23,10 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"sass": "^1.32.13",
"tailwindcss": "^4.1.12",
"typescript": "^5"
}
}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import Input from "@/components/input";
import PasswordInput from "@/components/password";
import { useState } from "react";
import axios from "axios";
import { useRouter } from "next/navigation";
export default function Login() {
const [value, setValue] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async () => {
try {
const response = await axios.post('https://venus.acatlan.unam.mx/servicios_pcpuma_test/login', {
email: value,
password: password,
});
const token = response.data.access_token;
localStorage.setItem('token', token);
// Redirige al usuario
window.location.href = '/carga';
} catch (err: any) {
const message =
err.response?.data?.message ||
err.message ||
'Error al iniciar sesión';
setError(message);
}
};
return (
<main style={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#063970"
}}>
<div style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#f3f4f6",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 4px 6px rgba(253, 253, 253, 0.1)",
width: "100%",
}}>
<div style={{
background: "#f3f4f6",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 4px 6px rgba(253, 253, 253, 0.1)",
width: "100%",
maxWidth: "400px",
}}>
<h2 style={{ color: "#fbbf24", textAlign: "center", marginBottom: "0.25rem" }}>Inicio de sesión</h2>
<h1 style={{ color: "#1e3a8a", textAlign: "center", fontWeight: "bold", marginBottom: "2rem" }}>Sistema de --------</h1>
<Input
label="Correo"
placeholder="escribe tu correo"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<PasswordInput
label="Contraseña"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && (
<p style={{ color: "red", marginTop: "0.5rem", textAlign: "center" }}>{error}</p>
)}
<div style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
gap: "16px",
}}>
<button
type="button"
onClick={handleLogin}
style={{
width: "100%",
padding: "0.5rem",
marginTop: "1rem",
backgroundColor: "#2563eb",
color: "#fff",
fontWeight: "bold",
border: "none",
borderRadius: "4px",
cursor: "pointer"
}}
>
Iniciar Sesión
</button>
<button
type="button"
onClick={() => router.replace("/visualizacion")}
style={{
width: "100%",
padding: "0.5rem",
marginTop: "1rem",
backgroundColor: "#009939",
color: "#fff",
fontWeight: "bold",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Iniciar Sesión Sin Contraseña
</button>
</div>
</div>
</div>
</main>
);
}
+83 -18
View File
@@ -1,26 +1,91 @@
"use client";
import Button from "@/components/button";
import SimpleInput from "@/components/input";
import PasswordInput from "@/components/password";
import React from "react";
import React, { useState } from "react";
import axios from "axios";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
setLoading(true);
try {
const response = await axios.post(`${apiUrl}/login`, {
email: email,
password: password,
});
const token = response.data.access_token;
localStorage.setItem("token", token);
// Disparar evento personalizado para notificar cambio de token
window.dispatchEvent(new Event("tokenChanged"));
// Limpiar formulario
setEmail("");
setPassword("");
setError("");
} catch (err: any) {
const message =
err.response?.data?.message || err.message || "Error al iniciar sesión";
setError(message);
} finally {
setLoading(false);
}
};
return (
<div className="flex-grow-1 d-flex flex-column justify-content-center align-items-center">
<h2 className="text-dorado">Bienvenido al sistema de</h2>
<h1 className="text-azul">Carga Masiva</h1>
<SimpleInput
label="Usuario"
className={{
container: "w-300px mb-3",
}}
/>
<PasswordInput
label="Contraseña"
className={{
container: "w-300px mb-3",
}}
/>
<Button>Iniciar Sesión</Button>
</div>
<>
<motion.main
className="auth-page flex-fill flex-grow-1 d-flex flex-column justify-content-center align-items-center"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0, duration: 0.5 }}
>
<h2 className="text-dorado">Bienvenido al sistema de</h2>
<h1 className="text-dorado">Servicios PC PUMA y CEDETEC</h1>
<SimpleInput
label="Usuario"
className={{
container: "w-300px mb-3",
}}
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<PasswordInput
label="Contraseña"
className={{
container: "w-300px mb-3",
input: "form-control",
button: "btn btn-outline-secondary",
}}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{error && <p className="text-danger text-center mb-3">{error}</p>}
<div className="d-flex gap-3">
<Button onClick={handleLogin} disabled={loading}>
{loading ? "Iniciando sesión..." : "Iniciar Sesión"}
</Button>
</div>
</motion.main>
</>
);
}
+282
View File
@@ -0,0 +1,282 @@
"use client";
import React, { useState } from "react";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const tiposUsuarioEstudiante = [
"Diplomado", "Extra Largo", "Servicio Social", "Idiomas R (UNAM)",
"Idiomas Sabatino", "Reinscrito", "Posgrado", "Intercambio UNAM",
"Movilidad", "Ampliación de Conocimiento", "Licenciatura",
];
const tiposUsuarioEspecial = ["Caso especial"];
const tiposUsuarioTrabajador = ["Profesor", "Trabajadores"];
const opcionesCarrera = [
"ACTUARIA",
"ARQUITECTURA",
"CIENCIA DE DATOS",
"CIENCIAS POLITICAS Y ADMINISTRACIÓN PUBLICA",
"COMUNICACION",
"COMUNIDAD EXTERNA",
"DERECHO",
"DERECHO (SUA)",
"DISEÑO GRAFICO",
"DOCTORADO EN CIENCIAS POLÍTICAS Y SOCIALES",
"DOCTORADO EN DERECHO",
"DOCTORADO EN ECONOMÍA",
"DOCTORADO EN PEDAGOGÍA",
"DOCTORADO EN URBANISMO",
"ECONOMIA",
"ENSEÑANZA DE ALEMÁN (LENG.EXTRANJ)SUAyED",
"ENSEÑANZA DE ESPAÑOL(LENG.EXTRANJ)SUAyED",
"ENSEÑANZA DE FRANCÉS(LENG.EXTRANJ)SUAyED",
"ENSEÑANZA DE INGLES",
"ENSEÑANZA DE INGLÉS (LENG.EXTRANJ)SUAyED",
"ENSEÑANZA DE ITALIANO(LENG EXTRANJ) SUAyED",
"ESPECIALIZACIÓN EN COSTOS EN LA CONSTRUCCION",
"ESPECIALIZACIÓN EN DERECHO ADMINISTRATIVO",
"ESPECIALIZACIÓN EN DERECHO DE LA PROPIEDAD INTELECTUAL",
"ESPECIALIZACIÓN EN DERECHO FISCAL",
"ESPECIALIZACIÓN EN DERECHO NOTARIAL Y REGISTRAL",
"ESPECIALIZACIÓN EN DERECHO PENAL",
"ESPECIALIZACIÓN EN DERECHO SANITARIO",
"ESPECIALIZACIÓN EN DERECHOS HUMANOS",
"ESPECIALIZACIÓN EN GENERO Y DERECHO",
"ESPECIALIZACIÓN EN GEOTECNIA",
"ESPECIALIZACIÓN EN INSTITUCIONES ADMINISTRATIVAS DE FINANZAS PÚBLICAS",
"ESPECIALIZACIÓN EN SISTEMAS DE CALIDAD",
"ESPECIALIZACIÓN EN TECNOLOGIA DIGITAL PARA LA ENSEÑANZA DE MATEMÁTICAS",
"FILOSOFIA",
"HISTORIA",
"INGENIERIA CIVIL",
"LENGUA Y LITERATURA HISPANICAS",
"MAESTRÍA EN COMUNICACIÓN",
"MAESTRÍA EN DERECHO",
"MAESTRÍA EN DOCENCIA PARA LA EDUCACIÓN MEDIA SUPERIOR",
"MAESTRÍA EN ECONOMÍA",
"MAESTRÍA EN ESTUDIOS EN RELACIONES INTERNACIONALES",
"MAESTRÍA EN ESTUDIOS MÉXICO - ESTADOS UNIDOS",
"MAESTRÍA EN ESTUDIOS POLÍTICOS Y SOCIALES",
"MAESTRÍA EN GOBIERNO Y ASUNTOS PÚBLICOS",
"MAESTRÍA EN PEDAGOGÍA",
"MAESTRÍA EN POLÍTICA CRIMINAL",
"MAESTRÍA EN URBANISMO",
"MATEMÁTICAS APLICADAS Y COMPUTACIÓN",
"PEDAGOGIA",
"PERIODISMO Y COMUNICACION COLECTIVA",
"RELAC. INTERNACIONALES (SUA)",
"RELACIONES INTERNACIONALES",
"SOCIOLOGIA",
];
const FormularioCargaIndividual = () => {
const [tipoUsuario, setTipoUsuario] = useState("");
const [formData, setFormData] = useState<any>({});
const [errores, setErrores] = useState<string[]>([]);
const [mensaje, setMensaje] = useState("");
const [guardando, setGuardando] = useState(false); // 🔹 Estado para controlar el botón
const camposEstudiante = [
"num_cuenta", "carrera", "nombre",
"a_paterno", "a_materno", "fecha_nacimiento", "genero", "generacion",
];
const camposEspecial = [
"nombre", "a_paterno", "a_materno", "contraseña",
"usuario",
];
const camposTrabajador = [
"num_cuenta", "nombre", "a_paterno", "a_materno", "rfc",
"fecha_nacimiento", "genero",
];
const camposVisibles =
tipoUsuario === ""
? []
: tiposUsuarioEstudiante.includes(tipoUsuario)
? camposEstudiante
: tiposUsuarioTrabajador.includes(tipoUsuario)
? camposTrabajador
: tiposUsuarioEspecial.includes(tipoUsuario)
? camposEspecial
: [];
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
) => {
const { name, value } = e.target;
if (name === "tipo_usuario") {
setTipoUsuario(value);
setFormData((prev: any) => ({
...prev,
tipo_usuario: value,
}));
} else {
setFormData((prev: any) => ({
...prev,
[name]: value,
}));
}
};
const validarCampos = (): boolean => {
const faltantes = camposVisibles.filter(
(campo) => !formData[campo] || formData[campo].toString().trim() === ""
);
setErrores(faltantes);
return faltantes.length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMensaje("");
if (!validarCampos()) {
setMensaje("Por favor llena todos los campos obligatorios.");
return;
}
setGuardando(true); // 🔹 Empieza a guardar
try {
const token = localStorage.getItem("token");
const normalizado = { ...formData };
if (normalizado.fecha_nacimiento) {
normalizado.fecha_nacimiento = normalizado.fecha_nacimiento.replaceAll("-", "").slice(0, 8);
}
if (normalizado.generacion !== undefined && normalizado.generacion !== "") {
const num = Number(normalizado.generacion);
if (!isNaN(num)) {
normalizado.generacion = num;
} else {
delete normalizado.generacion;
}
}
const response = await fetch(`${apiUrl}/excel/carga`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(normalizado),
});
if (!response.ok) {
const error = await response.json();
console.error("Error del servidor:", error);
setMensaje(error.message);
return;
}
setMensaje("Usuario guardado correctamente ✅");
setFormData({});
setTipoUsuario("");
} catch (err) {
console.error("Error en petición fetch:", err);
setMensaje(err instanceof Error ? err.message : String(err));
} finally {
setGuardando(false); // 🔹 Termina de guardar
}
};
return (
<form onSubmit={handleSubmit} style={{ maxWidth: "600px", margin: "0 auto" }}>
<h2 className="text-xl font-semibold mt-4 mb-3 text-start">Carga Individual</h2>
{/* Select tipo_usuario */}
<div className="mb-3 text-start">
<label className="form-label fw-bold">Tipo de Usuario</label>
<select
name="tipo_usuario"
className="form-select"
value={tipoUsuario}
onChange={handleChange}
required
>
<option value="">Selecciona una opción</option>
{[...tiposUsuarioEstudiante, ...tiposUsuarioTrabajador, ...tiposUsuarioEspecial].map((tipo) => (
<option key={tipo} value={tipo}>
{tipo}
</option>
))}
</select>
</div>
{/* Renderizado dinámico de campos */}
{camposVisibles.map((campo) => (
<div key={campo} className="mb-3 text-start">
<label htmlFor={campo} className="form-label fw-bold">
{campo.replace(/_/g, " ").toUpperCase()}
</label>
{/* campo especial "genero" */}
{campo === "genero" ? (
<select
name="genero"
className={`form-select ${errores.includes(campo) ? "is-invalid" : ""}`}
value={formData.genero || ""}
onChange={handleChange}
required
>
<option value="">Selecciona M o F</option>
<option value="M">M</option>
<option value="F">F</option>
</select>
) : campo === "carrera" ? (
// 🔹 Select de carrera
<select
name="carrera"
className={`form-select ${errores.includes(campo) ? "is-invalid" : ""}`}
value={formData.carrera || ""}
onChange={handleChange}
required
>
<option value="">Selecciona una carrera</option>
{opcionesCarrera.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
):(
<input
type={campo === "fecha_nacimiento" ? "date" : "text"}
name={campo}
className={`form-control ${errores.includes(campo) ? "is-invalid" : ""}`}
value={formData[campo] || ""}
onChange={handleChange}
maxLength={campo === "rfc" ? 13 : undefined}
/>
)}
</div>
))}
{mensaje && (
<div className="alert alert-info text-start" role="alert">
{mensaje}
</div>
)}
<div className="d-grid">
<button
type="submit"
className="btn"
style={{ backgroundColor: "#003d79", color: "#fff" }}
disabled={guardando} // 🔹 Se desactiva mientras se guarda
>
{guardando ? "Guardando..." : "Guardar Usuario"}
</button>
</div>
</form>
);
};
export default FormularioCargaIndividual;
+161 -45
View File
@@ -1,34 +1,78 @@
"use client";
import React from "react";
import React, { useEffect, useState } from "react";
import FuenteCard from "@/components/fuenteCard";
import CargaArchivo from "@/components/cargaArchivo";
import Button from "@/components/button";
import { useAuthRedirect } from "@/hooks/auth";
import CargaArchivo from "@/components/cargaArchivo";
import { getUserFromToken } from "@/components/Hook";
import { useSession } from "@/hooks/use-session";
const fuentes = [
{ nombre: "Fuente 1", fecha: "Martes 3 de mayo 2025", activa: true },
{ nombre: "Fuente 2", fecha: "Martes 3 de mayo 2025", activa: true },
{ nombre: "Fuente 3", fecha: "Martes 3 de mayo 2025", activa: true },
{ nombre: "Fuente 4", fecha: "Martes 3 de mayo 2025", activa: false },
];
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
interface Fuente {
nombre: string;
fecha: string;
activa: boolean;
id_mov: number;
}
export default function Dashboard() {
const loading = useAuthRedirect();
const [fuentes, setFuentes] = useState<Fuente[]>([]);
const [descargando, setDescargando] = useState(false);
const { data: user, } = useSession()
const fetchMovimientos = async () => {
const token = localStorage.getItem("token");
if (!token) return;
const res = await fetch(
`${apiUrl}/excel/movimientos`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = await res.json();
const fuentesConvertidas: Fuente[] = data.move.map((mov: any) => ({
nombre: `${mov.reporte}: ${mov.id_mov}`,
id_mov: mov.id_mov,
fecha: new Date(mov.fecha_mov).toLocaleString("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
}),
activa: data.button,
}));
setFuentes(fuentesConvertidas);
};
useEffect(() => {
fetchMovimientos();
}, []);
if (loading) {
return <p></p>;
}
const handleDownload = async () => {
const token = localStorage.getItem("token");
if (!token) {
alert("Token no encontrado. Inicia sesión.");
return;
}
setDescargando(true);
try {
const response = await fetch("http://localhost:4000/excel/download", {
const response = await fetch(`${apiUrl}/excel/download`, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`,
@@ -41,70 +85,142 @@ export default function Dashboard() {
return;
}
// Obtén el nombre del archivo desde el header
const disposition = response.headers.get("Content-Disposition");
let filename = "usuarios.csv"; // Valor por defecto
console.log(disposition);
if (disposition && disposition.includes("filename=")) {
const match = disposition.match(/filename="?(.+?)"?$/);
if (match && match[1]) {
filename = match[1];
}
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "usuarios.tsv";
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch (error: any) {
alert(`Error al descargar: ${error.message}`);
} finally {
setDescargando(false);
}
};
if (loading) return <p>Cargando...</p>;
return (
<main
className="roboto"
style={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#063970",
}}
>
<div
className="flex gap-4 flex-wrap mb-6"
style={{
background: "#f3f4f6",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 4px 6px rgba(253, 253, 253, 0.1)",
width: "100%",
}}
>
<h2 className="text-xl font-semibold mb-4">Fuentes</h2>
<main>
{user?.tipo !== "LOAD" ? <div className="descarga-info p-4">
<p className="descargar-text ">
<span className="fw-bold">Descargar:</span> obtiene todos los usuarios que se han
ingresado de las distintas fuentes o cargas.
</p>
<p className="mt-4 fw-bold importante-text">Importante</p>
<ol className="puntos-text">
<li>
Seleccionar el botón de validar de todas las fuentes que estaban a la
hora de que descargaste la base de datos, eso ayuda a no volver a
descargar los mismos datos y permite informar al usuario el status de
los servicios que tiene activos.
</li>
<li>
Si das validar antes de haber descargado los datos{" "}
<span className="fw-bold">YA NO PODRÁS DESCARGAR</span> los datos de
esa fuente.
</li>
</ol>
</div> : null}
<div className="flex gap-4 flex-wrap mb-6">
<h2 className="text-xl font-semibold mt-5 mb-5">Descarga de Datos</h2>
{user?.tipo !== "LOAD" ? <h5 className="!text-[32px] text-gray-700 leading-tight mb-3">
Paso 1. Descarga el archivo.
</h5> : null}
<Button
onClick={handleDownload}
disabled={descargando}
className="mb-4, mt-3"
variant="azul"
>
{descargando ? "Descargando..." : "Descarga Base de Datos"}
</Button>
<h2 className="text-xl font-semibold mb-4, mt-4">Fuentes</h2>
{user?.tipo !== "LOAD" ? <><h5 className="text-xs !text-gray-700 leading-tight mb-4">
Paso 2. Alimenta la BD del servicio asigando a este perfil.
</h5><h5 className="text-xs !text-gray-700 leading-tight mb-4">
Paso 3. Selecciona este botón al haber concluido el paso anterior.
</h5></> : null}
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
gap: "16px",
flexWrap: "wrap",
}}
>
{/* {fuentes.map((f, idx) => (
{fuentes.map((f, idx) => (
<FuenteCard
key={idx}
nombre={f.nombre}
fecha={f.fecha}
activa={f.activa}
id_mov={f.id_mov}
onValidated={fetchMovimientos} // ← actualiza fuentes al validar
/>
))} */}
))}
</div>
<h2 className="text-xl font-semibold mb-4">Carga</h2>
<div>
<CargaArchivo />
</div>
{getUserFromToken()?.tipo === "LOAD" && (
<>
<h2
style={{ marginTop: "2rem" }}
className="text-xl font-semibold mt-4 mb-4"
>
Carga
</h2>
<div>
<CargaArchivo />
</div>
</>
)}
<h2 className="text-xl font-semibold mb-4">Descarga de Datos</h2>
<Button onClick={handleDownload} className="mb-3">
Descarga Base de Datos
</Button>
</div>
</main>
);
+58
View File
@@ -0,0 +1,58 @@
* {
padding: none;
margin: none;
}
.icon-circle {
position: relative;
width: 300px;
height: 300px;
margin: auto;
}
.central-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) !important;
font-size: 2rem;
background: white;
border-radius: 50%;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
z-index: 1;
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.orbiting-container {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
}
.floating-icon {
position: absolute;
width: 40px;
height: 40px;
background: white;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 0 8px rgba(0, 0, 0, 0.1);
/* Centrar el icono en el punto de origen */
margin-top: -20px;
margin-left: -20px;
}
.floating-icon i {
font-size: 1.25rem;
color: #d59f0f;
/* Dorado UNAM */
}
+106
View File
@@ -0,0 +1,106 @@
// IconCircle.tsx
"use client";
import React from "react";
import { motion } from "framer-motion";
import "./IconCircle.css";
import red from "@/assets/pngs/1.png";
import equipos from "@/assets/pngs/2.png";
import correo from "@/assets/pngs/3.png";
import desarrolla from "@/assets/pngs/4.png";
import cedetec from "@/assets/pngs/5.png";
import Image from "next/image";
const iconImages = [red, equipos, correo, desarrolla, cedetec];
const iconClasses = [
"bi bi-graph-up",
"bi bi-lightning-fill",
"bi bi-currency-exchange",
"bi bi-globe2",
"bi bi-bar-chart-line-fill",
"bi bi-arrow-left-right",
"bi bi-bank",
"bi bi-boxes",
];
interface IconCircleProps {
delay?: number;
}
const IconCircle: React.FC<IconCircleProps> = ({ delay = 0 }) => {
return (
<div className="icon-circle">
{/* Icono central fijo */}
<motion.div
className="central-icon"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 1, delay }}
>
<i className="bi bi-stars"></i>
</motion.div>
{/* Contenedor que orbita con los iconos flotantes */}
<motion.div
className="orbiting-container"
animate={{ rotate: 360 }}
transition={{
duration: 30,
repeat: Infinity,
ease: "linear",
}}
>
{iconImages.map((iconClass, i) => {
const angle = i * 70 * (Math.PI / 180); // Convertir a radianes
const radius = 130;
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
return (
<motion.div
key={i}
className="floating-icon"
style={{
left: x,
top: y,
}}
initial={{
scale: 0,
opacity: 0,
}}
animate={{
scale: 1,
opacity: 1,
}}
transition={{
delay: delay + 0.5 + i * 0.1,
duration: 0.5,
type: "spring",
}}
>
<motion.div
animate={{
rotate: 360,
}}
transition={{
duration: 20 + i * 2,
repeat: Infinity,
ease: "linear",
}}
>
<Image
src={iconClass}
alt={`Icon ${i + 1}`}
width={64}
height={64}
/>
</motion.div>
</motion.div>
);
})}
</motion.div>
</div>
);
};
export default IconCircle;
+211
View File
@@ -0,0 +1,211 @@
* {
padding: none;
margin: none;
}
/* Fondo general y tipografía */
body {
margin: 0;
font-family: "Segoe UI", sans-serif;
background: radial-gradient(circle at center, #f8f4ff, hsl(218, 45%, 37%));
/* background: radial-gradient(circle at center, #f8f4ff, #d5a00f65); */
color: #1a1a1a;
text-align: center;
}
/* Contenedor centrado y ancho limitado */
/* Contenedor centrado y ancho limitado */
.descarga-info {
background-color: #ffffff;
border-radius: 0.5rem;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
max-width: 700px;
margin: 2rem auto;
padding: 1.5rem;
}
/* Descargar texto inline con espacio */
.descargar-text {
font-size: 18px;
color: #000000;
}
/* Importante */
.importante-text {
font-size: 16px;
color: #000000;
}
/* Lista numerada justificada y con separación */
.puntos-text {
text-align: justify;
margin-left: 1.5rem;
margin-top: 1rem;
}
.puntos-text li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
/* Header */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 2rem;
background: transparent;
font-weight: bold;
}
header div:first-child {
font-size: 1.5rem;
color: #003d79;
}
.nav-links {
background-color: white;
padding: 10px;
border-radius: 50px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
/* sombra suave */
display: flex;
gap: 1rem;
/* espacio entre los <a> */
}
header a {
margin: 0 0.75rem;
text-decoration: none;
color: #333;
font-weight: 500;
}
header button {
margin-left: 0.5rem;
padding: 0.4rem 1rem;
border-radius: 20px;
border: none;
font-weight: 600;
cursor: pointer;
}
header button:first-of-type {
background-color: white;
border: 1px solid #ccc;
color: #555;
}
header button:last-of-type {
background-color: #0033ff;
color: white;
}
/* Texto principal */
strong {
display: block;
font-size: 2.5rem;
margin: 2rem 0 1rem;
}
p {
font-size: 1rem;
color: #555;
/* max-width: 600px; */
margin: 0 auto 1rem;
}
/* Botón principal */
div>button {
background-color: #003d79;
color: white;
padding: 0.7rem 2rem;
border: none;
border-radius: 25px;
font-size: 1rem;
cursor: pointer;
}
div>div p {
margin: 0;
font-size: 0.9rem;
color: #888;
}
/* Scroll hint */
body>p:last-of-type {
margin-top: 2rem;
font-size: 0.85rem;
color: #aaa;
letter-spacing: 1px;
}
.ocultar-scroll {
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE 10+ */
}
.ocultar-scroll::-webkit-scrollbar {
display: none;
/* Chrome, Safari */
}
.circle-background {
margin-top: 0;
padding-top: 0;
position: fixed;
top: none;
left: none;
width: 100vw;
height: 100vh;
z-index: -1;
background-color: #003d79c2;
/* para que esté detrás de todo */
pointer-events: none;
/* no interfiere con el contenido */
display: flex;
justify-content: center;
align-items: center;
height: 100%;
overflow: hidden;
}
.circle {
position: absolute;
background: radial-gradient(circle, rgb(255, 255, 255), transparent);
border-radius: 50%;
pointer-events: none;
backdrop-filter: blur(2px);
}
.invert {
filter: invert(1);
}
.boton {
border: #cfcece;
border-radius: 8px;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 100;
font-family: inherit;
background-color: #d4d2d2;
cursor: pointer;
transition: border-color 0.5s;
padding: 1em;
}
.imagen {
background-color: #cfcbcb;
max-height: 25px;
max-width: 25px;
margin-right: 35px;
}
+38
View File
@@ -0,0 +1,38 @@
// components/LandingBody.tsx
"use client";
import { motion } from "framer-motion";
import IconCircle from "./IconCircle";
import Button from "@/components/button";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { getUserFromToken } from "@/components/Hook";
import { useEffect, useState } from "react";
export default function LandingBody() {
const router = useRouter();
return (
<motion.main
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 1, duration: 1 }}
>
<IconCircle delay={2} />
<strong className="text-dorado">Servicios PC PUMA y CEDETEC</strong>
</motion.main>
);
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
import { motion } from "framer-motion";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { PageKey } from "../page";
interface HeaderItem {
label: string;
page?: PageKey;
onClick?: () => void;
}
interface HeaderProps {
items: HeaderItem[];
onClickItem: (page: PageKey) => void;
}
export default function Header({ items, onClickItem }: HeaderProps) {
const [currentPage, setCurrentPage] = useState<PageKey>("landing");
const handleItemClick = (item: HeaderItem) => {
if (item.onClick) {
item.onClick();
} else if (item.page) {
setCurrentPage(item.page);
onClickItem(item.page);
}
};
return (
<motion.header
initial={{ x: -100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
>
<div className="first-child">
<Link
href={"https://www.unam.mx/"}
target="_blank"
className="d-none d-sm-block"
>
<Image
src="/logo-unam.png"
alt="Logo de la UNAM"
width={280}
height={84}
className="invert"
/>
</Link>
</div>
<nav className="nav-links">
{items.map((item) => (
<a
key={item.label}
onClick={() => handleItemClick(item)}
className={
item.page && currentPage === item.page
? "text-dorado fw-bold"
: ""
}
style={{
cursor: "pointer",
...(item.page && currentPage === item.page
? { color: "#fbbf24", fontWeight: "bold" }
: {}),
}}
>
{item.label}
</a>
))}
</nav>
<div className="first-child">
<Link href={"https://acatlan.unam.mx/"} target="_blank">
<Image
src="/logo-fes.png"
alt="Logo de la FES Acatlán"
width={250}
height={60}
className="invert"
/>
</Link>
</div>
</motion.header>
);
}
+32 -13
View File
@@ -1,15 +1,8 @@
import type { Metadata } from "next";
import { Roboto } from "next/font/google";
import 'bootstrap-icons/font/bootstrap-icons.css';
import '@/styles/sass/bootstrap.scss';
'use client';
import "bootstrap-icons/font/bootstrap-icons.css";
import "@/styles/sass/bootstrap.scss";
import BootstrapClient from "@/components/bootstrap-client";
const roboto = Roboto({
weight: ["100", "300", "400", "500", "700", "900"],
subsets: ["latin"],
});
import { motion } from "framer-motion";
export default function RootLayout({
children,
@@ -18,9 +11,35 @@ export default function RootLayout({
}>) {
return (
<html lang="en">
<body className={`${roboto.className}`}>
<body className="overflow">
<BootstrapClient />
{children}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 1 }}
className="d-flex flex-column min-vh-100"
>
<div className="circle-background">
{[150, 300, 450, 600, 900].map((size, i) => (
<motion.div
key={i}
className="circle"
initial={{ scale: 0 }}
animate={{ scale: 3 }}
transition={{
delay: 1 + i * 0.3, // empieza después de la animación del texto
duration: 2,
ease: "easeOut",
}}
style={{
width: size,
height: size,
}}
/>
))}
</div>
{children}
</motion.div>
</body>
</html>
);
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function OAuthCallback() {
const router = useRouter();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
// Guardar token en localStorage
localStorage.setItem("token", token);
// Disparar evento para que useSession se actualice
window.dispatchEvent(new Event("tokenChanged"));
// Redirigir al Home
router.replace("/");
} else {
// No hay token: redirigir a login
router.replace("/");
}
}, [router]);
return <p>Procesando inicio de sesión...</p>;
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import "./landing/globals.css";
import Header from "./landing/new_header";
import Footer from "@/components/layout/footer";
import LandingBody from "./landing/landing_body";
import Dashboard from "./carga/page";
import Consulta from "./visualizacion/page";
import LoginPage from "@/containers/LoginPage";
import FormularioCargaIndividual from "./alta/alta";
import { useSession } from "@/hooks/use-session";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
export type PageKey =
| "landing"
| "login"
| "carga"
| "consulta"
| "carga individual";
export interface HeaderItem {
label: string;
page?: PageKey;
onClick?: () => void;
}
const Home = () => {
const [page, setPage] = useState<PageKey>("landing");
const [tokenProcessed, setTokenProcessed] = useState(false); // ✅ Esperar token
const { data: user, loading } = useSession();
const [isCarga, setIsCarga] = useState("Carga");
const [saludo, setSaludo] = useState("");
// 🔹 Capturar token antes de montar la UI
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
const error = params.get("error");
if (token) {
localStorage.setItem("token", token);
window.dispatchEvent(new Event("tokenChanged"));
// Limpiar token de URL
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState({}, document.title, cleanUrl);
}
if (error) {
// Aquí decides cómo manejarlo
alert("Error de inicio de sesión: NO AUTORIZADO");
// Si quieres limpiar la URL después
const cleanUrl = window.location.origin + window.location.pathname;
window.history.replaceState({}, document.title, cleanUrl);
}
setTokenProcessed(true); // ✅ Ya procesamos token
}, []);
// 🔹 Saludo
useEffect(() => {
const origen = user?.tipo;
if (origen === "LOAD") setSaludo("Bienvenido al módulo de carga de datos");
else if (origen === "RED") setSaludo("Bienvenido usuario de red");
else if (origen === "AT") setSaludo("Bienvenido usuario de AT");
else if (origen === "CORREO") setSaludo("Bienvenido usuario de correo");
else if (origen === "SOLICITA")setSaludo("Bienvenido usuario de préstamos PC Puma");
else if (origen === "EXTERNO") setSaludo("Bienvenido usuario externo");
}, [user]);
// 🔹 Label carga/descarga
useEffect(() => {
if (user?.tipo === "LOAD") setIsCarga("Carga");
else setIsCarga("Descarga");
}, [user]);
// 🔹 Redirigir automáticamente si ya hay sesión
useEffect(() => {
if (!loading && user && page === "login") setPage("landing");
}, [loading, user, page]);
const handleLogout = () => {
localStorage.removeItem("token");
window.dispatchEvent(new Event("tokenChanged"));
location.reload();
};
const items: HeaderItem[] = user
? [
{ label: "Inicio", page: "landing" },
{ label: "Consulta", page: "consulta" },
...(user.tipo !== "EXTERNO"
? [{ label: isCarga, page: "carga" as PageKey }]
: []
),
...(user.tipo === "LOAD"
? [{ label: "Carga individual", page: "carga individual" as PageKey }]
: []),
...(user.tipo === "EXTERNO"
? [{ label: "Carga individual", page: "carga individual" as PageKey }]
: []
),
{ label: "Cerrar sesión", onClick: handleLogout },
]
: [
{ label: "Consulta", page: "consulta" },
{ label: "Inicio", page: "landing" },
{ label: "Login", page: "login" },
];
// 🔹 Esperamos hasta que se procese el token
if (!tokenProcessed) {
return <p>Cargando sesión...</p>;
}
return (
<>
<Header items={items} onClickItem={setPage} />
{saludo && (
<motion.div
className="w-full flex justify-end pr-6 mb-5"
initial={{ x: -100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
>
<h2 className="text-dorado text-sm text-right">{saludo}</h2>
</motion.div>
)}
<div className="flex-grow-1 d-flex flex-column align-items-center justify-content-center text-center">
{loading && <p>Cargando sesión...</p>}
{!loading && (
<>
{page === "landing" && <LandingBody />}
{page === "login" && <LoginPage />}
{page === "consulta" && <Consulta />}
{page === "carga" &&(user?.tipo!="EXTERNO")&& <Dashboard />}
{page === "carga individual" && (user?.tipo === "LOAD" || user?.tipo === "EXTERNO") && (
<FormularioCargaIndividual />
)}
</>
)}
</div>
<Footer />
</>
);
};
export default Home;
+134
View File
@@ -0,0 +1,134 @@
"use client";
import SimpleInput from "@/components/input";
import "../landing/globals.css";
import Footer from "@/components/layout/footer";
import { motion } from "framer-motion";
import PasswordInput from "@/components/password";
import Button from "@/components/button";
import { useEffect, useState } from "react";
import axios from "axios";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const SetPasswordPage = () => {
const [email, setEmail] = useState(""); // email desde URL
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Captura email desde query param
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const emailParam = params.get("email");
if (emailParam) setEmail(emailParam);
}, []);
const handleSetPassword = async () => {
if (!password || !confirmPassword) {
setError("Por favor ingresa la contraseña y confírmala");
return;
}
if (password !== confirmPassword) {
setError("Las contraseñas no coinciden");
return;
}
setLoading(true);
setError("");
try {
const response = await axios.post(`${apiUrl}/set-password`, {
email,
password,
});
const token = response.data.access_token;
localStorage.setItem("token", token);
window.dispatchEvent(new Event("tokenChanged"));
// Redirigir al Home
window.location.href = "/";
} catch (err: any) {
console.error(err);
setError(
err.response?.data?.message || "Error al establecer la contraseña"
);
} finally {
setLoading(false);
}
};
return (
<>
<motion.div
className="w-full flex justify-end pr-6 mb-5"
initial={{ x: -100, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 1.5, ease: "easeOut" }}
>
</motion.div>
<div className="flex-grow-1 d-flex flex-column align-items-center justify-content-center text-center">
<motion.main
className="auth-page flex-fill flex-grow-1 d-flex flex-column justify-content-center align-items-center"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0, duration: 0.5 }}
>
<h1 className="text-dorado">Establece tu contraseña para iniciar sesión</h1>
<SimpleInput
label="Usuario"
className={{
container: "w-300px mb-3",
}}
value={email}
readOnly
/>
<PasswordInput
label="Nueva contraseña"
className={{
container: "w-300px mb-3",
input: "form-control",
button: "btn btn-outline-secondary",
}}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<PasswordInput
label="Confirmar contraseña"
className={{
container: "w-300px mb-3",
input: "form-control",
button: "btn btn-outline-secondary",
}}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
{error && <p className="text-danger text-center mb-3">{error}</p>}
<div className="d-flex flex-column gap-3 w-300px">
<Button
onClick={handleSetPassword}
disabled={loading}
variant="azul"
>
{loading ? "Guardando..." : "Establecer contraseña"}
</Button>
</div>
</motion.main>
</div>
<Footer />
</>
);
};
export default SetPasswordPage;
+84 -57
View File
@@ -3,13 +3,17 @@
import React, { useState } from "react";
import Input from "@/components/input";
import Button from "@/components/button";
import Link from "next/link";
export default function Dashboard() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
export default function Consulta() {
const [tipo, setTipo] = useState("");
const [value, setValue] = useState("");
const [resultados, setResultados] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [error, setError] = useState("");
const [usuario, setUsuario] = useState("");
const handleSubmit = async () => {
if (!value) {
@@ -18,101 +22,102 @@ export default function Dashboard() {
}
setLoading(true);
setError('');
setError("");
setResultados([]);
setUsuario("");
try {
const res = await fetch(`http://localhost:4000/usuarios/${value}`);
const res = await fetch(`${apiUrl}/usuarios/${value}`);
if (!res.ok) {
const errData = await res.json();
throw new Error(errData.message || "Error al consultar usuario");
throw new Error("No se encontró usuario");
}
const data = await res.json();
setResultados(data);
setUsuario(data.usuario);
setResultados(data.resultado || []);
} catch (err: any) {
const message = err.message || "Error desconocido";
const message = "No se encontró usuario";
setError(message);
console.error("Error al obtener datos:", err);
console.error(message);
} finally {
setLoading(false);
}
};
return (
<main
className="roboto"
style={{
minHeight: "100vh",
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "#063970",
flexDirection: "column",
padding: "2rem",
}}
>
<div
className="flex gap-4 flex-wrap mb-6"
style={{
background: "#f3f4f6",
padding: "2rem",
borderRadius: "8px",
boxShadow: "0 4px 6px rgba(253, 253, 253, 0.1)",
width: "100%",
maxWidth: "900px",
}}
>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
gap: "16px",
flexWrap: "wrap",
width: "100%",
}}
>
<h2 className="text-xl font-semibold mb-4">Visualización De Datos</h2>
<main>
<div>
<div>
<strong className="text-dorado">Servicios PC PUMA y CEDETEC</strong>
<h2 className="text-xl font-semibold mb-4">Servicios habilitados</h2>
<Input
label="No.Cuenta"
placeholder="Ingresa tu No.Cuenta"
label="Número de cuenta o trabajador"
placeholder="Ingresa número"
value={value}
className={{
container: "mb-3 text-start",
}}
onChange={(e) => setValue(e.target.value)}
/>
<Button onClick={handleSubmit}>
{loading ? "Consultando..." : "Enviar Formulario"}
</Button>
<button
onClick={handleSubmit}
style={{
backgroundColor: "#003d79",
color: "#fff",
padding: "0.5rem 1rem",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
{loading ? "Buscando..." : "Buscar"}
</button>
</div>
</div>
{usuario && (
<h2 className="text-xl font-semibold mb-4 mt-5">El status de los servicios para <span className="text-blue-700">{usuario} son</span></h2>
)}
{error && (
<p style={{ color: "red", marginTop: "0.5rem", textAlign: "center" }}>{error}</p>
<p style={{ color: "red", marginTop: "0.5rem", textAlign: "center" }}>
{error}
</p>
)}
{resultados.length > 0 && (
<table
style={{
marginTop: "2rem",
marginBottom: "3rem",
background: "#fff",
borderCollapse: "collapse",
width: "100%",
maxWidth: "900px",
borderRadius: "8px",
overflow: "hidden",
overflow: "auto",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<thead style={{ backgroundColor: "#2563eb", color: "#fff" }}>
<thead style={{ backgroundColor: "#003d79", color: "#fff" }}>
<tr>
{Object.keys(resultados[0]).map((key) => (
<th
key={key}
style={{ padding: "1rem", textAlign: "left", borderBottom: "1px solid #ccc" }}
style={{
padding: "1rem",
textAlign: key === "fecha_activacion" ? "center" : "left",
borderBottom: "1px solid #ccc",
}}
>
{key.toUpperCase()}
{key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()}
</th>
))}
</tr>
@@ -120,16 +125,38 @@ export default function Dashboard() {
<tbody>
{resultados.map((item, idx) => (
<tr key={idx}>
{Object.values(item).map((val, i) => (
<td key={i} style={{ padding: "0.75rem", borderBottom: "1px solid #eee" }}>
{String(val)}
</td>
))}
{Object.values(item).map((val, i) => {
const cellKey = Object.keys(item)[i];
return (
<td
key={i}
style={{
padding: "0.75rem",
borderBottom: "1px solid #eee",
textAlign: cellKey === "fecha_activacion" ? "center" : "left",
}}
>
{cellKey === "fecha_activacion" && !val ? "" : String(val)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
)}
<div>
<Link
href="https://www.acatlan.unam.mx/pcpuma/"
target="_blank"
className="btn btn-azul btn-lg rounded-pill mt-5 mb-5"
>
Conoce PCPUMA
</Link>
<div>
<i></i>
</div>
</div>
</main>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+21
View File
@@ -0,0 +1,21 @@
import { jwtDecode } from "jwt-decode";
interface JwtPayload {
sub: string,
email: string,
tipo: string
}
export function getUserFromToken(): JwtPayload | null {
const token = localStorage.getItem("token");
if (!token) return null;
try {
const payload: JwtPayload = jwtDecode(token);
return payload;
} catch (error) {
console.error("Token inválido:", error);
return null;
}
}
+102 -22
View File
@@ -1,70 +1,150 @@
"use client";
import React, { useState } from "react";
import Button from "./button";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
const CargaArchivo: React.FC = () => {
const [archivo, setArchivo] = useState<File | null>(null);
const [errores, setErrores] = useState<string[] | null>(null);
const [loading, setLoading] = useState(false); // ← estado de carga
const [usarFuncionEspecial, setUsarFuncionEspecial] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) setArchivo(file);
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const file = e.dataTransfer.files?.[0];
if (file) setArchivo(file);
};
const handleUpload = async () => {
if (!archivo) return;
const token = localStorage.getItem("token"); // 👈 Token JWT guardado en login
const token = localStorage.getItem("token");
if (!token) {
alert("No hay token disponible. Inicia sesión.");
return;
}
const formData = new FormData();
formData.append("file", archivo); // 👈 ¡Debe coincidir con FileInterceptor('file')
formData.append("file", archivo);
setLoading(true); // ← empieza a subir
try {
const res = await fetch("http://localhost:4000/excel/load", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`, // 👈 Incluye el token en el header
},
body: formData,
});
const url = usarFuncionEspecial
? `${apiUrl}/excel/load?function=2`
: `${apiUrl}/excel/load`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
body: formData,
});
const data = await res.json();
if (res.ok) {
alert("Archivo subido correctamente");
alert(
`Archivo subido correctamente (ID Movimiento: ${data.id_movimiento})`
);
setArchivo(null);
if (data.errors && data.errors.length > 0) {
setErrores(data.errors);
}
} else {
console.error(data);
alert(`Error al subir archivo: ${JSON.stringify(data)}`);
}
} catch (err) {
alert(`Error en la solicitud: ${err}`);
} finally {
setLoading(false); // ← termina de subir
}
};
return (
<div className="w-full">
<div className="w-full max-w-xl mx-auto">
<input
id="fileInput"
type="file"
accept=".xls,.xlsx"
onChange={handleChange}
className="mb-4 block text-sm"
style={{ display: "none" }}
/>
<button
onClick={handleUpload}
disabled={!archivo}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
<div
className="rounded p-5 text-center bg-white shadow-lg transition hover:shadow-xl"
style={{
backgroundColor: "#2563eb",
color: "#fff",
border: "none",
cursor: "pointer",
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={() => document.getElementById("fileInput")?.click()}
>
Subir archivo
</button>
<p className="text-gray-600 mb-2">
Arrastra y suelta un archivo aquí o{" "}
<span className="text-blue-600 font-semibold">
haz clic para seleccionarlo
</span>.
</p>
<i className="bi bi-upload fs-1 text-primary"></i>
{archivo && (
<p className="my-0 text-green-600 font-semibold">
Archivo seleccionado: {archivo.name}
</p>
)}
</div>
<div className="mt-4 flex items-center justify-center gap-2">
<input
type="checkbox"
id="funcionEspecial"
checked={usarFuncionEspecial}
onChange={(e) => setUsarFuncionEspecial(e.target.checked)}
className="h-4 w-4 cursor-pointer"
/>
<label htmlFor="funcionEspecial" className="text-sm text-gray-700 cursor-pointer">
Usar función especial de carga
</label>
</div>
<div className="mt-4 text-center">
<Button onClick={handleUpload} disabled={!archivo || loading} variant="azul">
{loading ? "Subiendo..." : "Subir archivo"}
</Button>
</div>
{/* Modal de errores */}
{errores && (
<div className="mt-6 p-4 bg-red-100 border border-red-300 rounded">
<div className="flex justify-between items-center mb-2">
<h2 className="text-lg font-semibold text-red-800">Errores detectados</h2>
<Button variant="danger" onClick={() => setErrores(null)} className="text-sm">
<i className="fas fa-times"></i>
</Button>
</div>
<ul className="list-disc list-inside text-sm max-h-60 overflow-y-auto text-red-700">
{errores.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
</div>
);
};
+85
View File
@@ -0,0 +1,85 @@
"use client";
import React, { useState } from "react";
import Button from "./button";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
type FuenteCardProps = {
nombre: string;
fecha: string;
activa: boolean;
id_mov: number;
onValidated?: () => void;
};
const FuenteCard: React.FC<FuenteCardProps> = ({
nombre,
fecha,
activa,
id_mov,
onValidated,
}) => {
const [loading, setLoading] = useState(false); // ← nuevo estado
const handleActivar = async () => {
const token = localStorage.getItem("token");
if (!token) {
alert("Token no encontrado.");
return;
}
try {
setLoading(true); // ← activa estado de carga
const res = await fetch(
`${apiUrl}/excel/activar-servicios/${id_mov}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = await res.json();
if (res.ok) {
alert("Usuarios validados correctamente");
onValidated?.();
} else {
alert(`Error: ${data.message || "No se pudo validar"}`);
}
} catch (error: any) {
alert(`Error en la solicitud: ${error.message}`);
} finally {
setLoading(false); // ← desactiva estado de carga
}
};
return (
<div className="border rounded shadow-sm p-4 bg-white w-44">
<div className="flex items-center justify-between mb-2">
<span className="font-semibold text-sm">{nombre}</span>
<span
className={`w-3 h-3 rounded-full ${activa ? "bg-green-500" : "bg-red-500"}`}
/>
</div>
<p className="text-xs text-gray-700 leading-tight">
Fecha de carga:
<br />
{fecha}
</p>
{activa && (
<Button
onClick={handleActivar}
className="mb-3 px-3 py-1 mt-3"
variant="success"
disabled={loading} // ← deshabilita mientras valida
>
{loading ? "Validando..." : "Validar"}
</Button>
)}
</div>
);
};
export default FuenteCard;
+16 -6
View File
@@ -1,20 +1,30 @@
import React from "react";
import { motion } from "framer-motion";
export default function Footer() {
const date = new Date();
const year = date.getFullYear();
return (
<footer className="bg-azul p-3 text-center text-white small">
<p className="m-0">
Hecho en México. Todos los derechos reservados {year}.
<motion.footer
// Empieza 50px abajo y totalmente transparente
initial={{ y: 50, opacity: 0 }}
// Termina en su posición natural y opacidad 1
animate={{ y: 0, opacity: 1 }}
// Retraso para que aparezca justo después del main
transition={{ delay: 2, duration: 1, ease: "easeOut" }}
className="p-3 text-center small text-muted"
>
<p className="mb-1">
Hecho en México. Todos los derechos reservados 2025.
</p>
<p className="m-0">
Esta página puede ser reproducida con fines no lucrativos, siempre y
<p className="mb-0">
Esta página no puede ser reproducida con fines lucrativos, siempre y
cuando no se mutile, se cite la fuente completa y su dirección
electrónica. De otra forma, requiere permiso previo por escrito de la
institución.
</p>
</footer>
</motion.footer>
);
}
+11 -12
View File
@@ -1,10 +1,9 @@
'use client';
import React, { useState } from 'react';
import React, { useState } from "react";
export interface PasswordInputProps
extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
'type' | 'className'
"type" | "className"
> {
/**
* Texto de la etiqueta asociada al input (opcional).
@@ -33,18 +32,18 @@ export default function PasswordInput(props: PasswordInputProps) {
const [visible, setVisible] = useState(false);
// Determina el tipo de input según el estado de visibilidad
const inputType = visible ? 'text' : 'password';
const inputType = visible ? "text" : "password";
const inputId =
id ||
name ||
(label ? `password-${label.replace(/\s+/g, '-')}` : undefined);
(label ? `password-${label.toLowerCase().replace(/\s+/g, "-")}` : undefined);
const toggleVisibility = () => setVisible((v) => !v);
return (
<div className={className?.container || 'mb-3'}>
<div className={className?.container || "mb-3"}>
{label && inputId && (
<label htmlFor={inputId} className={className?.label || 'form-label'}>
<label htmlFor={inputId} className={className?.label || "form-label"}>
{label}
</label>
)}
@@ -53,17 +52,17 @@ export default function PasswordInput(props: PasswordInputProps) {
id={inputId}
name={name}
type={inputType}
className={className?.input || 'form-control'}
className={className?.input || "form-control"}
{...rest}
/>
<button
type="button"
className={className?.button || 'btn btn-light border'}
className={className?.button || "btn btn-light border"}
onClick={toggleVisibility}
aria-label={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
title={visible ? 'Ocultar contraseña' : 'Mostrar contraseña'}
aria-label={visible ? "Ocultar contraseña" : "Mostrar contraseña"}
title={visible ? "Ocultar contraseña" : "Mostrar contraseña"}
>
<i className={`bi ${visible ? 'bi-eye-slash' : 'bi-eye'}`} />
<i className={`bi ${visible ? "bi-eye-slash" : "bi-eye"}`} />
</button>
</div>
</div>
+135
View File
@@ -0,0 +1,135 @@
"use client";
import Image from 'next/image';
import google from "@/img/google.png"
import Button from "@/components/button";
import SimpleInput from "@/components/input";
import PasswordInput from "@/components/password";
import React, { useState } from "react";
import axios from "axios";
import { useRouter } from "next/navigation";
import { motion } from "framer-motion";
import { image } from "framer-motion/client";
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
export default function Page() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleLogin = async () => {
setLoading(true);
try {
const response = await axios.post(`${apiUrl}/login`, {
email: email,
password: password,
});
const token = response.data.access_token;
localStorage.setItem("token", token);
// Disparar evento personalizado para notificar cambio de token
window.dispatchEvent(new Event("tokenChanged"));
// Limpiar formulario
setEmail("");
setPassword("");
setError("");
window.location.reload();
} catch (err: any) {
const message =
"credenciales incorrectas, por favor verifica tu usuario y contraseña";
setError(message);
} finally {
setLoading(false);
}
};
// 🔑 Captura Enter en cualquier input
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleLogin();
}
};
// 👉 Iniciar sesión con Google
const handleGoogleLogin = () => {
window.location.href = `${apiUrl}/google`;
};
return (
<>
<motion.main
className="auth-page flex-fill flex-grow-1 d-flex flex-column justify-content-center align-items-center"
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0, duration: 0.5 }}
>
<h2 className="text-dorado">Bienvenido al sistema de</h2>
<h1 className="text-dorado">Servicios PC PUMA y CEDETEC</h1>
<SimpleInput
label="Usuario"
className={{
container: "w-300px mb-3",
}}
value={email}
onChange={(e) => setEmail(e.target.value)}
onKeyDown={handleKeyDown}
/>
<PasswordInput
label="Contraseña"
className={{
container: "w-300px mb-3",
input: "form-control",
button: "btn btn-outline-secondary",
}}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown}
/>
{error && <p className="text-danger text-center mb-3">{error}</p>}
<div className="d-flex flex-column gap-3 w-300px">
{/* Botón normal */}
<Button onClick={handleLogin} disabled={loading} variant="azul">
{loading ? "Iniciando sesión..." : "Iniciar Sesión"}
</Button>
<div> or </div>
{/* Botón Google */}
<div className="boton">
<Button
onClick={handleGoogleLogin}
variant="rojo"
>
{" "}
<Image src={google} alt="" className="imagen" />
<span>Continue with Google</span>
</Button>
</div>
{/* <Button
onClick={handleGoogleLogin}
variant="rojo"
className="d-flex align-items-center justify-content-center gap-2"
>
<img
src="https://www.svgrepo.com/show/355037/google.svg"
alt="Google"
className="w-5 h-5"
/>
Iniciar sesión con Google
</Button> */}
</div>
</motion.main>
</>
);
}
+2 -3
View File
@@ -10,11 +10,10 @@ export function useAuthRedirect() {
useEffect(() => {
const token = localStorage.getItem("token");
// Simula verificación (puedes hacer una llamada real)
if (!token) {
router.replace("/");
} else {
// Aquí podrías verificar validez con una API, jwt-decode, etc.
// Aquí podrías verificar validez con una API o jwt-decode
setLoading(false); // Ya está autenticado, muestra contenido
}
}, [router]);
+52
View File
@@ -0,0 +1,52 @@
// hooks/use-session.ts
import { useEffect, useState } from "react";
import { jwtDecode } from "jwt-decode";
interface JwtPayload {
sub: string;
email: string;
tipo: string;
exp: number; // <-- Asegúrate de incluir esto
}
export const useSession = () => {
const [data, setData] = useState<JwtPayload | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
setLoading(false);
return;
}
try {
const decoded = jwtDecode<JwtPayload>(token);
const now = Date.now() / 1000;
if (decoded.exp < now) {
// Token expirado
localStorage.removeItem("token");
setData(null);
} else {
setData(decoded);
// Programar logout automático al momento exacto de expiración
const timeout = setTimeout(() => {
localStorage.removeItem("token");
setData(null);
window.location.reload();
}, (decoded.exp - now) * 1000);
return () => clearTimeout(timeout);
}
} catch (e) {
console.error("Token inválido", e);
localStorage.removeItem("token");
} finally {
setLoading(false);
}
}, []);
return { loading, data };
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+19 -4
View File
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -19,9 +23,20 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/app/landing",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}