Merge pull request 'roberto' (#1) from roberto into main
Reviewed-on: #1
This commit is contained in:
commit
6c5a04aec4
|
@ -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
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
}
|
|
@ -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 }
|
|
@ -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,
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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 }
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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 });
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
Sí, continuar
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-gray-400 hover:bg-gray-600 text-white"
|
||||
onClick={() => setDialogoAdvertencia(false)}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Layout>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
});
|
|
@ -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(),
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"@supabase/supabase-js": "^2.49.4",
|
||||
"supabase": "^2.22.6"
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue