Preparacion del proyecto
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,42 +0,0 @@
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
+13
-11
@@ -1,20 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Roboto } from "next/font/google";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import "@/styles/sass/bootstrap.scss";
|
||||
import BootstrapClient from "@/components/bootstrap-client";
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const roboto = Roboto({
|
||||
weight: ["100", "300", "400", "500", "700", "900"],
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Sistema de formularios",
|
||||
description: "",
|
||||
icons: {
|
||||
icon: "/unam.png",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,7 +25,8 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<body className={roboto.className}>
|
||||
<BootstrapClient />
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
.page {
|
||||
--gray-rgb: 0, 0, 0;
|
||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
|
||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
|
||||
|
||||
--button-primary-hover: #383838;
|
||||
--button-secondary-hover: #f2f2f2;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 20px 1fr 20px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
min-height: 100svh;
|
||||
padding: 80px;
|
||||
gap: 64px;
|
||||
font-family: var(--font-geist-sans);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.page {
|
||||
--gray-rgb: 255, 255, 255;
|
||||
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
|
||||
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
|
||||
|
||||
--button-primary-hover: #ccc;
|
||||
--button-secondary-hover: #1a1a1a;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
grid-row-start: 2;
|
||||
}
|
||||
|
||||
.main ol {
|
||||
font-family: var(--font-geist-mono);
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
letter-spacing: -0.01em;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.main li:not(:last-of-type) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.main code {
|
||||
font-family: inherit;
|
||||
background: var(--gray-alpha-100);
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ctas {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
appearance: none;
|
||||
border-radius: 128px;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
transition:
|
||||
background 0.2s,
|
||||
color 0.2s,
|
||||
border-color 0.2s;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a.primary {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
border-color: var(--gray-alpha-200);
|
||||
min-width: 158px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
grid-row-start: 3;
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.footer img {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Enable hover only on non-touch devices */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
a.primary:hover {
|
||||
background: var(--button-primary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
a.secondary:hover {
|
||||
background: var(--button-secondary-hover);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.page {
|
||||
padding: 32px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.main {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.main ol {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ctas {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ctas a {
|
||||
font-size: 14px;
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
a.secondary {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo {
|
||||
filter: invert();
|
||||
}
|
||||
}
|
||||
+9
-90
@@ -1,95 +1,14 @@
|
||||
import Image from "next/image";
|
||||
import styles from "./page.module.css";
|
||||
import Footer from "@/components/layout/footer";
|
||||
import Header from "@/components/layout/header";
|
||||
import React from "react";
|
||||
|
||||
export default function Home() {
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.main}>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol>
|
||||
<li>
|
||||
Get started by editing <code>src/app/page.tsx</code>.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
|
||||
<div className={styles.ctas}>
|
||||
<a
|
||||
className={styles.primary}
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className={styles.logo}
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={styles.secondary}
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
<div className="d-flex flex-column min-vh-100">
|
||||
<Header />
|
||||
<div className="container flex-grow-1">
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
.box {
|
||||
@extend .shadow-sm;
|
||||
@extend .rounded;
|
||||
@extend .bg-white;
|
||||
@extend .p-3;
|
||||
|
||||
&.active {
|
||||
@extend .bg-primary;
|
||||
@extend .bg-opacity-75;
|
||||
@extend .text-white;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
@extend .text-muted;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
}
|
||||
|
||||
.unlink {
|
||||
@extend .text-decoration-none;
|
||||
@extend .text-dark;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
@extend .d-flex;
|
||||
@extend .justify-content-center;
|
||||
@extend .align-items-center;
|
||||
}
|
||||
|
||||
.option {
|
||||
@extend .box;
|
||||
@extend .py-2;
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:has(input:checked) {
|
||||
@extend .bg-primary;
|
||||
@extend .bg-opacity-75;
|
||||
@extend .text-white;
|
||||
}
|
||||
|
||||
input[type='radio']:checked {
|
||||
border: 1px solid #ffffff;
|
||||
@extend .bg-primary;
|
||||
@extend .bg-opacity-75;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f1f1f1 !important;
|
||||
}
|
||||
|
||||
&:has(input:disabled) {
|
||||
@extend .bg-light;
|
||||
@extend .text-muted;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.border-top-azul {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: $azul;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Este bloque crea una clase por cada color del tema, en cada dirección.
|
||||
@each $color-name, $color-value in $theme-colors {
|
||||
.bg-gradient-#{$color-name}-top {
|
||||
background-image: linear-gradient(
|
||||
to top,
|
||||
rgba($color-value, 0.5),
|
||||
$body-bg
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-#{$color-name}-bottom {
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
rgba($color-value, 0.5),
|
||||
$body-bg
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-#{$color-name}-left {
|
||||
background-image: linear-gradient(to left, rgba($color-value, 0.5), $body-bg);
|
||||
}
|
||||
|
||||
.bg-gradient-#{$color-name}-right {
|
||||
background-image: linear-gradient(to right, rgba($color-value, 0.5), $body-bg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Clases personalizadas de hover por color
|
||||
@each $name, $color in $theme-colors {
|
||||
.hover-bg-#{$name} {
|
||||
transition: background-color 0.4s;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background-color: rgba($color, 0.75) !important;
|
||||
.nav-link {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
.sidebar {
|
||||
@extend .py-0;
|
||||
@extend .box;
|
||||
@extend .m-lg-2;
|
||||
|
||||
.nav-item {
|
||||
@extend .rounded-pill;
|
||||
@extend .w-100;
|
||||
transition: background-color 0.4s;
|
||||
|
||||
// Hover por defecto (solo si no tiene clase hover-bg-*)
|
||||
&:not([class*="hover-bg-"]):hover,
|
||||
&:not([class*="hover-bg-"]).active {
|
||||
@extend .bg-primary;
|
||||
@extend .bg-opacity-75;
|
||||
|
||||
.nav-link {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
i {
|
||||
@extend .me-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
@extend .py-lg-2;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: clamp(250px, 100%, 300px);
|
||||
max-width: 300px;
|
||||
transition: width 0.3s ease;
|
||||
|
||||
.offcanvas-body {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&.open {
|
||||
.nav-link {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
transition: opacity 0.3s, visibility 0.3s;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.close {
|
||||
width: 80px;
|
||||
|
||||
.nav-item:hover {
|
||||
position: relative;
|
||||
|
||||
&::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 100%;
|
||||
transform: translate(10px, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
opacity: 0;
|
||||
animation: fadeInOut 4s ease-in-out;
|
||||
font-style: normal;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
span {
|
||||
transition: opacity 0.3s, visibility 0.3s;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
:root {
|
||||
--bg-header-table: #eee9e9;
|
||||
--border-row-table: #cecece;
|
||||
--hover-background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.e-table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 10px;
|
||||
|
||||
thead {
|
||||
tr {
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
th {
|
||||
background-color: var(--bg-header-table);
|
||||
position: sticky;
|
||||
top: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 15px;
|
||||
background-color: #ffffff;
|
||||
transition: background-color 0.5s;
|
||||
|
||||
&:first-child {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--bs-box-shadow-sm);
|
||||
|
||||
&.clickable-row {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
td {
|
||||
background-color: var(--hover-background-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0 10px 10px;
|
||||
border-radius: 8px;
|
||||
|
||||
th,
|
||||
td,
|
||||
th:first-child,
|
||||
td:first-child,
|
||||
th:last-child,
|
||||
td:last-child {
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
td[data-titulo]::before {
|
||||
content: attr(data-titulo) ':';
|
||||
margin-right: 15px;
|
||||
font-weight: 600;
|
||||
color: #3e3e3e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
.tab-pane.fade {
|
||||
transition: opacity 10s linear !important;
|
||||
}
|
||||
|
||||
.nav-tabs.style-box {
|
||||
border-bottom: 0cap;
|
||||
gap: 0.8rem;
|
||||
.nav-item .nav-link {
|
||||
@extend .box;
|
||||
@extend .py-2;
|
||||
@extend .text-black;
|
||||
|
||||
margin: 0 !important;
|
||||
border: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Archivo: _utilities.scss
|
||||
|
||||
$hover-scales: (
|
||||
1: 1.05,
|
||||
2: 1.1,
|
||||
3: 1.15,
|
||||
4: 1.2,
|
||||
5: 1.25
|
||||
);
|
||||
|
||||
@each $key, $value in $hover-scales {
|
||||
.scale-hover-#{$key} {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
transform: scale(#{$value});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/* Variables sobreescritas de bootstrap */
|
||||
$body-bg: #f5f5f7;
|
||||
$placeholder-opacity-max: 0.2;
|
||||
$placeholder-opacity-min: 0.1;
|
||||
|
||||
$modal-content-border-color: none;
|
||||
$modal-content-bg: #f5f5f7;
|
||||
$modal-lg: 900px;
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
@import "bootstrap/scss/functions";
|
||||
|
||||
@import "variables";
|
||||
|
||||
@import "bootstrap/scss/variables";
|
||||
@import "bootstrap/scss/variables-dark";
|
||||
|
||||
// Include any default map overrides here
|
||||
$azul: #003d79;
|
||||
$dorado: #d59f0f;
|
||||
|
||||
$custom-colors: (
|
||||
"azul": $azul,
|
||||
"dorado": $dorado,
|
||||
);
|
||||
|
||||
$theme-colors: map-merge($theme-colors, $custom-colors);
|
||||
|
||||
@import "bootstrap/scss/maps";
|
||||
@import "bootstrap/scss/mixins";
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
|
||||
@import "gradients";
|
||||
@import "extends";
|
||||
@import "table";
|
||||
@import "utilities";
|
||||
@import 'sidebar';
|
||||
@import 'hover';
|
||||
@import 'tabs';
|
||||
|
||||
//Add additional custom code here
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.bd-layout {
|
||||
display: grid;
|
||||
grid-template-areas: "sidebar main";
|
||||
grid-template-columns: 1fr 5fr;
|
||||
gap: $spacer * 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
@include media-breakpoint-up(lg) {
|
||||
.bd-sidebar {
|
||||
grid-area: sidebar;
|
||||
position: sticky;
|
||||
top: ($spacer * 3);
|
||||
height: calc(100vh - 6rem);
|
||||
}
|
||||
}
|
||||
|
||||
ul.list-unstyled li {
|
||||
padding: 0.5rem 0;
|
||||
a {
|
||||
color: $azul;
|
||||
transition: color 0.2s;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
color: $dorado;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user