Add new components and update styles
- Added new logo images: logo-negro-fes.png and logo-unam.png. - Removed unused SVG files: next.svg, vercel.svg, and window.svg. - Created layout component for authentication pages. - Implemented login page with input fields for username and password. - Updated carga page to include CargaArchivo component. - Removed old login page implementation. - Introduced CheckboxOptionGroup and RadioOptionGroup components for form handling. - Deleted FuenteCard component and added new layout components: Header and Footer. - Created Navbar and Sidebar components for navigation. - Added Pagination component for table navigation. - Developed Table component with sorting and pagination features. - Implemented Tabs component for tabbed navigation. - Updated utility styles in SASS for responsive widths. - Enhanced Bootstrap styles for form controls.
@@ -1 +0,0 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 391 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
Before Width: | Height: | Size: 385 B |
@@ -0,0 +1,13 @@
|
||||
import Footer from "@/components/layout/footer";
|
||||
import Header from "@/components/layout/header";
|
||||
import React from "react";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<Header />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Button from "@/components/button";
|
||||
import SimpleInput from "@/components/input";
|
||||
import PasswordInput from "@/components/password";
|
||||
import React from "react";
|
||||
|
||||
export default function Page() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import React 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";
|
||||
|
||||
const fuentes = [
|
||||
{ nombre: "Fuente 1", fecha: "Martes 3 de mayo 2025", activa: true },
|
||||
@@ -87,14 +86,14 @@ export default function Dashboard() {
|
||||
gap: "16px",
|
||||
}}
|
||||
>
|
||||
{fuentes.map((f, idx) => (
|
||||
{/* {fuentes.map((f, idx) => (
|
||||
<FuenteCard
|
||||
key={idx}
|
||||
nombre={f.nombre}
|
||||
fecha={f.fecha}
|
||||
activa={f.activa}
|
||||
/>
|
||||
))}
|
||||
))} */}
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mb-4">Carga</h2>
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"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('http://localhost:4000/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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from "react";
|
||||
|
||||
export interface CheckboxOption<T = unknown> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface CheckboxOptionGroupProps<T = unknown> {
|
||||
name: string;
|
||||
options: CheckboxOption<T>[];
|
||||
selectedValues: T[];
|
||||
onChange: (value: T, checked: boolean) => void;
|
||||
maxSelected?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CheckboxOptionGroup<T = unknown>({
|
||||
name,
|
||||
options,
|
||||
selectedValues,
|
||||
onChange,
|
||||
maxSelected,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: CheckboxOptionGroupProps<T>) {
|
||||
const isSelected = (value: T) => selectedValues.includes(value);
|
||||
|
||||
return (
|
||||
<div className={`d-flex flex-column gap-2 mb-3 ${className}`}>
|
||||
{options.map((option, index) => {
|
||||
const checked = isSelected(option.value);
|
||||
const limitReached =
|
||||
maxSelected !== undefined &&
|
||||
selectedValues.length >= maxSelected &&
|
||||
!checked;
|
||||
|
||||
return (
|
||||
<label
|
||||
key={index}
|
||||
className="option list-group-item-action d-flex align-items-center"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
className="form-check-input me-2"
|
||||
disabled={disabled || option.disabled || limitReached}
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(option.value, e.target.checked)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
type FuenteCardProps = {
|
||||
nombre: string;
|
||||
fecha: string;
|
||||
activa: boolean;
|
||||
};
|
||||
|
||||
const FuenteCard: React.FC<FuenteCardProps> = ({ nombre, fecha, activa }) => {
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FuenteCard;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from "react";
|
||||
|
||||
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}.
|
||||
</p>
|
||||
<p className="m-0">
|
||||
Esta página puede ser reproducida con fines no 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Header({ small = false }: { small?: boolean }) {
|
||||
return (
|
||||
<header className="bg-azul p-3">
|
||||
<div className="container flex-center justify-content-sm-between">
|
||||
<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={small ? 200 : 280}
|
||||
height={small ? 60 : 84}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={"https://acatlan.unam.mx/"} target="_blank">
|
||||
<Image
|
||||
src="/logo-fes.png"
|
||||
alt="Logo de la fes Acatlàn"
|
||||
width={small ? 200 : 250}
|
||||
height={small ? 48 : 60}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const Navbar: React.FC = () => {
|
||||
return (
|
||||
<nav className="navbar navbar-expand-lg navbar-light bg-transparent fixed-top">
|
||||
<div className="container">
|
||||
<button
|
||||
className="navbar-toggler border-0 ms-auto"
|
||||
type="button"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target="#offcanvasNavbar"
|
||||
aria-controls="offcanvasNavbar"
|
||||
>
|
||||
<span className="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div
|
||||
className="offcanvas offcanvas-end"
|
||||
tabIndex={-1}
|
||||
id="offcanvasNavbar"
|
||||
aria-labelledby="offcanvasNavbarLabel"
|
||||
>
|
||||
<div className="offcanvas-header">
|
||||
<h5 className="offcanvas-title" id="offcanvasNavbarLabel">
|
||||
Menu
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
data-bs-dismiss="offcanvas"
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div className="offcanvas-body justify-content-center">
|
||||
<ul className="navbar-nav gap-lg-5">
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/">
|
||||
Inicio
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/table">
|
||||
Table
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/input">
|
||||
Input
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/pagination">
|
||||
Pagination
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/options">
|
||||
Options
|
||||
</Link>
|
||||
</li>
|
||||
<li className="nav-item" data-bs-dismiss="offcanvas">
|
||||
<Link className="nav-link" href="/styles">
|
||||
Styles
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
interface PaginationProps {
|
||||
totalPages: number;
|
||||
currentPage: number;
|
||||
onPageChange: (page: number) => void;
|
||||
delta?: number;
|
||||
}
|
||||
|
||||
export default function Pagination({
|
||||
currentPage,
|
||||
onPageChange,
|
||||
totalPages,
|
||||
delta = 1,
|
||||
}: PaginationProps) {
|
||||
const handlePageChange = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages && page !== currentPage) {
|
||||
onPageChange(page);
|
||||
}
|
||||
};
|
||||
|
||||
const visiblePages = useMemo(() => {
|
||||
const pages: (number | -1)[] = [];
|
||||
|
||||
if (totalPages <= 5 + delta * 2) {
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const left = Math.max(2, currentPage - delta);
|
||||
const right = Math.min(totalPages - 1, currentPage + delta);
|
||||
|
||||
pages.push(1);
|
||||
if (left > 2) pages.push(-1);
|
||||
|
||||
for (let i = left; i <= right; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (right < totalPages - 1) pages.push(-1);
|
||||
pages.push(totalPages);
|
||||
|
||||
return pages;
|
||||
}, [currentPage, totalPages, delta]);
|
||||
|
||||
return (
|
||||
<nav aria-label="Page navigation">
|
||||
<ul className="pagination gap-1">
|
||||
{/* Botón Anterior */}
|
||||
<li className={`page-item ${currentPage === 1 ? "disabled" : ""}`}>
|
||||
<button
|
||||
className="page-link shadow-sm rounded border-0 bg-white"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
aria-label="Previous"
|
||||
>
|
||||
«
|
||||
</button>
|
||||
</li>
|
||||
|
||||
{/* Números de Página */}
|
||||
{visiblePages.map((page, index) =>
|
||||
page === -1 ? (
|
||||
<li key={`ellipsis-${index}`} className="page-item disabled">
|
||||
<span className="page-link shadow-sm rounded border-0 bg-white">
|
||||
...
|
||||
</span>
|
||||
</li>
|
||||
) : (
|
||||
<li
|
||||
key={page}
|
||||
className={`page-item ${currentPage === page ? "active" : ""}`}
|
||||
>
|
||||
<button
|
||||
className={`page-link shadow-sm rounded border-0 ${
|
||||
currentPage === page
|
||||
? ""
|
||||
: "bg-white text-primary"
|
||||
}`}
|
||||
onClick={() => handlePageChange(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Botón Siguiente */}
|
||||
<li
|
||||
className={`page-item ${
|
||||
currentPage === totalPages ? "disabled" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
className="page-link shadow-sm rounded border-0 bg-white"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
aria-label="Next"
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface PasswordInputProps
|
||||
@@ -10,7 +11,7 @@ export interface PasswordInputProps
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Clases CSS personalizables para distinto s elementos.
|
||||
* Clases CSS personalizables para distintos elementos.
|
||||
*/
|
||||
className?: {
|
||||
/** Clases para el contenedor del componente */
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface PopoverProps {
|
||||
icon?: string | React.ReactNode; // Puede ser un string o un componente React
|
||||
message: string;
|
||||
className?: string;
|
||||
placement?: 'auto' | 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
export default function Popover({
|
||||
icon,
|
||||
message,
|
||||
className,
|
||||
placement = 'top',
|
||||
}: PopoverProps) {
|
||||
const popoverRef = useRef<HTMLButtonElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let instance: any;
|
||||
|
||||
const loadPopover = async () => {
|
||||
const { Popover } = await import('bootstrap');
|
||||
|
||||
if (popoverRef.current) {
|
||||
instance = new Popover(popoverRef.current, {
|
||||
trigger: 'hover focus',
|
||||
placement,
|
||||
content: message,
|
||||
container: 'body',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadPopover();
|
||||
|
||||
return () => {
|
||||
if (instance) {
|
||||
instance.dispose();
|
||||
}
|
||||
};
|
||||
}, [placement, message]);
|
||||
|
||||
return (
|
||||
<button
|
||||
tabIndex={0}
|
||||
ref={popoverRef}
|
||||
type='button'
|
||||
className={`btn ${className ?? 'btn-light'} rounded`}
|
||||
data-bs-toggle='popover'
|
||||
aria-label='Popover Button'
|
||||
aria-describedby={message}
|
||||
>
|
||||
{typeof icon === 'string' ? <i className={`bi bi-${icon}`}></i> : icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
interface EstatusProps {
|
||||
estados: string[];
|
||||
estadoActivo: number;
|
||||
}
|
||||
|
||||
export default function ProgressBar({ estados, estadoActivo }: EstatusProps) {
|
||||
return (
|
||||
<>
|
||||
<div className='track my-5 d-none d-md-flex'>
|
||||
{estados.map((label, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`step ${index <= estadoActivo ? 'active' : ''}`}
|
||||
>
|
||||
<span className='icon'>{index + 1}</span>
|
||||
<span className='text'>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='track my-5 d-flex d-md-none'>
|
||||
<div className='step active'>
|
||||
<span className='icon'>{estadoActivo + 1}</span>
|
||||
<span className='text'>{estados[estadoActivo]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EstatusEskeleton() {
|
||||
return (
|
||||
<div className='box mb-3 '>
|
||||
<span className='text-muted fw-bold'>Estatus:</span>
|
||||
<div className='placeholder-glow'>
|
||||
<span className='placeholder rounded col-12'></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EstatusEskeleton };
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
export interface RadioOption<T = unknown> {
|
||||
label: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface RadioOptionGroupProps<T = unknown> {
|
||||
name: string;
|
||||
options: RadioOption<T>[];
|
||||
selectedValue?: T;
|
||||
onChange: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function RadioOptionGroup<T>({
|
||||
name,
|
||||
options,
|
||||
selectedValue,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = "",
|
||||
}: RadioOptionGroupProps<T>) {
|
||||
return (
|
||||
<div className={`d-flex flex-column gap-2 mb-3 ${className}`}>
|
||||
{options.map((option, index) => (
|
||||
<label
|
||||
key={index}
|
||||
className="option list-group-item-action d-flex align-items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name={name}
|
||||
className="form-check-input me-2"
|
||||
disabled={disabled || option.disabled}
|
||||
checked={option.value === selectedValue}
|
||||
onChange={() => onChange(option.value)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export interface SidebarLink {
|
||||
icon?: string;
|
||||
text?: string;
|
||||
href?: string;
|
||||
show?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
render?: () => React.ReactNode;
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
id: string;
|
||||
userName?: string;
|
||||
userRole?: string;
|
||||
logoSrc?: string;
|
||||
appTitle?: string;
|
||||
links: SidebarLink[];
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
userName = "Usuario",
|
||||
userRole = "Administrador",
|
||||
logoSrc = "/unam.png",
|
||||
appTitle = "Sistema de -------",
|
||||
links,
|
||||
id,
|
||||
}: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const open = localStorage.getItem(id) === "true";
|
||||
setIsOpen(open);
|
||||
}, [id]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
localStorage.setItem(id, `${!isOpen}`);
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`sidebar ${isOpen ? "open" : "close"}`}>
|
||||
{/* Header for mobile */}
|
||||
<div className="d-flex justify-content-between align-items-center p-2 d-lg-none">
|
||||
<h1 className="h3 m-0">{appTitle}</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 p-0 border-0 bg-transparent"
|
||||
data-bs-toggle="offcanvas"
|
||||
data-bs-target={`#${id}-offcanvas`}
|
||||
aria-controls={`${id}-offcanvas`}
|
||||
>
|
||||
<i className="bi bi-list fs-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="offcanvas-lg offcanvas-end"
|
||||
tabIndex={-1}
|
||||
id={`${id}-offcanvas`}
|
||||
aria-labelledby={`${id}-offcanvas-label`}
|
||||
>
|
||||
<div className="offcanvas-header">
|
||||
<h5 className="offcanvas-title" id={`${id}-offcanvas-label`}>
|
||||
{appTitle}
|
||||
</h5>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
data-bs-dismiss="offcanvas"
|
||||
data-bs-target={`#${id}-offcanvas`}
|
||||
aria-label="Close"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="offcanvas-body position-relative">
|
||||
{/* Toggle sidebar button */}
|
||||
<div
|
||||
className="d-none d-lg-flex position-absolute"
|
||||
style={{ top: "0", right: "-2rem" }}
|
||||
>
|
||||
<button
|
||||
className="bg-white border-0 shadow-sm px-2 py-1"
|
||||
type="button"
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<i
|
||||
className={`bi bi-layout-sidebar-${
|
||||
isOpen ? "inset" : "inset-reverse"
|
||||
} text-muted`}
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{/* User Info */}
|
||||
<div className="d-flex align-items-center gap-3 my-2">
|
||||
<Image src={logoSrc} width={40} height={40} alt="Logo" />
|
||||
<div className="nav-link">
|
||||
<span>
|
||||
<b>{userName}</b>
|
||||
<br />
|
||||
<p className="text-muted m-0">{userRole}</p>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<ul className="nav flex-column gap-2">
|
||||
{links
|
||||
.filter((link) => link.show !== false)
|
||||
.map((link, index) => {
|
||||
if (link.render) {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{link.render()}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const isActive =
|
||||
!!link.href &&
|
||||
(pathname === link.href ||
|
||||
(pathname?.startsWith(link.href) && link.href !== "/u"));
|
||||
|
||||
const baseClass = `nav-item ${isActive ? "active" : ""} ${
|
||||
link.className || ""
|
||||
}`;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={baseClass}
|
||||
data-tooltip={link.text}
|
||||
data-bs-dismiss="offcanvas"
|
||||
data-bs-target="#offcanvasResponsive"
|
||||
aria-label="Close"
|
||||
>
|
||||
{link.href ? (
|
||||
<Link
|
||||
href={link.href}
|
||||
className="nav-link"
|
||||
onClick={link.onClick}
|
||||
>
|
||||
{link.icon && <i className={`bi ${link.icon}`}></i>}
|
||||
<span>{link.text}</span>
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
className="nav-link w-100 text-start"
|
||||
onClick={link.onClick}
|
||||
>
|
||||
{link.icon && <i className={`bi ${link.icon}`}></i>}
|
||||
<span>{link.text}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import React, { useState, useMemo } from "react";
|
||||
import Pagination from "./pagination";
|
||||
|
||||
export interface Header<T> {
|
||||
key: keyof T;
|
||||
label: string;
|
||||
render?: (value: T[keyof T], row: T) => React.ReactNode;
|
||||
width?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TableProps<T> {
|
||||
headers: Header<T>[];
|
||||
data: T[];
|
||||
rowsPerPage?: number;
|
||||
onRowClick?: (rowData: T) => void;
|
||||
rowKey?: (row: T) => string | number;
|
||||
className?: {
|
||||
table?: string;
|
||||
row?: string;
|
||||
cell?: string;
|
||||
header?: string;
|
||||
};
|
||||
controlledPage?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
export default function Table<T>({
|
||||
headers,
|
||||
data,
|
||||
rowsPerPage = 10,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
className,
|
||||
controlledPage,
|
||||
onPageChange,
|
||||
}: TableProps<T>) {
|
||||
const [sortConfig, setSortConfig] = useState<{
|
||||
key: keyof T;
|
||||
direction: "asc" | "desc";
|
||||
} | null>(null);
|
||||
|
||||
const [internalPage, setInternalPage] = useState(1);
|
||||
const currentPage = controlledPage ?? internalPage;
|
||||
const setPage = onPageChange ?? setInternalPage;
|
||||
|
||||
const handleSort = (key: keyof T) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const sortedData = useMemo(() => {
|
||||
if (!sortConfig) return data;
|
||||
return [...data].sort((a, b) => {
|
||||
if (a[sortConfig.key]! < b[sortConfig.key]!)
|
||||
return sortConfig.direction === "asc" ? -1 : 1;
|
||||
if (a[sortConfig.key]! > b[sortConfig.key]!)
|
||||
return sortConfig.direction === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [data, sortConfig]);
|
||||
|
||||
const totalPages = Math.ceil(sortedData.length / rowsPerPage);
|
||||
const paginatedData = sortedData.slice(
|
||||
(currentPage - 1) * rowsPerPage,
|
||||
currentPage * rowsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="table-container">
|
||||
<table className={`e-table ${className?.table || ""}`}>
|
||||
<colgroup>
|
||||
{headers.map((header, index) => (
|
||||
<col key={index} style={{ width: header.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th
|
||||
key={index}
|
||||
onClick={() => handleSort(header.key)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-sort={
|
||||
sortConfig?.key === header.key
|
||||
? sortConfig.direction === "asc"
|
||||
? "ascending"
|
||||
: "descending"
|
||||
: "none"
|
||||
}
|
||||
style={{ width: header.width }}
|
||||
className={`sortable ${className?.header || ""} ${
|
||||
header.className || ""
|
||||
} ${
|
||||
sortConfig?.key === header.key
|
||||
? `sorted-${sortConfig.direction}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{header.label}
|
||||
{sortConfig?.key === header.key && (
|
||||
<i
|
||||
className={`ms-1 bi bi-caret-${
|
||||
sortConfig.direction === "asc" ? "up" : "down"
|
||||
}`}
|
||||
></i>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paginatedData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={headers.length} className="text-center text-muted">
|
||||
No hay datos disponibles
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginatedData.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowKey ? rowKey(row) : rowIndex}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
className={`${onRowClick ? "clickable-row" : ""} ${
|
||||
className?.row || ""
|
||||
}`}
|
||||
>
|
||||
{headers.map((header, cellIndex) => (
|
||||
<td
|
||||
key={cellIndex}
|
||||
className={`${className?.cell || ""} ${
|
||||
header.className || ""
|
||||
}`}
|
||||
style={{ width: header.width }}
|
||||
data-titulo={header.label}
|
||||
>
|
||||
{header.render
|
||||
? header.render(row[header.key], row)
|
||||
: (row[header.key] as React.ReactNode)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
onPageChange={setPage}
|
||||
totalPages={totalPages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TablaSkeletonProps {
|
||||
rows?: number;
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 5, columns = 5 }: TablaSkeletonProps) {
|
||||
return (
|
||||
<table className="e-table placeholder-glow">
|
||||
<thead>
|
||||
<tr>
|
||||
{Array.from({ length: columns }).map((_, index) => (
|
||||
<th key={index}>
|
||||
<span className="placeholder rounded col-12"></span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Array.from({ length: rows }).map((_, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{Array.from({ length: columns }).map((_, colIndex) => (
|
||||
<td key={colIndex}>
|
||||
<span className="placeholder rounded col-12"></span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useState, JSX } from 'react';
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
title: string | JSX.Element;
|
||||
titleText?: string;
|
||||
content: JSX.Element;
|
||||
disabled?: boolean;
|
||||
}
|
||||
interface TabsProps {
|
||||
tabs: Tab[];
|
||||
style?: 'default' | 'box';
|
||||
className?: {
|
||||
container?: string;
|
||||
tab?: string;
|
||||
content?: string;
|
||||
};
|
||||
defaultActiveTab?: string;
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
style = 'default',
|
||||
className,
|
||||
defaultActiveTab,
|
||||
}: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
// 1) si se pasó defaultActiveTab y existe en tabs (y no está disabled), úsala
|
||||
if (defaultActiveTab) {
|
||||
const found = tabs.find((t) => t.id === defaultActiveTab && !t.disabled);
|
||||
if (found) return found.id;
|
||||
}
|
||||
// 2) si no, buscar el primer tab habilitado
|
||||
const firstEnabled = tabs.find((t) => !t.disabled);
|
||||
return firstEnabled ? firstEnabled.id : tabs[0]?.id || '';
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className={`nav nav-tabs style-${style}`} id='myTab' role='tablist'>
|
||||
{tabs.map((tab) => (
|
||||
<li
|
||||
key={tab.id}
|
||||
className='nav-item'
|
||||
role='presentation'
|
||||
title={
|
||||
typeof tab.title === 'string' ? tab.title : tab.titleText || ''
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={`nav-link ${tab.disabled ? 'disabled' : ''} ${
|
||||
activeTab === tab.id ? 'active' : ''
|
||||
}`}
|
||||
id={`tab-${tab.id}`}
|
||||
data-bs-toggle='tab'
|
||||
data-bs-target={`#tab-pane-${tab.id}`}
|
||||
type='button'
|
||||
role='tab'
|
||||
aria-controls={`tab-pane-${tab.id}`}
|
||||
aria-selected={activeTab === tab.id}
|
||||
onClick={() => !tab.disabled && setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.title.toString().length > 35
|
||||
? `${tab.title.toString().slice(0, 35)}...`
|
||||
: tab.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className='tab-content mt-3' id='myTabContent'>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
key={tab.id}
|
||||
className={`tab-pane fade ${
|
||||
activeTab === tab.id ? 'show active' : ''
|
||||
} ${className?.content}`}
|
||||
id={`tab-pane-${tab.id}`}
|
||||
role='tabpanel'
|
||||
aria-labelledby={`tab-${tab.id}`}
|
||||
>
|
||||
{activeTab === tab.id && tab.content}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,3 +17,13 @@ $hover-scales: (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$min-width: 100px;
|
||||
$max-width: 600px;
|
||||
|
||||
@for $i from 1 through (($max-width - $min-width) / 100px + 1) {
|
||||
$current-width: $min-width + ($i - 1) * 100px;
|
||||
.w-#{$current-width} {
|
||||
width: $current-width;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,3 +64,8 @@ ul.list-unstyled li {
|
||||
.btn-dorado {
|
||||
color: $white !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
--bs-body-bg: #ffffff;
|
||||
}
|
||||
|
||||