Merge pull request 'roberto' (#1) from roberto into main

Reviewed-on: #1
This commit is contained in:
roberto.viveros 2025-05-22 17:22:43 +00:00
commit 6c5a04aec4
36 changed files with 5886 additions and 270 deletions

View File

@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
devIndicators: false,
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -9,20 +9,34 @@
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.7",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-tooltip": "^1.2.4",
"@react-pdf/renderer": "^4.3.0",
"@sendgrid/mail": "^8.1.5",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"diplomas": "file:",
"express": "^5.1.0",
"lucide-react": "^0.488.0",
"mysql2": "^3.14.1",
"next": "15.3.0",
"papaparse": "^5.5.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.2",
"tailwind-merge": "^3.2.0",
"tw-animate-css": "^1.2.5"
"tw-animate-css": "^1.2.5",
"xlsx": "^0.18.5",
"zod": "^3.24.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",

View File

@ -0,0 +1,56 @@
import React from "react";
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer";
const styles = StyleSheet.create({
page: { padding: 40, fontFamily: "Helvetica" },
title: { fontSize: 24, textAlign: "center", marginBottom: 20 },
section: { marginBottom: 10, fontSize: 14 },
competencias: { marginLeft: 20, marginTop: 5 },
competencia: { fontSize: 12, marginBottom: 2 },
footer: {
position: "absolute",
bottom: 20,
right: 40,
fontSize: 10,
color: "#888",
},
});
export default function Diploma({ alumno, curso, competencias = [], fecha }) {
return (
<Document>
<Page size="A4" style={styles.page}>
<Text style={styles.title}>Diploma</Text>
<View style={styles.section}>
<Text>
<Text style={{ fontWeight: "bold" }}>Alumno: </Text>
{alumno?.nombre}
</Text>
</View>
<View style={styles.section}>
<Text>
<Text style={{ fontWeight: "bold" }}>Curso: </Text>
{curso?.nombre || "Sin curso"}
</Text>
</View>
<View style={styles.section}>
<Text style={{ fontWeight: "bold" }}>Competencias Acreditadas:</Text>
<View style={styles.competencias}>
{(competencias || []).map((comp) => (
<Text key={comp.id} style={styles.competencia}>
- {comp.descripcion}
</Text>
))}
</View>
</View>
<View style={styles.section}>
<Text>
<Text style={{ fontWeight: "bold" }}>Fecha: </Text>
{fecha}
</Text>
</View>
<Text style={styles.footer}>Generado por SIDAC</Text>
</Page>
</Document>
);
}

View File

@ -0,0 +1,118 @@
import * as React from "react";
import { GalleryVerticalEnd } from "lucide-react";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarRail,
} from "@/components/ui/sidebar";
import Link from "next/link";
// This is sample data.
const data = {
navMain: [
{
title: "Alumnos",
items: [
{
title: "Vista de alumnos",
url: "/alumnosVista",
},
{
title: "Agregar manualmente",
url: "/alumnosManual",
},
{
title: "Agregar desde archivo",
url: "/alumnosArchivo",
},
],
},
{
title: "Cursos",
items: [
{
title: "Vista de cursos",
url: "/cursosVista",
},
{
title: "Agregar manualmente",
url: "/cursosManual",
},
{
title: "Agregar desde archivo",
url: "/cursosArchivo",
},
],
},
{
title: "Diplomas",
items: [
{
title: "Creación de diplomas",
url: "/diplomasVista",
},
],
},
],
};
export function AppSidebar({ ...props }) {
return (
<Sidebar {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<a href="/">
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<GalleryVerticalEnd className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-medium">SIDAC</span>
<span className="">v1.0.0</span>
</div>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarMenu>
{data.navMain.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<h1 className="text-xl font-medium">{item.title}</h1>
</SidebarMenuButton>
{item.items?.length ? (
<SidebarMenuSub>
{item.items.map((item) => (
<SidebarMenuSubItem key={item.title}>
<SidebarMenuSubButton
asChild
isActive={item.isActive}
className="py-5"
>
<Link href={item.url}>{item.title}</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
) : null}
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}

View File

@ -0,0 +1,112 @@
import React, { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { supabaseClient } from "@/utils/supabase";
// Puedes reemplazar esto por tus propios diseños
const DISEÑOS = [
{ id: 1, nombre: "Diseño Clásico" },
{ id: 2, nombre: "Diseño Moderno" },
];
export default function CrearDiplomaDialog({
open,
onOpenChange,
alumno,
competencias,
setCompetencias,
competenciasAcreditadas,
setCompetenciasAcreditadas,
fecha,
setFecha,
}) {
const [diseñoSeleccionado, setDiseñoSeleccionado] = useState(
DISEÑOS[0]?.id || null
);
const toggleCompetencia = (id) => {
setCompetenciasAcreditadas((prev) =>
prev.includes(id) ? prev.filter((cid) => cid !== id) : [...prev, id]
);
};
useEffect(() => {
if (alumno && alumno.curso?.id) {
supabaseClient
.from("curso_competencia")
.select("competencia(id, descripcion)")
.eq("curso_id", alumno.curso.id)
.then(({ data }) => {
const comps = data?.map((c) => c.competencia).filter(Boolean) || [];
setCompetencias(comps);
setCompetenciasAcreditadas(comps.map((c) => c.id)); // todas seleccionadas por default
});
}
}, [alumno, setCompetencias, setCompetenciasAcreditadas]);
const handleCrearDiploma = async () => {
// Aquí va la lógica para crear el diploma en la base de datos
onOpenChange(false);
};
if (!open || !alumno) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[400px]">
<DialogHeader>
<DialogTitle>Crear Diploma</DialogTitle>
<DialogDescription>
Selecciona las competencias acreditadas para el diploma.
</DialogDescription>
</DialogHeader>
<div className="mb-2">
<label className="block text-sm font-semibold mb-1">
Competencias:
</label>
{competencias.map((comp) => (
<div key={comp.id} className="flex items-center mb-1">
<input
type="checkbox"
checked={competenciasAcreditadas.includes(comp.id)}
onChange={() => toggleCompetencia(comp.id)}
className="mr-2"
/>
<span>{comp.descripcion}</span>
</div>
))}
</div>
<div className="mb-2">
<label className="block text-sm font-semibold mb-1">Fecha:</label>
<input
type="date"
className="border p-1 w-full"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
/>
</div>
<DialogFooter>
<Button
onClick={handleCrearDiploma}
className="bg-green-500 hover:bg-green-700 text-white"
>
Crear Diploma
</Button>
<Button
onClick={() => onOpenChange(false)}
className="bg-gray-400 hover:bg-gray-600 text-white"
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,193 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import Diploma from "@/components/Diploma";
import { PDFDownloadLink, PDFViewer, pdf } from "@react-pdf/renderer";
import { supabaseClient } from "@/utils/supabase";
function VistaPreviaDiplomaDialog({
open,
onOpenChange,
alumno,
competencias: competenciasProp,
fecha,
competenciasAcreditadas,
}) {
const [mostrarVistaPrevia, setMostrarVistaPrevia] = useState(false);
const [enviando, setEnviando] = useState(false);
const [mensaje, setMensaje] = useState("");
const [competencias, setCompetencias] = useState([]);
useEffect(() => {
if (alumno && alumno.curso?.id) {
supabaseClient
.from("curso_competencia")
.select("competencia(id, descripcion)")
.eq("curso_id", alumno.curso.id)
.then(({ data }) => {
const comps = data?.map((c) => c.competencia).filter(Boolean) || [];
setCompetencias(comps);
});
}
}, [alumno]);
if (!alumno) return null;
const competenciasMostradas = competenciasAcreditadas
? competencias.filter((comp) => competenciasAcreditadas.includes(comp.id))
: competencias;
// Simulación de envío de PDF por correo y WhatsApp
const handleEnviar = async () => {
setEnviando(true);
setMensaje("");
// Genera el PDF como blob
const blob = await pdf(
<Diploma
alumno={alumno}
curso={alumno.curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>
).toBlob();
// Convierte el blob a base64
const pdfBase64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(blob);
});
// Llama a tu API de Next.js
const resp = await fetch("/api/send-diploma", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: alumno.correo,
nombre: alumno.nombre,
curso: alumno.curso?.nombre || "Sin curso",
pdfBase64,
}),
});
if (resp.ok) {
// WhatsApp real (abre ventana)
const telefono = alumno.telefono.replace(/\D/g, "");
const mensajeWhatsapp = encodeURIComponent(
`Hola ${alumno.nombre}, tu diploma ha sido generado y enviado a tu correo (${alumno.correo}). ¡Felicidades!`
);
window.open(
`https://wa.me/${telefono}?text=${mensajeWhatsapp}`,
"_blank"
);
setMensaje(
`Diploma enviado por correo a ${alumno.correo} y mensaje enviado por WhatsApp al ${alumno.telefono}.`
);
} else {
setMensaje("Error enviando el diploma por correo.");
}
setEnviando(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg text-black">
<DialogHeader>
<DialogTitle>Diploma</DialogTitle>
</DialogHeader>
<div className="text-lg mb-2">
<b>Alumno:</b> {alumno.nombre}
</div>
<div className="text-lg mb-2">
<b>Curso:</b> {alumno.curso?.nombre || "Sin curso"}
</div>
<div className="text-lg mb-2">
<b>Competencias Acreditadas:</b>
<ul className="list-disc ml-6">
{competenciasMostradas.map((comp) => (
<li key={comp.id}>{comp.descripcion}</li>
))}
</ul>
</div>
<div className="text-lg mb-2">
<b>Fecha:</b> {fecha || new Date().toLocaleDateString()}
</div>
<div className="mt-auto text-gray-400 text-xs text-right">
Vista previa
</div>
<div className="mt-4 flex gap-2 justify-center flex-wrap">
<PDFDownloadLink
document={
<Diploma
alumno={alumno}
curso={alumno.curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>
}
fileName={`Diploma_${alumno.nombre}.pdf`}
>
{({ loading }) =>
loading ? (
<button className="bg-gray-300 px-4 py-2 rounded" disabled>
Generando PDF...
</button>
) : (
<button className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded">
Descargar PDF
</button>
)
}
</PDFDownloadLink>
<button
className="bg-blue-500 hover:bg-blue-700 text-white px-4 py-2 rounded"
onClick={() => setMostrarVistaPrevia(true)}
>
Ver vista previa PDF
</button>
<button
className="bg-purple-600 hover:bg-purple-800 text-white px-4 py-2 rounded"
onClick={handleEnviar}
disabled={enviando}
>
{enviando ? "Enviando..." : "Enviar por correo y WhatsApp"}
</button>
</div>
{mensaje && (
<div className="mt-4 text-green-700 font-semibold text-center">
{mensaje}
</div>
)}
{mostrarVistaPrevia && (
<div className="fixed inset-0 bg-black bg-opacity-60 flex flex-col items-center justify-center z-50">
<div className="bg-white rounded shadow-lg p-4 flex flex-col items-center">
<div className="w-[80vw] h-[80vh] lg:h-[90vh] mb-4 border">
<PDFViewer width="100%" height="100%">
<Diploma
alumno={alumno}
curso={alumno.curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>
</PDFViewer>
</div>
<button
className="bg-red-500 hover:bg-red-700 text-white px-4 py-2 rounded"
onClick={() => setMostrarVistaPrevia(false)}
>
Cerrar vista previa
</button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
export default VistaPreviaDiplomaDialog;

View File

@ -0,0 +1,17 @@
"use client";
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
export default function Layout({ children }) {
return (
<SidebarProvider>
<div className="flex">
<AppSidebar />
<SidebarInset>
<div className="p-4 w-full">{children}</div>
</SidebarInset>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,112 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({
...props
}) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({
className,
...props
}) {
return (
(<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props} />)
);
}
function BreadcrumbItem({
className,
...props
}) {
return (
(<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props} />)
);
}
function BreadcrumbLink({
asChild,
className,
...props
}) {
const Comp = asChild ? Slot : "a"
return (
(<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props} />)
);
}
function BreadcrumbPage({
className,
...props
}) {
return (
(<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props} />)
);
}
function BreadcrumbSeparator({
children,
className,
...props
}) {
return (
(<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}>
{children ?? <ChevronRight />}
</li>)
);
}
function BreadcrumbEllipsis({
className,
...props
}) {
return (
(<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>)
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@ -0,0 +1,27 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}) {
return (
(<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props} />)
);
}
export { Separator }

View File

@ -0,0 +1,138 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({
...props
}) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}) {
return (
(<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props} />)
);
}
function SheetContent({
className,
children,
side = "right",
...props
}) {
return (
(<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}>
{children}
<SheetPrimitive.Close
className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>)
);
}
function SheetHeader({
className,
...props
}) {
return (
(<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props} />)
);
}
function SheetFooter({
className,
...props
}) {
return (
(<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props} />)
);
}
function SheetTitle({
className,
...props
}) {
return (
(<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props} />)
);
}
function SheetDescription({
className,
...props
}) {
return (
(<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props} />)
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View File

@ -0,0 +1,656 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
const SidebarContext = React.createContext(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
}}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
}}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({ className, onClick, ...props }) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({ className, ...props }) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({ className, ...props }) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({ className, asChild = false, ...props }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({ className, asChild = false, ...props }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({ className, ...props }) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({ className, ...props }) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({ className, showIcon = false, ...props }) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={{
"--skeleton-width": width,
}}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({ className, ...props }) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}) {
return (
(<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props} />)
);
}
export { Skeleton }

View File

@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({
className,
...props
}) {
return (
(<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props} />)
);
}
export { Textarea }

View File

@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props} />);
}
function Tooltip({
...props
}) {
return (
(<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>)
);
}
function TooltipTrigger({
...props
}) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}) {
return (
(<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}>
{children}
<TooltipPrimitive.Arrow
className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>)
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange);
}, [])
return !!isMobile
}

View File

@ -1,80 +0,0 @@
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Alumnos() {
return (
<div className="flex flex-col items-center justify-center w-full h-screen text-black relative">
<Link href="/" className="bg-blue-400 py-2 px-5 absolute top-5 left-5">
Volver
</Link>
<Tabs defaultValue="account" className="w-[800px]">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account" className="text-black">
Registrar alumno manualmente
</TabsTrigger>
<TabsTrigger value="password" className="text-black">
Registrar alumno por archivo
</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>Alumnos</CardTitle>
<CardDescription>
Make changes to your account here. Click save when youre done.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="Pedro Duarte" />
</div>
<div className="space-y-1">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@peduarte" />
</div>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>Alumnos</CardTitle>
<CardDescription>
Change your password here. After saving, youll be logged out.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="current">Current password</Label>
<Input id="current" type="password" />
</div>
<div className="space-y-1">
<Label htmlFor="new">New password</Label>
<Input id="new" type="password" />
</div>
</CardContent>
<CardFooter>
<Button>Save password</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,80 +0,0 @@
import React from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
export default function Cursos() {
return (
<div className="flex flex-col items-center justify-center w-full h-screen text-black relative">
<Link href="/" className="bg-blue-400 py-2 px-5 absolute top-5 left-5">
Volver
</Link>
<Tabs defaultValue="account" className="w-[800px]">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="account" className="text-black">
Registrar curso manualmente
</TabsTrigger>
<TabsTrigger value="password" className="text-black">
Registrar curso por archivo
</TabsTrigger>
</TabsList>
<TabsContent value="account">
<Card>
<CardHeader>
<CardTitle>Cursos</CardTitle>
<CardDescription>
Make changes to your account here. Click save when youre done.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="name">Name</Label>
<Input id="name" defaultValue="Pedro Duarte" />
</div>
<div className="space-y-1">
<Label htmlFor="username">Username</Label>
<Input id="username" defaultValue="@peduarte" />
</div>
</CardContent>
<CardFooter>
<Button>Save changes</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="password">
<Card>
<CardHeader>
<CardTitle>Cursos</CardTitle>
<CardDescription>
Change your password here. After saving, youll be logged out.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="space-y-1">
<Label htmlFor="current">Current password</Label>
<Input id="current" type="password" />
</div>
<div className="space-y-1">
<Label htmlFor="new">New password</Label>
<Input id="new" type="password" />
</div>
</CardContent>
<CardFooter>
<Button>Save password</Button>
</CardFooter>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,220 @@
import React, { useState, useEffect } from "react";
import Papa from "papaparse";
import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { CursosManualForm } from "./cursosManual"; // Importa el formulario sin Layout
import { supabaseClient } from "@/utils/supabase";
export default function AlumnosArchivo() {
const [archivo, setArchivo] = useState(null);
const [datos, setDatos] = useState([]);
const [dialogoAbierto, setDialogoAbierto] = useState(false);
const [mensajeDialogo, setMensajeDialogo] = useState("");
const [mostrarDialogCurso, setMostrarDialogCurso] = useState(false);
const [cursoFaltante, setCursoFaltante] = useState("");
useEffect(() => {
if (archivo) extraerContenido();
}, [archivo]);
const registrarAlumnos = async () => {
if (datos.length === 0) return;
const errores = [];
for (const alumno of datos) {
// 1. Verifica si el curso existe
const { data: cursosEncontrados, error: errorCurso } = await supabaseClient
.from("curso")
.select("id")
.eq("nombre", alumno.nombreCurso)
.maybeSingle();
if (errorCurso) {
errores.push({ alumno, error: "Error al buscar el curso" });
continue;
}
if (!cursosEncontrados) {
// Si no existe el curso, muestra el dialog para registrar el curso
setCursoFaltante(alumno.nombreCurso);
setMostrarDialogCurso(true);
setMensajeDialogo(`El curso "${alumno.nombreCurso}" no existe. Por favor, regístralo primero.`);
setDialogoAbierto(true);
return; // Detiene el registro de alumnos
}
// 2. Si existe, registra el alumno con el curso_id correcto
const res = await fetch("/api/alumno", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: alumno.nombre,
correo: alumno.correo,
telefono: alumno.telefono,
curso_id: cursosEncontrados.id, // Usar el id del curso
}),
});
const resultado = await res.json();
if (!res.ok) {
errores.push({ alumno, error: resultado.error || "Error desconocido" });
}
}
if (errores.length > 0) {
setMensajeDialogo(`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`);
} else {
setMensajeDialogo("Todos los alumnos fueron registrados correctamente.");
setArchivo(null);
setDatos([]);
}
setDialogoAbierto(true);
};
const manejarArchivo = (e) => {
const file = e.target.files[0];
if (validarArchivo(file)) setArchivo(file);
};
const manejarSoltar = (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (validarArchivo(file)) setArchivo(file);
};
const manejarArrastrar = (e) => e.preventDefault();
const validarArchivo = (file) => {
if (file && (file.name.endsWith(".csv") || file.name.endsWith(".xlsx"))) {
return true;
} else {
setMensajeDialogo("Solo se permiten archivos .csv o .xlsx");
setDialogoAbierto(true);
return false;
}
};
const extraerContenido = () => {
if (!archivo) return;
const extension = archivo.name.split(".").pop().toLowerCase();
if (extension === "csv") {
Papa.parse(archivo, {
header: true,
skipEmptyLines: true,
complete: (result) => {
setDatos(result.data);
},
error: (error) => {
console.error("Error al leer el CSV:", error.message);
},
});
} else if (extension === "xlsx") {
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: "array" });
const hoja = workbook.SheetNames[0];
const contenido = XLSX.utils.sheet_to_json(workbook.Sheets[hoja], {
defval: "",
});
setDatos(contenido);
};
reader.readAsArrayBuffer(archivo);
}
};
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">
Nuevo alumno
</h1>
<label
htmlFor="archivo"
onDrop={manejarSoltar}
onDragOver={manejarArrastrar}
className="border-2 border-gray-300 rounded-md p-8 text-gray-600 cursor-pointer w-80 text-center mb-4"
>
{archivo ? (
<span className="text-black font-medium">{archivo.name}</span>
) : (
<span>Arrastra y suelta un archivo o haz clic para seleccionarlo</span>
)}
<input
type="file"
id="archivo"
accept=".csv, .xlsx"
onChange={manejarArchivo}
className="hidden"
/>
</label>
<Button
onClick={registrarAlumnos}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md mt-4"
>
Registrar alumnos
</Button>
{datos.length > 0 && (
<div className="mt-6 text-left w-full overflow-auto">
<h3 className="font-bold mb-2">Vista previa del archivo:</h3>
<table className="min-w-full bg-white border border-gray-300 text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
{Object.keys(datos[0]).map((columna, index) => (
<th key={index} className="border px-4 py-2">{columna}</th>
))}
</tr>
</thead>
<tbody>
{datos.map((fila, index) => (
<tr key={index}>
{Object.values(fila).map((valor, i) => (
<td key={i} className="border px-4 py-1">{valor}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Dialog para curso faltante */}
<Dialog open={mostrarDialogCurso} onOpenChange={setMostrarDialogCurso}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Registrar curso faltante
</DialogTitle>
<DialogDescription>
El curso <b>{cursoFaltante}</b> no existe. Por favor, regístralo antes de continuar.
</DialogDescription>
</DialogHeader>
<CursosManualForm nombreSugerido={cursoFaltante} />
<DialogFooter>
<Button onClick={() => setMostrarDialogCurso(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog de información */}
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Información</DialogTitle>
<DialogDescription>{mensajeDialogo}</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,177 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { supabaseClient } from "@/utils/supabase";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { alumnoSchema } from "@/schemas/AlumnosSchema";
export default function AlumnosManual() {
const [cursos, setCursos] = useState([]);
const [isDialogOpen, setIsDialogOpen] = useState(false); // Estado para controlar el diálogo
// Configurar React Hook Form con zod
const {
register,
handleSubmit,
setValue,
formState: { errors },
reset,
} = useForm({
resolver: zodResolver(alumnoSchema),
defaultValues: {
nombre: "",
correo: "",
telefono: "",
cursoSeleccionado: "",
},
});
// Cargar cursos al iniciar el componente
useEffect(() => {
const cargarCursos = async () => {
const { data, error } = await supabaseClient
.from("curso")
.select("id, nombre");
if (error) {
console.error("Error al cargar cursos:", error.message);
} else {
setCursos(data);
}
};
cargarCursos();
}, []);
// Guardar alumno
const manejarGuardar = async (data) => {
try {
const { error } = await supabaseClient.from("alumno").insert([
{
nombre: data.nombre,
correo: data.correo,
telefono: data.telefono,
curso_id: Number(data.cursoSeleccionado), // Guardar el ID del curso
},
]);
if (error) {
console.error("Error al guardar:", error.message);
setIsDialogOpen(false); // Asegurarse de cerrar el diálogo en caso de error
} else {
setIsDialogOpen(true); // Mostrar el diálogo al guardar exitosamente
reset(); // Reiniciar el formulario
}
} catch (err) {
console.error("Error inesperado:", err);
}
};
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%]">
<h1 className="text-xl font-semibold mb-4 text-black">
Nuevo alumno
</h1>
<form onSubmit={handleSubmit(manejarGuardar)}>
<div className="mb-3">
<Input
type="text"
placeholder="Nombre"
{...register("nombre")}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{errors.nombre && (
<p className="text-red-500 text-sm mt-1">
{errors.nombre.message}
</p>
)}
</div>
<div className="mb-3">
<Input
type="text"
placeholder="Email"
{...register("correo")}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{errors.correo && (
<p className="text-red-500 text-sm mt-1">
{errors.correo.message}
</p>
)}
</div>
<div className="mb-3">
<Input
type="text"
placeholder="Teléfono"
{...register("telefono")}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
{errors.telefono && (
<p className="text-red-500 text-sm mt-1">
{errors.telefono.message}
</p>
)}
</div>
<div className="mb-4">
<Select
onValueChange={(value) => setValue("cursoSeleccionado", value)}
>
<SelectTrigger className="w-full px-3 py-2 border border-gray-300 rounded-md">
<SelectValue placeholder="Selecciona un curso" />
</SelectTrigger>
<SelectContent>
{cursos.map((curso) => (
<SelectItem key={curso.id} value={curso.id.toString()}>
{curso.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.cursoSeleccionado && (
<p className="text-red-500 text-sm mt-1">
{errors.cursoSeleccionado.message}
</p>
)}
</div>
<Button
type="submit"
className="bg-green-400 hover:bg-green-500 font-bold py-2 px-4 rounded-md text-black"
>
Registrar
</Button>
</form>
</div>
</div>
{/* Diálogo de confirmación */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Alumno guardado</DialogTitle>
</DialogHeader>
<p className="text-black">El alumno ha sido guardado exitosamente.</p>
<DialogFooter>
<Button onClick={() => setIsDialogOpen(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,274 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { set } from "react-hook-form";
export default function AlumnosVista() {
const [alumnos, setAlumnos] = useState([]);
const [alumnoEditando, setAlumnoEditando] = useState(null);
const [nuevoNombre, setNuevoNombre] = useState("");
const [nuevoCorreo, setNuevoCorreo] = useState("");
const [nuevoNumero, setNuevoNumero] = useState("");
const [mostrarModal, setMostrarModal] = useState(false);
const [modalMensaje, setModalMensaje] = useState("");
const [nuevoCurso, setNuevoCurso] = useState("");
const [cursos, setCursos] = useState([]);
// Estado para confirmación de eliminación
const [confirmarEliminar, setConfirmarEliminar] = useState(false);
const [alumnoAEliminar, setAlumnoAEliminar] = useState(null);
useEffect(() => {
cargarAlumnos();
cargarCursos();
}, []);
const cargarCursos = async () => {
const { data, error } = await supabaseClient.from("curso").select("*");
if (error) {
console.error("Error al cargar cursos:", error.message);
} else {
setCursos(data);
}
};
const cargarAlumnos = async () => {
const { data, error } = await supabaseClient
.from("alumno")
.select("id, nombre, correo, telefono, curso_id, curso(nombre)")
.order("id", { ascending: true });
if (error) {
console.error("Error al cargar alumnos:", error.message);
} else {
setAlumnos(data);
}
};
// Iniciar edición
const iniciarEdicion = (alumno) => {
setAlumnoEditando(alumno.id);
setNuevoNombre(alumno.nombre);
setNuevoCorreo(alumno.correo);
setNuevoNumero(alumno.telefono);
setNuevoCurso(alumno.curso_id);
};
// Cancelar edición
const cancelarEdicion = () => {
setAlumnoEditando(null);
setNuevoNombre("");
setNuevoCorreo("");
setNuevoNumero("");
setNuevoCurso("");
};
// Guardar cambios
const guardarEdicion = async (id) => {
const { error } = await supabaseClient
.from("alumno")
.update({
nombre: nuevoNombre,
correo: nuevoCorreo,
telefono: nuevoNumero,
curso_id: nuevoCurso,
})
.eq("id", id);
if (error) {
console.error("Error actualizando alumno:", error.message);
setModalMensaje("Error al actualizar el alumno");
} else {
setModalMensaje("Alumno actualizado exitosamente");
await cargarAlumnos();
cancelarEdicion();
}
setMostrarModal(true);
};
// Confirmar eliminación
const confirmarEliminacion = (id) => {
setAlumnoAEliminar(id);
setConfirmarEliminar(true);
};
// Eliminar alumno
const eliminarAlumno = async () => {
const { error } = await supabaseClient.from("alumno").delete().eq("id", alumnoAEliminar);
if (error) {
console.error("Error eliminando alumno:", error.message);
setModalMensaje("Error al eliminar el alumno");
} else {
setModalMensaje("Alumno eliminado exitosamente");
await cargarAlumnos();
}
setConfirmarEliminar(false);
setMostrarModal(true);
};
return (
<Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6 text-black">
Lista de Alumnos
</h1>
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{alumnos.map((alumno) =>
alumnoEditando === alumno.id ? (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b text-center">
{alumno.id}
</td>
<td className="py-2 px-4 border-b">
<Input
type="text"
value={nuevoNombre}
onChange={(e) => setNuevoNombre(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<Input
type="email"
value={nuevoCorreo}
onChange={(e) => setNuevoCorreo(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<Input
type="text"
value={nuevoNumero}
onChange={(e) => setNuevoNumero(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<Select
value={nuevoCurso}
onValueChange={(value) => setNuevoCurso(value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona un curso" />
</SelectTrigger>
<SelectContent>
{cursos.map((curso) => (
<SelectItem key={curso.id} value={curso.id.toString()}>
{curso.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
</td>
<td className="py-2 px-4 border-b flex justify-center">
<Button
className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-2 rounded"
onClick={() => guardarEdicion(alumno.id)}
>
Guardar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded"
onClick={cancelarEdicion}
>
Cancelar
</Button>
</td>
</tr>
) : (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">{alumno.curso?.nombre || "Sin curso"}</td>
<td className="py-2 px-4 border-b space-x-2">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => iniciarEdicion(alumno)}
>
Editar
</Button>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded"
onClick={() => confirmarEliminacion(alumno.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
{/* Modal de confirmación */}
<Dialog open={confirmarEliminar} onOpenChange={setConfirmarEliminar}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Confirmar eliminación</DialogTitle>
<DialogDescription>
¿Estás seguro de que deseas eliminar este alumno? Esta acción no se puede deshacer.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={eliminarAlumno}
>
Eliminar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setConfirmarEliminar(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de resultado */}
<Dialog open={mostrarModal} onOpenChange={setMostrarModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Resultado de la operación
</DialogTitle>
<DialogDescription>{modalMensaje}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarModal(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,29 @@
// pages/api/alumno.js
import { createClient } from "@/utils/supabase";
export default async function handler(req, res) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Método no permitido" });
}
try {
const supabase = createClient({ req, res });
const { nombre, correo, telefono, curso_id } = req.body;
if (!nombre || !correo || !telefono || !curso_id) {
return res.status(400).json({ error: "Faltan datos del alumno" });
}
const { data, error } = await supabase.from("alumno").insert([
{ nombre, correo, telefono, curso_id },
]);
if (error) {
return res.status(500).json({ error: "Error al insertar en Supabase", detalles: error.message });
}
return res.status(200).json({ mensaje: "Alumno registrado", data });
} catch (err) {
return res.status(500).json({ error: "Error interno del servidor", detalles: err.message });
}
}

View File

@ -0,0 +1,56 @@
import { createClient } from "@/utils/supabase";
export default async function handler(req, res) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Método no permitido" });
}
try {
const supabase = createClient({ req, res });
const { nombre, horas, descripcion, competencias } = req.body;
if (!nombre || !horas || !descripcion || !Array.isArray(competencias)) {
return res.status(400).json({ error: "Faltan datos del curso" });
}
// 1. Insertar el curso
const { data: cursoInsertado, error: errorCurso } = await supabase
.from("curso")
.insert([{ nombre, horas, descripcion }])
.select("id")
.single();
if (errorCurso) {
return res.status(500).json({ error: "Error al insertar el curso", detalles: errorCurso.message });
}
const cursoId = cursoInsertado.id;
// 2. Insertar competencias y asociar en la tabla pivote
for (const descripcionComp of competencias) {
// Insertar competencia (sin validación, siempre se inserta)
const { data: competenciaInsertada, error: errorComp } = await supabase
.from("competencia")
.insert([{ descripcion: descripcionComp }])
.select("id")
.single();
if (errorComp) {
return res.status(500).json({ error: "Error al insertar competencia", detalles: errorComp.message });
}
// Insertar en la tabla pivote
const competenciaId = competenciaInsertada.id;
const { error: errorPivote } = await supabase
.from("curso_competencia")
.insert([{ curso_id: cursoId, competencia_id: competenciaId }]);
if (errorPivote) {
return res.status(500).json({ error: "Error al asociar competencia", detalles: errorPivote.message });
}
}
return res.status(200).json({ mensaje: "Curso registrado correctamente" });
} catch (err) {
return res.status(500).json({ error: "Error interno del servidor", detalles: err.message });
}
}

View File

@ -0,0 +1,35 @@
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
export default async function handler(req, res) {
if (req.method !== "POST") return res.status(405).end();
const { email, nombre, curso, pdfBase64 } = req.body;
try {
await sgMail.send({
to: email,
from: "rviverosgonzalez@outlook.com", // Cambia esto por tu correo verificado en SendGrid
subject: "Tu diploma",
text: `Hola ${nombre}, has concluido tu curso ${curso} por lo que adjuntamos tu diploma.`,
attachments: [
{
content: pdfBase64,
filename: `Diploma_${nombre}.pdf`,
type: "application/pdf",
disposition: "attachment",
},
],
});
res.status(200).json({ ok: true });
} catch (error) {
console.error(
"Error enviando el correo:",
error.response?.body || error.message || error
);
res
.status(500)
.json({ error: error.message, details: error.response?.body });
}
}

View File

@ -0,0 +1,12 @@
import Layout from "@/components/layout/Layout";
import React from "react";
export default function CompetenciasVista() {
return (
<Layout>
<div className="text-black">
<h1>Vista de competencias</h1>
</div>
</Layout>
);
}

View File

@ -0,0 +1,245 @@
import React, { useState, useEffect } from "react";
import Papa from "papaparse";
import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { useRouter } from "next/router";
export default function cursosArchivo() {
const [archivo, setArchivo] = useState(null);
const [datos, setDatos] = useState([]);
const [dialogoAbierto, setDialogoAbierto] = useState(false);
const [mensajeDialogo, setMensajeDialogo] = useState("");
const [dialogoCargando, setDialogoCargando] = useState(false);
const [dialogoAdvertencia, setDialogoAdvertencia] = useState(false);
const [rutaPendiente, setRutaPendiente] = useState(null);
const router = useRouter();
useEffect(() => {
if (archivo) extraerContenido();
}, [archivo]);
useEffect(() => {
const handleRouteChange = (url) => {
if (archivo && datos.length > 0) {
setDialogoAdvertencia(true);
setRutaPendiente(url);
// Cancelar navegación
throw "Bloqueo de navegación por archivo pendiente";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [archivo, datos, router]);
const registrarCursos = async () => {
if (datos.length === 0) return;
setDialogoCargando(true); // Mostrar dialogo de carga
const errores = [];
for (const curso of datos) {
const res = await fetch("/api/curso", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: curso.nombre,
horas: curso.horas,
descripcion: curso.descripcion,
competencias: curso.competencias
? curso.competencias.split(",").map(c => c.trim())
: [],
}),
});
const resultado = await res.json();
if (!res.ok) {
errores.push({ curso, error: resultado.error || "Error desconocido" });
}
}
setDialogoCargando(false); // Ocultar dialogo de carga
if (errores.length > 0) {
setMensajeDialogo(`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`);
} else {
setMensajeDialogo("Todos los cursos fueron registrados correctamente.");
setArchivo(null);
setDatos([]);
}
setDialogoAbierto(true);
};
const manejarArchivo = (e) => {
const file = e.target.files[0];
if (validarArchivo(file)) setArchivo(file);
};
const manejarSoltar = (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (validarArchivo(file)) setArchivo(file);
};
const manejarArrastrar = (e) => e.preventDefault();
const validarArchivo = (file) => {
if (file && (file.name.endsWith(".csv") || file.name.endsWith(".xlsx"))) {
return true;
} else {
setMensajeDialogo("Solo se permiten archivos .csv o .xlsx");
setDialogoAbierto(true);
return false;
}
};
const extraerContenido = () => {
if (!archivo) return;
const extension = archivo.name.split(".").pop().toLowerCase();
if (extension === "csv") {
Papa.parse(archivo, {
header: true,
skipEmptyLines: true,
complete: (result) => {
setDatos(result.data);
},
error: (error) => {
console.error("Error al leer el CSV:", error.message);
},
});
} else if (extension === "xlsx") {
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: "array" });
const hoja = workbook.SheetNames[0];
const contenido = XLSX.utils.sheet_to_json(workbook.Sheets[hoja], {
defval: "",
});
setDatos(contenido);
};
reader.readAsArrayBuffer(archivo);
}
};
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">
Nuevo curso
</h1>
<label
htmlFor="archivo"
onDrop={manejarSoltar}
onDragOver={manejarArrastrar}
className="border-2 border-gray-300 rounded-md p-8 text-gray-600 cursor-pointer w-80 text-center mb-4"
>
{archivo ? (
<span className="text-black font-medium">{archivo.name}</span>
) : (
<span>Arrastra y suelta un archivo o haz clic para seleccionarlo</span>
)}
<input
type="file"
id="archivo"
accept=".csv, .xlsx"
onChange={manejarArchivo}
className="hidden"
/>
</label>
<Button
onClick={registrarCursos}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md mt-4"
>
Registrar cursos
</Button>
{datos.length > 0 && (
<div className="mt-6 text-left w-full overflow-auto">
<h3 className="font-bold mb-2">Vista previa del archivo:</h3>
<table className="min-w-full bg-white border border-gray-300 text-sm">
<thead className="bg-gray-100 text-gray-700">
<tr>
{Object.keys(datos[0]).map((columna, index) => (
<th key={index} className="border px-4 py-2">{columna}</th>
))}
</tr>
</thead>
<tbody>
{datos.map((fila, index) => (
<tr key={index}>
{Object.values(fila).map((valor, i) => (
<td key={i} className="border px-4 py-1">{valor}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* Dialog Component */}
<Dialog open={dialogoAbierto} onOpenChange={setDialogoAbierto}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Información</DialogTitle>
<DialogDescription>{mensajeDialogo}</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={dialogoCargando}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Cargando...</DialogTitle>
<DialogDescription>
Por favor espera, se están registrando los cursos.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={dialogoAdvertencia} onOpenChange={setDialogoAdvertencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle>
<DialogDescription>
Si cambias de ventana perderás la subida del archivo. ¿Deseas continuar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={() => {
setDialogoAdvertencia(false);
setArchivo(null);
setDatos([]);
if (rutaPendiente) router.push(rutaPendiente);
}}
>
, continuar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setDialogoAdvertencia(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,388 @@
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { cursosSchema } from "@/schemas/CursosSchema";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { supabaseClient } from "@/utils/supabase"; // Importar el cliente de Supabase
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
export default function CursosManual() {
const [addCompetencia, setAddCompetencia] = useState(false);
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]);
const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
const form = useForm({
resolver: zodResolver(cursosSchema),
defaultValues: {
nombre: "",
descripcion: "",
horas: 0,
nuevaCompetencia: "",
},
});
const {
register,
handleSubmit,
setValue,
getValues,
formState: { errors },
} = form;
const handleAddCompetencia = () => {
setAddCompetencia(true);
};
const handleCancel = () => {
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
};
const handleSaveCompetencia = (e) => {
e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia");
if (nuevaCompetencia.trim() !== "") {
setCompetenciasGuardadas([
...competenciasGuardadas,
nuevaCompetencia.trim(),
]);
handleCancel();
}
};
const handleDeleteCompetencia = (index) => {
setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== index)
);
};
const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10); // Convertir horas a número
const competencias = competenciasGuardadas;
setLoading(true); // Mostrar estado de carga
try {
const { error } = await supabaseClient.from("curso").insert([
{
nombre,
descripcion,
horas,
competencias, // Guardar competencias como array
},
]);
if (error) {
console.error("Error al guardar en Supabase:", error.message);
alert("Error al guardar el curso: " + error.message);
} else {
setMensajeDialog("Curso guardado exitosamente");
setMostrarDialog(true);
form.reset(); // Reiniciar el formulario
setCompetenciasGuardadas([]); // Limpiar competencias guardadas
}
} catch (err) {
console.error("Error inesperado:", err);
alert("Ocurrió un error inesperado");
} finally {
setLoading(false); // Ocultar estado de carga
}
};
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">Nuevo curso</h1>
<CursosManualForm nombreSugerido="" />
</div>
</div>
</Layout>
);
}
export function CursosManualForm({ nombreSugerido = "" }) {
const [addCompetencia, setAddCompetencia] = useState(false);
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]); // [{id, descripcion}]
const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
// Estado para dialog de competencia agregada
const [mostrarDialogCompetencia, setMostrarDialogCompetencia] = useState(false);
const form = useForm({
resolver: zodResolver(cursosSchema),
defaultValues: {
nombre: nombreSugerido,
descripcion: "",
horas: 0,
nuevaCompetencia: "",
},
});
const {
register,
handleSubmit,
setValue,
getValues,
formState: { errors },
} = form;
// Cambia handleSaveCompetencia para mostrar el dialog
const handleSaveCompetencia = async (e) => {
e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia").trim();
if (!nuevaCompetencia) return;
// Verifica si ya existe en el estado
if (competenciasGuardadas.some((c) => c.descripcion === nuevaCompetencia)) {
alert("La competencia ya fue agregada.");
return;
}
// Verifica si ya existe en la base de datos
let competenciaId = null;
try {
// Busca si ya existe
const { data: existente } = await supabaseClient
.from("competencia")
.select("id")
.eq("descripcion", nuevaCompetencia)
.maybeSingle();
if (existente && existente.id) {
competenciaId = existente.id;
} else {
// Si no existe, la crea
const { data: insertada, error } = await supabaseClient
.from("competencia")
.insert([{ descripcion: nuevaCompetencia }])
.select("id")
.single();
if (error) throw error;
competenciaId = insertada.id;
}
setCompetenciasGuardadas([
...competenciasGuardadas,
{ id: competenciaId, descripcion: nuevaCompetencia },
]);
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
setMostrarDialogCompetencia(true); // Mostrar dialog de éxito
} catch (err) {
alert("Error al guardar la competencia: " + (err.message || err));
}
};
const handleDeleteCompetencia = (index) => {
setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== index)
);
};
// Guardar curso y asociar competencias
const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10);
setLoading(true);
try {
// 1. Inserta el curso
const { data: cursoInsertado, error: errorCurso } = await supabaseClient
.from("curso")
.insert([{ nombre, descripcion, horas }])
.select("id")
.single();
if (errorCurso) {
setMensajeDialog("Error al guardar el curso: " + errorCurso.message);
setMostrarDialog(true);
setLoading(false);
return;
}
// 2. Inserta en la tabla pivote curso_competencia
const cursoId = cursoInsertado.id;
const relaciones = competenciasGuardadas.map((c) => ({
curso_id: cursoId,
competencia_id: c.id,
}));
if (relaciones.length > 0) {
const { error: errorPivote } = await supabaseClient
.from("curso_competencia")
.insert(relaciones);
if (errorPivote) {
setMensajeDialog("Error al asociar competencias: " + errorPivote.message);
setMostrarDialog(true);
setLoading(false);
return;
}
}
setMensajeDialog("Curso guardado exitosamente");
setMostrarDialog(true);
form.reset();
setCompetenciasGuardadas([]);
} catch (err) {
alert("Ocurrió un error inesperado");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<Input
type="text"
placeholder="Nombre del curso"
{...register("nombre")}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.nombre && (
<p className="text-red-500 text-sm">{errors.nombre.message}</p>
)}
<Textarea
placeholder="Descripción"
{...register("descripcion")}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 h-24 text-black"
/>
{errors.descripcion && (
<p className="text-red-500 text-sm">{errors.descripcion.message}</p>
)}
<Input
type="number"
placeholder="Horas del curso"
{...register("horas", { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.horas && (
<p className="text-red-500 text-sm">{errors.horas.message}</p>
)}
<h2 className="text-lg font-semibold mb-3 text-black">Competencias</h2>
<p className="text-xs text-gray-500 mb-2">
Puedes agregar competencias nuevas sin necesidad de crear un nuevo curso. Las competencias se guardarán y podrás asociarlas a otros cursos después.
</p>
{competenciasGuardadas.length > 0 && (
<div className="mt-5 w-full flex-wrap">
{competenciasGuardadas.map((competencia, index) => (
<div
key={index}
className="w-full flex justify-between items-center px-2 mb-2"
>
<span className="text-black">{competencia.descripcion}</span>
<Button
type="button"
onClick={() => handleDeleteCompetencia(index)}
className="bg-red-400 hover:bg-red-500 text-white font-bold py-1 px-3 rounded-md"
>
X
</Button>
</div>
))}
</div>
)}
{addCompetencia && (
<div className="w-full flex flex-col md:flex-row mt-5">
<div className="flex flex-col">
<Input
type="text"
placeholder="Nueva competencia"
{...register("nuevaCompetencia")}
className="w-80 px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.nuevaCompetencia && (
<p className="text-red-500 text-sm">
{errors.nuevaCompetencia.message}
</p>
)}
</div>
<div className="flex flex-row">
<Button
type="button"
onClick={handleSaveCompetencia}
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md mr-2"
>
Guardar
</Button>
<Button
type="button"
onClick={() => {
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
}}
className="bg-gray-400 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-md"
>
Cancelar
</Button>
</div>
</div>
)}
<Button
type="button"
onClick={() => setAddCompetencia(true)}
className="w-full bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded-md mt-5"
>
Agregar competencia
</Button>
<div className="flex justify-center w-full mt-5">
<Button
type="submit"
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md"
disabled={loading}
>
{loading ? "Guardando..." : "Guardar curso"}
</Button>
</div>
<Dialog open={mostrarDialog} onOpenChange={setMostrarDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Resultado</DialogTitle>
<DialogDescription>{mensajeDialog}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialog(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={mostrarDialogCompetencia} onOpenChange={setMostrarDialogCompetencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Competencia agregada</DialogTitle>
<DialogDescription>
¡La competencia fue agregada exitosamente!
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialogCompetencia(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
);
}

View File

@ -0,0 +1,414 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default function CursosVista() {
const [cursos, setCursos] = useState([]);
const [cursoEditando, setCursoEditando] = useState(null);
const [nuevoNombre, setNuevoNombre] = useState("");
const [nuevaDescripcion, setNuevaDescripcion] = useState("");
const [nuevaHoras, setNuevaHoras] = useState("");
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]);
const [mostrarModal, setMostrarModal] = useState(false);
const [modalMensaje, setModalMensaje] = useState("");
const [todasCompetencias, setTodasCompetencias] = useState([]);
// Para eliminar curso
const [confirmarEliminar, setConfirmarEliminar] = useState(false);
const [cursoAEliminar, setCursoAEliminar] = useState(null);
// Para eliminar competencia
const [dialogQuitarComp, setDialogQuitarComp] = useState(false);
const [compAEliminar, setCompAEliminar] = useState(null);
useEffect(() => {
cargarCursos();
cargarTodasCompetencias();
}, []);
const cargarCursos = async () => {
const { data, error } = await supabaseClient
.from("curso")
.select(`
id,
nombre,
descripcion,
horas,
curso_competencia (
competencia (
id,
descripcion
)
)
`)
.order("id", { ascending: true });
if (error) {
console.error("Error al cargar cursos:", error.message);
} else {
const cursosConCompetencias = data.map((curso) => ({
...curso,
competencias: curso.curso_competencia.map((cc) => cc.competencia),
}));
setCursos(cursosConCompetencias);
}
};
const cargarTodasCompetencias = async () => {
const { data, error } = await supabaseClient
.from("competencia")
.select("id, descripcion")
.order("id", { ascending: true });
if (!error) setTodasCompetencias(data);
};
const iniciarEdicion = (curso) => {
setCursoEditando(curso.id);
setNuevoNombre(curso.nombre);
setNuevaDescripcion(curso.descripcion);
setNuevaHoras(curso.horas);
setCompetenciasGuardadas(curso.competencias || []);
};
const cancelarEdicion = () => {
setCursoEditando(null);
setNuevoNombre("");
setNuevaDescripcion("");
setNuevaHoras("");
setCompetenciasGuardadas([]);
};
// Guardar cambios en curso y competencias
const guardarEdicion = async (id) => {
// Validar que no haya competencias repetidas
const ids = competenciasGuardadas.map(c => c?.id).filter(Boolean);
const setIds = new Set(ids);
if (ids.length !== setIds.size) {
setModalMensaje("No puedes repetir competencias en un curso.");
setMostrarModal(true);
return;
}
// Actualiza datos del curso
const { error: errorCurso } = await supabaseClient
.from("curso")
.update({
nombre: nuevoNombre,
descripcion: nuevaDescripcion,
horas: nuevaHoras,
})
.eq("id", id);
// Actualiza competencias (tabla pivote)
// 1. Elimina todas las competencias actuales del curso
await supabaseClient
.from("curso_competencia")
.delete()
.eq("curso_id", id);
// 2. Inserta las nuevas competencias seleccionadas
const competenciasAInsertar = competenciasGuardadas
.filter(c => c && c.id)
.map(c => ({
curso_id: id,
competencia_id: c.id,
}));
if (competenciasAInsertar.length > 0) {
await supabaseClient
.from("curso_competencia")
.insert(competenciasAInsertar);
}
if (errorCurso) {
setModalMensaje("Error al actualizar el curso");
} else {
setModalMensaje("Curso actualizado exitosamente");
await cargarCursos();
cancelarEdicion();
}
setMostrarModal(true);
};
const confirmarEliminacion = (id) => {
setCursoAEliminar(id);
setConfirmarEliminar(true);
};
const eliminarCurso = async () => {
// 0. Verifica si hay alumnos inscritos a este curso
const { data: alumnosInscritos, error: errorAlumnos } = await supabaseClient
.from("alumno")
.select("id")
.eq("curso_id", cursoAEliminar);
if (errorAlumnos) {
setModalMensaje("Error al verificar alumnos inscritos.");
setConfirmarEliminar(false);
setMostrarModal(true);
return;
}
if (alumnosInscritos && alumnosInscritos.length > 0) {
setModalMensaje("No se puede eliminar el curso porque hay alumnos inscritos a este curso.");
setConfirmarEliminar(false);
setMostrarModal(true);
return;
}
// 1. Elimina relaciones en la tabla pivote
await supabaseClient
.from("curso_competencia")
.delete()
.eq("curso_id", cursoAEliminar);
// 2. Elimina el curso
const { error } = await supabaseClient
.from("curso")
.delete()
.eq("id", cursoAEliminar);
if (error) {
setModalMensaje("Error al eliminar el curso");
} else {
setModalMensaje("Curso eliminado exitosamente");
await cargarCursos();
}
setConfirmarEliminar(false);
setMostrarModal(true);
};
// Dialog para quitar competencia
const pedirConfirmacionQuitarComp = (idx) => {
setCompAEliminar(idx);
setDialogQuitarComp(true);
};
const quitarCompetencia = () => {
setCompetenciasGuardadas(competenciasGuardadas.filter((_, i) => i !== compAEliminar));
setDialogQuitarComp(false);
setCompAEliminar(null);
};
return (
<Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Lista de Cursos</h1>
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Descripción</th>
<th className="py-2 border-b">Horas</th>
<th className="py-2 border-b">Competencias</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{cursos.map((curso) =>
cursoEditando === curso.id ? (
<tr key={curso.id}>
<td className="py-2 px-4 border-b text-center">{curso.id}</td>
<td className="py-2 px-4 border-b">
<Input
value={nuevoNombre}
onChange={(e) => setNuevoNombre(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<Input
value={nuevaDescripcion}
onChange={(e) => setNuevaDescripcion(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<Input
type="number"
value={nuevaHoras}
onChange={(e) => setNuevaHoras(e.target.value)}
/>
</td>
<td className="py-2 px-4 border-b">
<div className="flex flex-col gap-2">
{competenciasGuardadas.map((comp, idx) => (
<div key={idx} className="flex items-center gap-2">
<select
className="border rounded px-2 py-1"
value={comp?.id || ""}
onChange={e => {
const nuevaLista = [...competenciasGuardadas];
const nuevaComp = todasCompetencias.find(c => c.id === Number(e.target.value));
nuevaLista[idx] = nuevaComp;
setCompetenciasGuardadas(nuevaLista);
}}
>
<option value="">Selecciona competencia</option>
{todasCompetencias.map(tc => (
<option
key={tc.id}
value={tc.id}
disabled={
// Deshabilita si ya está seleccionada en otro select
competenciasGuardadas.some(
(c, i) => c && c.id === tc.id && i !== idx
)
}
>
{tc.descripcion}
</option>
))}
</select>
<Button
type="button"
className="bg-red-500 hover:bg-red-700 text-white px-2 py-1 rounded"
onClick={() => pedirConfirmacionQuitarComp(idx)}
>
Quitar
</Button>
</div>
))}
<Button
type="button"
className="bg-blue-500 hover:bg-blue-700 text-white px-2 py-1 rounded mt-2"
onClick={() => setCompetenciasGuardadas([...competenciasGuardadas, null])}
disabled={competenciasGuardadas.length >= todasCompetencias.length}
>
Agregar competencia
</Button>
</div>
</td>
<td className="py-2 px-4 border-b flex justify-center">
<Button
className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-2 rounded"
onClick={() => guardarEdicion(curso.id)}
>
Guardar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded"
onClick={cancelarEdicion}
>
Cancelar
</Button>
</td>
</tr>
) : (
<tr key={curso.id}>
<td className="py-2 px-4 border-b">{curso.id}</td>
<td className="py-2 px-4 border-b">{curso.nombre}</td>
<td className="py-2 px-4 border-b">{curso.descripcion}</td>
<td className="py-2 px-4 border-b">{curso.horas}</td>
<td className="py-2 px-4 border-b">
{Array.isArray(curso.competencias) && curso.competencias.length > 0
? (
<ul className="list-disc pl-4">
{curso.competencias.map((comp) => (
<li key={comp.id}>{comp.descripcion}</li>
))}
</ul>
)
: "Sin competencias"}
</td>
<td className="py-2 px-4 border-b space-x-2">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => iniciarEdicion(curso)}
>
Editar
</Button>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded"
onClick={() => confirmarEliminacion(curso.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
{/* Dialog para eliminar curso */}
<Dialog open={confirmarEliminar} onOpenChange={setConfirmarEliminar}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Confirmar eliminación
</DialogTitle>
<DialogDescription>
¿Estás seguro de que deseas eliminar este curso? Esta acción no se
puede deshacer.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={eliminarCurso}
>
Eliminar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setConfirmarEliminar(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Dialog para eliminar competencia */}
<Dialog open={dialogQuitarComp} onOpenChange={setDialogQuitarComp}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Quitar competencia
</DialogTitle>
<DialogDescription>
¿Estás seguro de que deseas quitar esta competencia del curso?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={quitarCompetencia}
>
Quitar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setDialogQuitarComp(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de resultado */}
<Dialog open={mostrarModal} onOpenChange={setMostrarModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Resultado de la operación
</DialogTitle>
<DialogDescription>{modalMensaje}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarModal(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,123 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase";
import { Button } from "@/components/ui/button";
import CrearDiplomaDialog from "@/components/dialogs/crearDiplomaDialog";
import VistaPreviaDiplomaDialog from "@/components/dialogs/vistaPreviaDiplomaDialog";
export default function DiplomasVista() {
const [alumnos, setAlumnos] = useState([]);
const [alumnoSeleccionado, setAlumnoSeleccionado] = useState(null);
const [mostrarDialog, setMostrarDialog] = useState(false);
// Estado compartido para los datos del diploma
const [competencias, setCompetencias] = useState([]);
const [competenciasAcreditadas, setCompetenciasAcreditadas] = useState([]);
const [fecha, setFecha] = useState("");
useEffect(() => {
const cargarAlumnos = async () => {
const { data, error } = await supabaseClient
.from("alumno")
.select("id, nombre, correo, telefono, curso(id, nombre)")
.order("id", { ascending: true });
if (!error) setAlumnos(data);
};
cargarAlumnos();
}, []);
// Limpiar datos al cerrar dialog
const handleCloseDialog = (open) => {
setMostrarDialog(open);
if (!open) {
setAlumnoSeleccionado(null);
setCompetencias([]);
setCompetenciasAcreditadas([]);
setFecha("");
}
};
useEffect(() => {
if (alumnoSeleccionado && alumnoSeleccionado.curso?.id) {
supabaseClient
.from("curso_competencia")
.select("competencia(id, descripcion)")
.eq("curso_id", alumnoSeleccionado.curso.id)
.then(({ data }) => {
const comps = data?.map((c) => c.competencia).filter(Boolean) || [];
setCompetencias(comps);
setCompetenciasAcreditadas(comps.map((c) => c.id)); // Opcional: selecciona todas por default
});
}
}, [alumnoSeleccionado]);
return (
<Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Vista de Diplomas</h1>
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{alumnos.map((alumno) => (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">
{alumno.curso?.nombre || "Sin curso"}
</td>
<td className="py-2 px-4 border-b">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => {
setAlumnoSeleccionado(alumno);
setMostrarDialog(true);
}}
>
Crear Diploma
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Dialog para crear diploma y vista previa juntos */}
{mostrarDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="flex gap-8 bg-black bg-opacity-30 p-8 rounded">
{/*<CrearDiplomaDialog
open={mostrarDialog}
onOpenChange={handleCloseDialog}
alumno={alumnoSeleccionado}
competencias={competencias}
setCompetencias={setCompetencias}
competenciasAcreditadas={competenciasAcreditadas}
setCompetenciasAcreditadas={setCompetenciasAcreditadas}
fecha={fecha}
setFecha={setFecha}
/>*/}
<VistaPreviaDiplomaDialog
open={mostrarDialog}
onOpenChange={handleCloseDialog}
alumno={alumnoSeleccionado}
competencias={competencias}
fecha={fecha}
competenciasAcreditadas={competenciasAcreditadas}
/>
</div>
</div>
)}
</Layout>
);
}

View File

@ -1,37 +1,11 @@
import Link from "next/link";
import Layout from "@/components/layout/Layout";
export default function Home() {
return (
<div className="flex flex-col items-center justify-center w-full h-screen text-black">
<h1 className="text-3xl font-bold mb-4">¿Qué quieres hacer?</h1>
<div className="flex">
<Link href="/Alumnos">
<div className="flex flex-col items-center">
<div
style={{
backgroundImage: "url('/alumnos.jpg')",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
}}
className="h-60 w-96 border rounded-3 m-5"
></div>
<h1>Dar de Alumnos</h1>
</div>
</Link>
<Link href="/Cursos">
<div className="flex flex-col items-center">
<div
style={{
backgroundImage: "url('/cursos.jpg')",
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
}}
className="h-60 w-96 border rounded-3 m-5"
></div>
<h1>Dar de Cursos</h1>
</div>
</Link>
<Layout>
<div className="w-[70vw] h-[90vh] flex flex-col items-center justify-center">
<h1 className="text-3xl font-bold text-black">¡Bienvenido a SIDAC!</h1>
</div>
</div>
</Layout>
);
}
}

View File

@ -0,0 +1,14 @@
import { z } from "zod";
export const alumnoSchema = z.object({
nombre: z
.string()
.nonempty("Escribe el nombre")
.regex(/^[\p{L}\s]+$/u, "Solo se permiten letras en el nombre"),
correo: z.string().email("Escribe un correo válido"),
telefono: z
.string()
.nonempty("Escribe el número de teléfono")
.regex(/^\d+$/, "Solo se permiten números en el teléfono"),
cursoSeleccionado: z.string().nonempty("Selecciona un curso"),
});

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const cursosSchema = z.object({
nombre: z
.string()
.nonempty("Escribe el nombre del curso")
.regex(/^[\p{L}\s]+$/u, "Solo se permiten letras en el nombre"),
descripcion: z.string().nonempty("Escribe una descripción"),
horas: z.number().positive("Las horas deben ser un número positivo").int(),
nuevaCompetencia: z.string().optional(),
});

View File

View File

@ -0,0 +1,33 @@
import {
createServerClient,
serializeCookieHeader,
createBrowserClient,
} from "@supabase/ssr";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
export const supabaseClient = createBrowserClient(supabaseUrl, supabaseKey);
export function createClient({ req, res }) {
const supabase = createServerClient(supabaseUrl, supabaseKey, {
cookies: {
getAll() {
return Object.keys(req.cookies).map((name) => ({
name,
value: req.cookies[name] || "",
}));
},
setAll(cookiesToSet) {
res.setHeader(
"Set-Cookie",
cookiesToSet.map(({ name, value, options }) =>
serializeCookieHeader(name, value, options)
)
);
},
},
});
return supabase;
}

459
package-lock.json generated
View File

@ -2,5 +2,462 @@
"name": "SIDAC",
"lockfileVersion": 3,
"requires": true,
"packages": {}
"packages": {
"": {
"dependencies": {
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
"supabase": "^2.22.6"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.69.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.69.1.tgz",
"integrity": "sha512-FILtt5WjCNzmReeRLq5wRs3iShwmnWgBvxHfqapC/VoljJl+W8hDAyFmf1NVw3zH+ZjZ05AKxiKxVeb0HNWRMQ==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.4.tgz",
"integrity": "sha512-WL2p6r4AXNGwop7iwvul2BvOtuJ1YQy8EbOd0dhG1oN1q8el/BIRSFCFnWAMM/vJJlHWLi4ad22sKbKr9mvjoA==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.19.4",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.19.4.tgz",
"integrity": "sha512-O4soKqKtZIW3olqmbXXbKugUtByD2jPa8kL2m2c1oozAO11uCcGrRhkZL0kVxjBLrXHE0mdSkFsMj7jDSfyNpw==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.11.2.tgz",
"integrity": "sha512-u/XeuL2Y0QEhXSoIPZZwR6wMXgB+RQbJzG9VErA3VghVt7uRfSVsjeqd7m5GhX3JR6dM/WRmLbVR8URpDWG4+w==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14",
"@types/phoenix": "^1.5.4",
"@types/ws": "^8.5.10",
"ws": "^8.18.0"
}
},
"node_modules/@supabase/ssr": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.6.1.tgz",
"integrity": "sha512-QtQgEMvaDzr77Mk3vZ3jWg2/y+D8tExYF7vcJT+wQ8ysuvOeGGjYbZlvj5bHYsj/SpC0bihcisnwPrM4Gp5G4g==",
"dependencies": {
"cookie": "^1.0.1"
},
"peerDependencies": {
"@supabase/supabase-js": "^2.43.4"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.7.1.tgz",
"integrity": "sha512-asYHcyDR1fKqrMpytAS1zjyEfvxuOIp1CIXX7ji4lHHcJKqyk+sLl/Vxgm4sN6u8zvuUtae9e4kDxQP2qrwWBA==",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.49.4",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.49.4.tgz",
"integrity": "sha512-jUF0uRUmS8BKt37t01qaZ88H9yV1mbGYnqLeuFWLcdV+x1P4fl0yP9DGtaEhFPZcwSom7u16GkLEH9QJZOqOkw==",
"dependencies": {
"@supabase/auth-js": "2.69.1",
"@supabase/functions-js": "2.4.4",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.19.4",
"@supabase/realtime-js": "2.11.2",
"@supabase/storage-js": "2.7.1"
}
},
"node_modules/@types/node": {
"version": "22.14.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A=="
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"engines": {
"node": ">= 14"
}
},
"node_modules/bin-links": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/bin-links/-/bin-links-5.0.0.tgz",
"integrity": "sha512-sdleLVfCjBtgO5cNjA2HVRvWBJAHs4zwenaCPMNJAJU0yNxpzj80IpjOIimkpkr+mhlA+how5poQtt53PygbHA==",
"dependencies": {
"cmd-shim": "^7.0.0",
"npm-normalize-package-bin": "^4.0.0",
"proc-log": "^5.0.0",
"read-cmd-shim": "^5.0.0",
"write-file-atomic": "^6.0.0"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"engines": {
"node": ">=18"
}
},
"node_modules/cmd-shim": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-7.0.0.tgz",
"integrity": "sha512-rtpaCbr164TPPh+zFdkWpCyZuKkjpAzODfaZCf/SVJZzJN+4bHQb/LP3Jzq5/+84um3XXY8r548XiWKSborwVw==",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"engines": {
"node": ">=18"
}
},
"node_modules/data-uri-to-buffer": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
"integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
"engines": {
"node": ">= 12"
}
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
"integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "paypal",
"url": "https://paypal.me/jimmywarting"
}
],
"dependencies": {
"node-domexception": "^1.0.0",
"web-streams-polyfill": "^3.0.3"
},
"engines": {
"node": "^12.20 || >= 14.13"
}
},
"node_modules/formdata-polyfill": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
"integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
"dependencies": {
"fetch-blob": "^3.1.2"
},
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/imurmurhash": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"engines": {
"node": ">=0.8.19"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
"dependencies": {
"data-uri-to-buffer": "^4.0.0",
"fetch-blob": "^3.1.4",
"formdata-polyfill": "^4.0.10"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/node-fetch"
}
},
"node_modules/npm-normalize-package-bin": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz",
"integrity": "sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/proc-log": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz",
"integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/read-cmd-shim": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-5.0.0.tgz",
"integrity": "sha512-SEbJV7tohp3DAAILbEMPXavBjAnMN0tVnh4+9G8ihV4Pq3HYF9h8QNez9zkJ1ILkv9G2BjdzwctznGZXgu/HGw==",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/supabase": {
"version": "2.22.6",
"resolved": "https://registry.npmjs.org/supabase/-/supabase-2.22.6.tgz",
"integrity": "sha512-W/A5JlKqrp0pxSaFRYwlWpk+aCql9xUp1ZeLVarRVtqsT6QPLqLsLPIwES0005lQKS3QBbdWDgYThEStPM1kxQ==",
"hasInstallScript": true,
"dependencies": {
"bin-links": "^5.0.0",
"https-proxy-agent": "^7.0.2",
"node-fetch": "^3.3.2",
"tar": "7.4.3"
},
"bin": {
"supabase": "bin/supabase"
},
"engines": {
"npm": ">=8"
}
},
"node_modules/tar": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
"integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
"engines": {
"node": ">= 8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/write-file-atomic": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-6.0.0.tgz",
"integrity": "sha512-GmqrO8WJ1NuzJ2DrziEI2o57jKAVIQNf8a18W3nCYU3H7PNWqCCVTeH6/NQE93CIllIgQS98rrmVkYgTX9fFJQ==",
"dependencies": {
"imurmurhash": "^0.1.4",
"signal-exit": "^4.0.1"
},
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"engines": {
"node": ">=18"
}
}
}
}

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"dependencies": {
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.49.4",
"supabase": "^2.22.6"
}
}