Preparacion del proyecto
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function BootstrapClient() {
|
||||
useEffect(() => {
|
||||
const loadBootstrap = async () => {
|
||||
if (typeof document !== 'undefined') {
|
||||
// @ts-expect-error: Bootstrap is loaded dynamically
|
||||
await import('bootstrap/dist/js/bootstrap.bundle.min.js');
|
||||
}
|
||||
};
|
||||
|
||||
loadBootstrap();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Botón reutilizable con soporte para variantes de Bootstrap y personalización de íconos.
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Variante Bootstrap o personalizada. Ej: `primary`, `danger`, `mi-clase-custom`.
|
||||
* @default "primary"
|
||||
*/
|
||||
variant?: string;
|
||||
|
||||
/**
|
||||
* Si se usa variante `outline` (por ejemplo, `btn-outline-primary`).
|
||||
* @default false
|
||||
*/
|
||||
outline?: boolean;
|
||||
|
||||
/**
|
||||
* Ícono Bootstrap (`string`, como `check-circle`) o componente React.
|
||||
*/
|
||||
icon?: string | React.ReactNode;
|
||||
|
||||
/**
|
||||
* Tamaño del botón: `sm` o `lg` para tamaños Bootstrap.
|
||||
*/
|
||||
size?: 'sm' | 'lg';
|
||||
|
||||
/**
|
||||
* Clases adicionales personalizadas para el botón.
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente `Button` con estilos de Bootstrap y soporte para íconos.
|
||||
*/
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
type = 'button',
|
||||
icon,
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
outline = false,
|
||||
disabled = false,
|
||||
size,
|
||||
...rest
|
||||
}) => {
|
||||
const btnVariant = outline ? `btn-outline-${variant}` : `btn-${variant}`;
|
||||
const sizeClass = size ? `btn-${size}` : '';
|
||||
const combinedClasses = `btn ${btnVariant} ${sizeClass} ${className}`.trim();
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
disabled={disabled}
|
||||
className={combinedClasses}
|
||||
{...rest}
|
||||
>
|
||||
{typeof icon === 'string' ? (
|
||||
<i className={`me-2 bi bi-${icon}`}></i>
|
||||
) : (
|
||||
icon && <span className="me-2">{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React, { useState } from "react";
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "className"> {
|
||||
label?: string;
|
||||
className?: {
|
||||
container?: string;
|
||||
label?: string;
|
||||
input?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente de entrada (`Input`) reutilizable en React.
|
||||
*
|
||||
* Este componente renderiza un campo de entrada (`<input>`) con una etiqueta opcional y
|
||||
* funcionalidad para mostrar u ocultar contraseñas cuando el tipo es `password`.
|
||||
* También permite personalizar clases CSS y es accesible mediante `aria-label` y `htmlFor`.
|
||||
*
|
||||
* @component
|
||||
* @param {Object} props - Propiedades del componente.
|
||||
* @param {string} [props.label] - Etiqueta visible asociada al campo de entrada.
|
||||
* @param {Object} [props.className] - Clases CSS personalizables para distintos elementos.
|
||||
* @param {string} [props.className.container] - Clases para el contenedor del input.
|
||||
* @param {string} [props.className.label] - Clases para la etiqueta del input.
|
||||
* @param {string} [props.className.input] - Clases para el input en sí.
|
||||
* @param {string} [props.type] - Tipo del input (`text`, `password`, `email`, etc.).
|
||||
* @param {string} [props.name] - Nombre del input (útil para formularios).
|
||||
* @param {string} [props.id] - ID personalizado para el input (para accesibilidad).
|
||||
* @param {string} [props.value] - Valor del input (para uso controlado).
|
||||
* @param {function} [props.onChange] - Manejador del cambio de valor del input.
|
||||
* @param {React.InputHTMLAttributes<HTMLInputElement>} [rest] - Atributos adicionales compatibles con `<input>`.
|
||||
*/
|
||||
export default function Input(props: InputProps) {
|
||||
const { className, label, type, name, id, ...rest } = props;
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const isPassword = type === "password";
|
||||
const inputType = isPassword && showPassword ? "text" : type;
|
||||
const inputId =
|
||||
id || name || (label ? `input-${label.replace(/\s+/g, "-")}` : undefined);
|
||||
|
||||
const togglePasswordVisibility = () => setShowPassword(!showPassword);
|
||||
|
||||
return (
|
||||
<div className={className?.container || "mb-3"}>
|
||||
{label && inputId && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className={`form-label ${className?.label || ""}`}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="input-group">
|
||||
<input
|
||||
id={inputId}
|
||||
name={name}
|
||||
type={inputType}
|
||||
className={`form-control bg-white ${className?.input || ""}`}
|
||||
{...rest}
|
||||
/>
|
||||
{isPassword && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-light border"
|
||||
onClick={togglePasswordVisibility}
|
||||
aria-label={
|
||||
showPassword ? "Ocultar contraseña" : "Mostrar contraseña"
|
||||
}
|
||||
title={showPassword ? "Ocultar contraseña" : "Mostrar contraseña"}
|
||||
>
|
||||
<i className={`bi ${showPassword ? "bi-eye-slash" : "bi-eye"}`}></i>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,103 @@
|
||||
import React, { useId } from 'react';
|
||||
|
||||
interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SelectProps
|
||||
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'className'> {
|
||||
label: string;
|
||||
className?: {
|
||||
container?: string;
|
||||
label?: string;
|
||||
input?: string;
|
||||
};
|
||||
options: Option[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Componente `Select` reutilizable en React.
|
||||
*
|
||||
* Este componente renderiza un elemento `<select>` con una etiqueta visible,
|
||||
* opciones personalizadas y estilos configurables por clase. Es compatible
|
||||
* con el uso controlado (`value`) o no controlado (`defaultValue`).
|
||||
*
|
||||
* Además, genera automáticamente un `id` si no se proporciona, manteniendo
|
||||
* buenas prácticas de accesibilidad.
|
||||
*
|
||||
* @component
|
||||
* @example
|
||||
* // Uso básico
|
||||
* <Select
|
||||
* label="País"
|
||||
* name="pais"
|
||||
* options={[
|
||||
* { value: 'mx', label: 'México' },
|
||||
* { value: 'us', label: 'Estados Unidos' },
|
||||
* { value: 'ca', label: 'Canadá' }
|
||||
* ]}
|
||||
* placeholder="Selecciona un país"
|
||||
* defaultValue=""
|
||||
* />
|
||||
*
|
||||
* @param {Object} props - Propiedades del componente.
|
||||
* @param {string} props.label - Etiqueta visible asociada al campo.
|
||||
* @param {Array<{ value: string | number, label: string }>} props.options - Lista de opciones disponibles en el select.
|
||||
* @param {string} [props.placeholder] - Opción deshabilitada inicial que actúa como guía o título (si se proporciona).
|
||||
* @param {string} [props.name] - Nombre del campo (útil en formularios).
|
||||
* @param {string} [props.id] - ID del campo; si no se proporciona, se genera automáticamente.
|
||||
* @param {string | number} [props.defaultValue] - Valor por defecto del select (modo no controlado).
|
||||
* @param {string | number} [props.value] - Valor actual del select (modo controlado).
|
||||
* @param {Object} [props.className] - Objeto para aplicar clases CSS personalizadas.
|
||||
* @param {string} [props.className.container] - Clases del contenedor principal.
|
||||
* @param {string} [props.className.label] - Clases de la etiqueta.
|
||||
* @param {string} [props.className.input] - Clases del `<select>`.
|
||||
* @param {React.SelectHTMLAttributes<HTMLSelectElement>} [rest] - Cualquier otra prop válida para `<select>`.
|
||||
*
|
||||
* @returns {JSX.Element} Un elemento `<select>` con sus opciones y etiqueta asociada.
|
||||
*/
|
||||
const Select: React.FC<SelectProps> = ({
|
||||
label,
|
||||
className,
|
||||
options,
|
||||
placeholder,
|
||||
id,
|
||||
name,
|
||||
defaultValue,
|
||||
value,
|
||||
...rest
|
||||
}) => {
|
||||
const generatedId = useId();
|
||||
const selectId = id || name || generatedId;
|
||||
|
||||
return (
|
||||
<div className={`mb-3 ${className?.container ?? ''}`}>
|
||||
<label htmlFor={selectId} className={`form-label ${className?.label ?? ''}`}>
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
id={selectId}
|
||||
name={name}
|
||||
className={`form-select bg-white ${className?.input ?? ''}`}
|
||||
defaultValue={defaultValue ?? (value === undefined ? '' : undefined)}
|
||||
value={value}
|
||||
{...rest}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value.toString()} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Select;
|
||||
@@ -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,196 @@
|
||||
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)}
|
||||
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,74 @@
|
||||
import React, { useState, JSX } from "react";
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
title: string | JSX.Element;
|
||||
content: JSX.Element;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: Tab[];
|
||||
style?: "default" | "box";
|
||||
className?: {
|
||||
container?: string;
|
||||
tab?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function Tabs({
|
||||
tabs,
|
||||
style = "default",
|
||||
className,
|
||||
}: TabsProps) {
|
||||
const [activeTab, setActiveTab] = useState(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={tab.title.toString()}
|
||||
>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user