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

Reviewed-on: #2
This commit is contained in:
roberto.viveros 2025-06-04 06:10:21 +00:00
commit 1963d4d8d4
27 changed files with 2680 additions and 768 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -1,10 +1,29 @@
import React from "react"; import React from "react";
import { Document, Page, Text, View, StyleSheet } from "@react-pdf/renderer"; import {
Document,
Page,
Text,
View,
StyleSheet,
Image,
} from "@react-pdf/renderer";
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { padding: 40, fontFamily: "Helvetica" }, page: { fontFamily: "Helvetica" },
title: { fontSize: 24, textAlign: "center", marginBottom: 20 }, title: { fontSize: 24, textAlign: "center", marginBottom: 20 },
section: { marginBottom: 10, fontSize: 14 }, nombre: {
fontSize: 18,
textAlign: "center",
marginBottom: 10,
fontStyle: "italic",
},
curso: {
fontSize: 30,
textAlign: "center",
marginBottom: 10,
fontWeight: "bold",
},
section: { padding: 40, fontSize: 14 },
competencias: { marginLeft: 20, marginTop: 5 }, competencias: { marginLeft: 20, marginTop: 5 },
competencia: { fontSize: 12, marginBottom: 2 }, competencia: { fontSize: 12, marginBottom: 2 },
footer: { footer: {
@ -20,36 +39,21 @@ export default function Diploma({ alumno, curso, competencias = [], fecha }) {
return ( return (
<Document> <Document>
<Page size="A4" style={styles.page}> <Page size="A4" style={styles.page}>
<Text style={styles.title}>Diploma</Text> <Image src="/encabezado.png" />
<View style={styles.section}> <Text style={styles.title}>Otorga la presente</Text>
<Text> <Text style={styles.title}>CONSTANCIA</Text>
<Text style={{ fontWeight: "bold" }}>Alumno: </Text> <Text style={styles.title}>a: </Text>
{alumno?.nombre} <Text style={styles.nombre}>{alumno?.nombre} </Text>
</Text> <Text style={styles.title}>
</View> Por su asistencia a la píldora educativa
<View style={styles.section}> </Text>
<Text> <Text style={styles.curso}>{curso?.nombre || "Sin curso"}</Text>
<Text style={{ fontWeight: "bold" }}>Curso: </Text> <Text style={styles.title}>
{curso?.nombre || "Sin curso"} con duración de 2 horas, modalidad remota
</Text> </Text>
</View> <Text style={styles.title}>
<View style={styles.section}> Se expide en la ciudad de Xalapa, Ver., {fecha}
<Text style={{ fontWeight: "bold" }}>Competencias Acreditadas:</Text> </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> </Page>
</Document> </Document>
); );

View File

@ -36,6 +36,15 @@ const data = {
}, },
], ],
}, },
/*{
title: "Vista general",
items: [
{
title: "Vista general",
url: "/vistaGeneral",
},
],
},*/
{ {
title: "Cursos", title: "Cursos",
items: [ items: [
@ -53,6 +62,40 @@ const data = {
}, },
], ],
}, },
{
title: "Inyecciones",
items: [
{
title: "Vista de inyecciones",
url: "/inyeccionesVista",
},
{
title: "Agregar manualmente",
url: "/inyeccionesManual",
},
{
title: "Agregar desde archivo",
url: "/inyeccionesArchivo",
},
],
},
{
title: "Pildoras",
items: [
{
title: "Vista de pildoras",
url: "/pildorasVista",
},
{
title: "Agregar manualmente",
url: "/pildorasManual",
},
{
title: "Agregar desde archivo",
url: "/pildorasArchivo",
},
],
},
{ {
title: "Diplomas", title: "Diplomas",
items: [ items: [

View File

@ -0,0 +1,290 @@
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { cursosSchema } from "@/schemas/CursosSchema";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { supabaseClient } from "@/utils/supabase";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
export function CursosManualForm({ nombreSugerido = "" }) {
const [addCompetencia, setAddCompetencia] = useState(false);
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]);
const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
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;
const handleSaveCompetencia = async (e) => {
e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia").trim();
if (!nuevaCompetencia) return;
if (competenciasGuardadas.some((c) => c.descripcion === nuevaCompetencia)) {
alert("La competencia ya fue agregada.");
return;
}
let competenciaId = null;
try {
const { data: existente } = await supabaseClient
.from("competencia")
.select("id")
.eq("descripcion", nuevaCompetencia)
.maybeSingle();
if (existente && existente.id) {
competenciaId = existente.id;
} else {
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);
} catch (err) {
alert("Error al guardar la competencia: " + (err.message || err));
}
};
const handleDeleteCompetencia = (index) => {
setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== index)
);
};
const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10);
setLoading(true);
try {
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;
}
const cursoId = cursoInsertado.id;
const relaciones = competenciasGuardadas.map((c) => ({
curso_id: cursoId,
competencia_id: c.id,
}));
if (relaciones.length > 0) {
const { error: errorPivote } = await supabaseClient
.from("curso_competencia")
.insert(relaciones);
if (errorPivote) {
setMensajeDialog(
"Error al asociar competencias: " + errorPivote.message
);
setMostrarDialog(true);
setLoading(false);
return;
}
}
setMensajeDialog("Curso guardado exitosamente");
setMostrarDialog(true);
form.reset();
setCompetenciasGuardadas([]);
} catch (err) {
alert("Ocurrió un error inesperado");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<Input
type="text"
placeholder="Nombre del curso"
{...register("nombre")}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.nombre && (
<p className="text-red-500 text-sm">{errors.nombre.message}</p>
)}
<Textarea
placeholder="Descripción"
{...register("descripcion")}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 h-24 text-black"
/>
{errors.descripcion && (
<p className="text-red-500 text-sm">{errors.descripcion.message}</p>
)}
<Input
type="number"
placeholder="Horas del curso"
{...register("horas", { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.horas && (
<p className="text-red-500 text-sm">{errors.horas.message}</p>
)}
<h2 className="text-lg font-semibold mb-3 text-black">Competencias</h2>
<p className="text-xs text-gray-500 mb-2">
Puedes agregar competencias nuevas sin necesidad de crear un nuevo
curso. Las competencias se guardarán y podrás asociarlas a otros cursos
después.
</p>
{competenciasGuardadas.length > 0 && (
<div className="mt-5 w-full flex-wrap">
{competenciasGuardadas.map((competencia, index) => (
<div
key={index}
className="w-full flex justify-between items-center px-2 mb-2"
>
<span className="text-black">{competencia.descripcion}</span>
<Button
type="button"
onClick={() => handleDeleteCompetencia(index)}
className="bg-red-400 hover:bg-red-500 text-white font-bold py-1 px-3 rounded-md"
>
X
</Button>
</div>
))}
</div>
)}
{addCompetencia && (
<div className="w-full flex flex-col md:flex-row mt-5">
<div className="flex flex-col">
<Input
type="text"
placeholder="Nueva competencia"
{...register("nuevaCompetencia")}
className="w-80 px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/>
{errors.nuevaCompetencia && (
<p className="text-red-500 text-sm">
{errors.nuevaCompetencia.message}
</p>
)}
</div>
<div className="flex flex-row">
<Button
type="button"
onClick={handleSaveCompetencia}
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md mr-2"
>
Guardar
</Button>
<Button
type="button"
onClick={() => {
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
}}
className="bg-gray-400 hover:bg-gray-500 text-white font-bold py-2 px-4 rounded-md"
>
Cancelar
</Button>
</div>
</div>
)}
<Button
type="button"
onClick={() => setAddCompetencia(true)}
className="w-full bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded-md mt-5"
>
Agregar competencia
</Button>
<div className="flex justify-center w-full mt-5">
<Button
type="submit"
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md"
disabled={loading}
>
{loading ? "Guardando..." : "Guardar curso"}
</Button>
</div>
<Dialog open={mostrarDialog} onOpenChange={setMostrarDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Resultado</DialogTitle>
<DialogDescription>{mensajeDialog}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialog(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={mostrarDialogCompetencia}
onOpenChange={setMostrarDialogCompetencia}
>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Competencia agregada
</DialogTitle>
<DialogDescription>
¡La competencia fue agregada exitosamente!
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialogCompetencia(false)}>
Cerrar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
);
}

View File

@ -8,11 +8,16 @@ import {
import Diploma from "@/components/Diploma"; import Diploma from "@/components/Diploma";
import { PDFDownloadLink, PDFViewer, pdf } from "@react-pdf/renderer"; import { PDFDownloadLink, PDFViewer, pdf } from "@react-pdf/renderer";
import { supabaseClient } from "@/utils/supabase"; import { supabaseClient } from "@/utils/supabase";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { mensajesSchema } from "@/schemas/mensajesSchema";
import { Textarea } from "../ui/textarea";
function VistaPreviaDiplomaDialog({ function VistaPreviaDiplomaDialog({
open, open,
onOpenChange, onOpenChange,
alumno, alumno,
curso,
competencias: competenciasProp, competencias: competenciasProp,
fecha, fecha,
competenciasAcreditadas, competenciasAcreditadas,
@ -20,8 +25,77 @@ function VistaPreviaDiplomaDialog({
const [mostrarVistaPrevia, setMostrarVistaPrevia] = useState(false); const [mostrarVistaPrevia, setMostrarVistaPrevia] = useState(false);
const [enviando, setEnviando] = useState(false); const [enviando, setEnviando] = useState(false);
const [mensaje, setMensaje] = useState(""); const [mensaje, setMensaje] = useState("");
const [loadingMensajes, setLoadingMensajes] = useState(false);
const [competencias, setCompetencias] = useState([]); const [competencias, setCompetencias] = useState([]);
const form = useForm({
resolver: zodResolver(mensajesSchema),
defaultValues: {
correo: "",
whatsapp: "",
},
});
const {
register,
handleSubmit,
setValue,
getValues,
formState: { errors },
} = form;
// 🔄 Cargar mensajes al abrir el modal
useEffect(() => {
if (open) {
setLoadingMensajes(true);
Promise.all([
supabaseClient.from("mensaje_correo").select("id, mensaje").single(),
supabaseClient.from("mensaje_whatsapp").select("id, mensaje").single(),
])
.then(([correoRes, whatsappRes]) => {
if (correoRes.data) setValue("correo", correoRes.data.mensaje);
if (whatsappRes.data) setValue("whatsapp", whatsappRes.data.mensaje);
})
.finally(() => setLoadingMensajes(false));
}
}, [open, setValue]);
// 📝 Guardar mensajes personalizados
const handleGuardarMensajes = async () => {
const { correo, whatsapp } = getValues();
const [{ data: correoExistente }] = await Promise.all([
supabaseClient.from("mensaje_correo").select("id").maybeSingle(),
]);
if (correoExistente) {
await supabaseClient
.from("mensaje_correo")
.update({ mensaje: correo })
.eq("id", correoExistente.id);
} else {
await supabaseClient.from("mensaje_correo").insert({ mensaje: correo });
}
const { data: whatsappExistente } = await supabaseClient
.from("mensaje_whatsapp")
.select("id")
.maybeSingle();
if (whatsappExistente) {
await supabaseClient
.from("mensaje_whatsapp")
.update({ mensaje: whatsapp })
.eq("id", whatsappExistente.id);
} else {
await supabaseClient
.from("mensaje_whatsapp")
.insert({ mensaje: whatsapp });
}
setMensaje("Mensajes guardados correctamente.");
};
useEffect(() => { useEffect(() => {
if (alumno && alumno.curso?.id) { if (alumno && alumno.curso?.id) {
supabaseClient supabaseClient
@ -41,28 +115,25 @@ function VistaPreviaDiplomaDialog({
? competencias.filter((comp) => competenciasAcreditadas.includes(comp.id)) ? competencias.filter((comp) => competenciasAcreditadas.includes(comp.id))
: competencias; : competencias;
// Simulación de envío de PDF por correo y WhatsApp
const handleEnviar = async () => { const handleEnviar = async () => {
setEnviando(true); setEnviando(true);
setMensaje(""); setMensaje("");
// Genera el PDF como blob
const blob = await pdf( const blob = await pdf(
<Diploma <Diploma
alumno={alumno} alumno={alumno}
curso={alumno.curso} curso={curso}
competencias={competenciasMostradas} competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()} fecha={fecha || new Date().toLocaleDateString()}
/> />
).toBlob(); ).toBlob();
// Convierte el blob a base64
const pdfBase64 = await new Promise((resolve) => { const pdfBase64 = await new Promise((resolve) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(",")[1]); reader.onloadend = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(blob); reader.readAsDataURL(blob);
}); });
// Llama a tu API de Next.js
const resp = await fetch("/api/send-diploma", { const resp = await fetch("/api/send-diploma", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -71,32 +142,30 @@ function VistaPreviaDiplomaDialog({
nombre: alumno.nombre, nombre: alumno.nombre,
curso: alumno.curso?.nombre || "Sin curso", curso: alumno.curso?.nombre || "Sin curso",
pdfBase64, pdfBase64,
mensajeCorreo: getValues("correo"),
}), }),
}); });
if (resp.ok) { if (resp.ok) {
// WhatsApp real (abre ventana)
const telefono = alumno.telefono.replace(/\D/g, ""); const telefono = alumno.telefono.replace(/\D/g, "");
const mensajeWhatsapp = encodeURIComponent( const mensajeWhatsapp = encodeURIComponent(getValues("whatsapp"));
`Hola ${alumno.nombre}, tu diploma ha sido generado y enviado a tu correo (${alumno.correo}). ¡Felicidades!`
);
window.open( window.open(
`https://wa.me/${telefono}?text=${mensajeWhatsapp}`, `https://wa.me/${telefono}?text=${mensajeWhatsapp}`,
"_blank" "_blank"
); );
setMensaje(`Diploma enviado por correo a ${alumno.correo}.`);
setMensaje(
`Diploma enviado por correo a ${alumno.correo} y mensaje enviado por WhatsApp al ${alumno.telefono}.`
);
} else { } else {
setMensaje("Error enviando el diploma por correo."); setMensaje("Error enviando el diploma.");
} }
setEnviando(false); setEnviando(false);
}; };
if (!alumno) return null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg text-black"> <DialogContent className="max-w-lg h-screen text-black overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Diploma</DialogTitle> <DialogTitle>Diploma</DialogTitle>
</DialogHeader> </DialogHeader>
@ -117,15 +186,35 @@ function VistaPreviaDiplomaDialog({
<div className="text-lg mb-2"> <div className="text-lg mb-2">
<b>Fecha:</b> {fecha || new Date().toLocaleDateString()} <b>Fecha:</b> {fecha || new Date().toLocaleDateString()}
</div> </div>
<div className="mt-auto text-gray-400 text-xs text-right"> {/* Campos de mensaje */}
Vista previa <form>
</div> <div className="mb-4">
<div className="mt-4 flex gap-2 justify-center flex-wrap"> <Textarea
label="Mensaje para correo"
placeholder="Escribe un mensaje personalizado para el correo"
{...register("correo")}
error={errors.correo?.message}
disabled={loadingMensajes}
/>
</div>
<div className="mb-4">
<Textarea
label="Mensaje para WhatsApp"
placeholder="Escribe un mensaje personalizado para WhatsApp"
{...register("whatsapp")}
error={errors.whatsapp?.message}
disabled={loadingMensajes}
/>
</div>
</form>
{/* Acciones */}
<div className="mt-4 flex flex-wrap gap-2 justify-center">
<PDFDownloadLink <PDFDownloadLink
document={ document={
<Diploma <Diploma
alumno={alumno} alumno={alumno}
curso={alumno.curso} curso={curso}
competencias={competenciasMostradas} competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()} fecha={fecha || new Date().toLocaleDateString()}
/> />
@ -144,12 +233,14 @@ function VistaPreviaDiplomaDialog({
) )
} }
</PDFDownloadLink> </PDFDownloadLink>
<button <button
className="bg-blue-500 hover:bg-blue-700 text-white px-4 py-2 rounded" className="bg-blue-500 hover:bg-blue-700 text-white px-4 py-2 rounded"
onClick={() => setMostrarVistaPrevia(true)} onClick={() => setMostrarVistaPrevia(true)}
> >
Ver vista previa PDF Ver vista previa PDF
</button> </button>
<button <button
className="bg-purple-600 hover:bg-purple-800 text-white px-4 py-2 rounded" className="bg-purple-600 hover:bg-purple-800 text-white px-4 py-2 rounded"
onClick={handleEnviar} onClick={handleEnviar}
@ -157,20 +248,31 @@ function VistaPreviaDiplomaDialog({
> >
{enviando ? "Enviando..." : "Enviar por correo y WhatsApp"} {enviando ? "Enviando..." : "Enviar por correo y WhatsApp"}
</button> </button>
<button
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded"
onClick={handleGuardarMensajes}
type="button"
>
Guardar mensajes
</button>
</div> </div>
{mensaje && ( {mensaje && (
<div className="mt-4 text-green-700 font-semibold text-center"> <div className="mt-4 text-green-700 font-semibold text-center">
{mensaje} {mensaje}
</div> </div>
)} )}
{/* Vista previa PDF */}
{mostrarVistaPrevia && ( {mostrarVistaPrevia && (
<div className="fixed inset-0 bg-black bg-opacity-60 flex flex-col items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50">
<div className="bg-white rounded shadow-lg p-4 flex flex-col items-center"> <div className="bg-white rounded shadow-lg p-4">
<div className="w-[80vw] h-[80vh] lg:h-[90vh] mb-4 border"> <div className="w-[80vw] h-[90vh] mb-4 border">
<PDFViewer width="100%" height="100%"> <PDFViewer width="100%" height="100%">
<Diploma <Diploma
alumno={alumno} alumno={alumno}
curso={alumno.curso} curso={curso}
competencias={competenciasMostradas} competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()} fecha={fecha || new Date().toLocaleDateString()}
/> />

View File

@ -1,7 +1,26 @@
"use client"; "use client";
import { AppSidebar } from "@/components/app-sidebar"; import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
function MainContent({ children }) {
const { open } = useSidebar();
return (
<div
className={`p-4 min-h-screen flex justify-center transition-all duration-200 ${
open ? "w-screen md:w-[80vw]" : "w-screen"
}`}
>
{children}
</div>
);
}
export default function Layout({ children }) { export default function Layout({ children }) {
return ( return (
@ -9,7 +28,8 @@ export default function Layout({ children }) {
<div className="flex"> <div className="flex">
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset>
<div className="p-4 w-full">{children}</div> <SidebarTrigger className="-ml-1 text-black" />
<MainContent>{children}</MainContent>
</SidebarInset> </SidebarInset>
</div> </div>
</SidebarProvider> </SidebarProvider>

View File

@ -238,7 +238,7 @@ function SidebarTrigger({ className, onClick, ...props }) {
data-slot="sidebar-trigger" data-slot="sidebar-trigger"
variant="ghost" variant="ghost"
size="icon" size="icon"
className={cn("size-7", className)} className={cn("size-10", className)}
onClick={(event) => { onClick={(event) => {
onClick?.(event); onClick?.(event);
toggleSidebar(); toggleSidebar();

View File

@ -3,9 +3,18 @@ import Papa from "papaparse";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout"; import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import {
import { CursosManualForm } from "./cursosManual"; // Importa el formulario sin Layout Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { CursosManualForm } from "@/components/cursosManualForm";
import { supabaseClient } from "@/utils/supabase"; import { supabaseClient } from "@/utils/supabase";
import { useRouter } from "next/router";
export default function AlumnosArchivo() { export default function AlumnosArchivo() {
const [archivo, setArchivo] = useState(null); const [archivo, setArchivo] = useState(null);
@ -14,11 +23,32 @@ export default function AlumnosArchivo() {
const [mensajeDialogo, setMensajeDialogo] = useState(""); const [mensajeDialogo, setMensajeDialogo] = useState("");
const [mostrarDialogCurso, setMostrarDialogCurso] = useState(false); const [mostrarDialogCurso, setMostrarDialogCurso] = useState(false);
const [cursoFaltante, setCursoFaltante] = useState(""); const [cursoFaltante, setCursoFaltante] = useState("");
const [dialogoAdvertencia, setDialogoAdvertencia] = useState(false);
const [rutaPendiente, setRutaPendiente] = useState(null);
const router = useRouter();
useEffect(() => { useEffect(() => {
if (archivo) extraerContenido(); if (archivo) extraerContenido();
// eslint-disable-next-line
}, [archivo]); }, [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 registrarAlumnos = async () => { const registrarAlumnos = async () => {
if (datos.length === 0) return; if (datos.length === 0) return;
@ -26,11 +56,12 @@ export default function AlumnosArchivo() {
for (const alumno of datos) { for (const alumno of datos) {
// 1. Verifica si el curso existe // 1. Verifica si el curso existe
const { data: cursosEncontrados, error: errorCurso } = await supabaseClient const { data: cursosEncontrados, error: errorCurso } =
.from("curso") await supabaseClient
.select("id") .from("curso")
.eq("nombre", alumno.nombreCurso) .select("id")
.maybeSingle(); .eq("nombre", alumno.nombreCurso)
.maybeSingle();
if (errorCurso) { if (errorCurso) {
errores.push({ alumno, error: "Error al buscar el curso" }); errores.push({ alumno, error: "Error al buscar el curso" });
@ -41,7 +72,9 @@ export default function AlumnosArchivo() {
// Si no existe el curso, muestra el dialog para registrar el curso // Si no existe el curso, muestra el dialog para registrar el curso
setCursoFaltante(alumno.nombreCurso); setCursoFaltante(alumno.nombreCurso);
setMostrarDialogCurso(true); setMostrarDialogCurso(true);
setMensajeDialogo(`El curso "${alumno.nombreCurso}" no existe. Por favor, regístralo primero.`); setMensajeDialogo(
`El curso "${alumno.nombreCurso}" no existe. Por favor, regístralo primero.`
);
setDialogoAbierto(true); setDialogoAbierto(true);
return; // Detiene el registro de alumnos return; // Detiene el registro de alumnos
} }
@ -54,7 +87,7 @@ export default function AlumnosArchivo() {
nombre: alumno.nombre, nombre: alumno.nombre,
correo: alumno.correo, correo: alumno.correo,
telefono: alumno.telefono, telefono: alumno.telefono,
curso_id: cursosEncontrados.id, // Usar el id del curso curso_id: cursosEncontrados.id,
}), }),
}); });
@ -65,7 +98,9 @@ export default function AlumnosArchivo() {
} }
if (errores.length > 0) { if (errores.length > 0) {
setMensajeDialogo(`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`); setMensajeDialogo(
`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`
);
} else { } else {
setMensajeDialogo("Todos los alumnos fueron registrados correctamente."); setMensajeDialogo("Todos los alumnos fueron registrados correctamente.");
setArchivo(null); setArchivo(null);
@ -130,8 +165,8 @@ export default function AlumnosArchivo() {
return ( return (
<Layout> <Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black"> <div className="w-full pt-10 flex flex-col items-start md:items-center md:justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center"> <div className="bg-white font-sans text-center w-full flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black"> <h1 className="text-xl font-semibold mb-4 text-black">
Nuevo alumno Nuevo alumno
</h1> </h1>
@ -144,7 +179,9 @@ export default function AlumnosArchivo() {
{archivo ? ( {archivo ? (
<span className="text-black font-medium">{archivo.name}</span> <span className="text-black font-medium">{archivo.name}</span>
) : ( ) : (
<span>Arrastra y suelta un archivo o haz clic para seleccionarlo</span> <span>
Arrastra y suelta un archivo o haz clic para seleccionarlo
</span>
)} )}
<input <input
type="file" type="file"
@ -169,7 +206,9 @@ export default function AlumnosArchivo() {
<thead className="bg-gray-100 text-gray-700"> <thead className="bg-gray-100 text-gray-700">
<tr> <tr>
{Object.keys(datos[0]).map((columna, index) => ( {Object.keys(datos[0]).map((columna, index) => (
<th key={index} className="border px-4 py-2">{columna}</th> <th key={index} className="border px-4 py-2">
{columna}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
@ -177,7 +216,9 @@ export default function AlumnosArchivo() {
{datos.map((fila, index) => ( {datos.map((fila, index) => (
<tr key={index}> <tr key={index}>
{Object.values(fila).map((valor, i) => ( {Object.values(fila).map((valor, i) => (
<td key={i} className="border px-4 py-1">{valor}</td> <td key={i} className="border px-4 py-1">
{valor}
</td>
))} ))}
</tr> </tr>
))} ))}
@ -196,7 +237,8 @@ export default function AlumnosArchivo() {
Registrar curso faltante Registrar curso faltante
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
El curso <b>{cursoFaltante}</b> no existe. Por favor, regístralo antes de continuar. El curso <b>{cursoFaltante}</b> no existe. Por favor, regístralo
antes de continuar.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<CursosManualForm nombreSugerido={cursoFaltante} /> <CursosManualForm nombreSugerido={cursoFaltante} />
@ -215,6 +257,38 @@ export default function AlumnosArchivo() {
</DialogHeader> </DialogHeader>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Dialog de advertencia de navegación */}
<Dialog open={dialogoAdvertencia} onOpenChange={setDialogoAdvertencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle>
<DialogDescription>
Si cambias de ventana perderás la subida del archivo. ¿Deseas
continuar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={() => {
setDialogoAdvertencia(false);
setArchivo(null);
setDatos([]);
if (rutaPendiente) router.push(rutaPendiente);
}}
>
, continuar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setDialogoAdvertencia(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout> </Layout>
); );
} }

View File

@ -84,80 +84,66 @@ export default function AlumnosManual() {
return ( return (
<Layout> <Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black"> <div className="w-full bg-white font-sans text-center md:w-[80%] pt-10 flex flex-col items-center justify-start text-black">
<div className="bg-white p-8 font-sans text-center w-[70%]"> <h1 className="text-xl font-semibold mb-10 text-black my-10">
<h1 className="text-xl font-semibold mb-4 text-black"> Nuevo alumno
Nuevo alumno </h1>
</h1> <form onSubmit={handleSubmit(manejarGuardar)} className="w-full">
<form onSubmit={handleSubmit(manejarGuardar)}> <Input
<div className="mb-3"> type="text"
<Input placeholder="Nombre"
type="text" {...register("nombre")}
placeholder="Nombre" className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3"
{...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>
{errors.nombre && ( )}
<p className="text-red-500 text-sm mt-1"> <Input
{errors.nombre.message} type="text"
</p> placeholder="Email"
)} {...register("correo")}
</div> className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3"
<div className="mb-3"> />
<Input {errors.correo && (
type="text" <p className="text-red-500 text-sm mt-1">{errors.correo.message}</p>
placeholder="Email" )}
{...register("correo")} <Input
className="w-full px-3 py-2 border border-gray-300 rounded-md" type="text"
/> placeholder="Teléfono"
{errors.correo && ( {...register("telefono")}
<p className="text-red-500 text-sm mt-1"> className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3"
{errors.correo.message} />
</p> {errors.telefono && (
)} <p className="text-red-500 text-sm mt-1">
</div> {errors.telefono.message}
<div className="mb-3"> </p>
<Input )}
type="text" <Select
placeholder="Teléfono" onValueChange={(value) => setValue("cursoSeleccionado", value)}
{...register("telefono")} >
className="w-full px-3 py-2 border border-gray-300 rounded-md" <SelectTrigger className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3">
/> <SelectValue placeholder="Selecciona un curso" />
{errors.telefono && ( </SelectTrigger>
<p className="text-red-500 text-sm mt-1"> <SelectContent>
{errors.telefono.message} {cursos.map((curso) => (
</p> <SelectItem key={curso.id} value={curso.id.toString()}>
)} {curso.nombre}
</div> </SelectItem>
<div className="mb-4"> ))}
<Select </SelectContent>
onValueChange={(value) => setValue("cursoSeleccionado", value)} </Select>
> {errors.cursoSeleccionado && (
<SelectTrigger className="w-full px-3 py-2 border border-gray-300 rounded-md"> <p className="text-red-500 text-sm mt-1">
<SelectValue placeholder="Selecciona un curso" /> {errors.cursoSeleccionado.message}
</SelectTrigger> </p>
<SelectContent> )}
{cursos.map((curso) => ( <Button
<SelectItem key={curso.id} value={curso.id.toString()}> type="submit"
{curso.nombre} className="bg-green-400 hover:bg-green-500 font-bold py-2 px-4 rounded-md text-white"
</SelectItem> >
))} Registrar
</SelectContent> </Button>
</Select> </form>
{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> </div>
{/* Diálogo de confirmación */} {/* Diálogo de confirmación */}

View File

@ -142,124 +142,126 @@ export default function AlumnosVista() {
return ( return (
<Layout> <Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black"> <div className="w-full pt-5 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6 text-black"> <h1 className="text-2xl font-semibold mt-5 mb-10 text-black">
Lista de Alumnos Lista de Alumnos
</h1> </h1>
<table className="min-w-full bg-white border"> <div className="overflow-x-auto w-full">
<thead> <table className="min-w-full bg-white border">
<tr className="bg-gray-100"> <thead>
<th className="py-2 border-b">ID</th> <tr className="bg-gray-100">
<th className="py-2 border-b">Nombre</th> <th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Correo</th> <th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Teléfono</th> <th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Curso</th> <th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Acciones</th> <th className="py-2 border-b">Curso</th>
</tr> <th className="py-2 border-b">Acciones</th>
</thead> </tr>
<tbody> </thead>
{alumnos.map((alumno) => <tbody>
alumnoEditando === alumno.id ? ( {alumnos.map((alumno) =>
<tr key={alumno.id}> alumnoEditando === alumno.id ? (
<td className="py-2 px-4 border-b text-center"> <tr key={alumno.id}>
{alumno.id} <td className="py-2 px-4 border-b text-center">
</td> {alumno.id}
<td className="py-2 px-4 border-b"> </td>
<Input type="text" {...register("nombre")} /> <td className="py-2 px-4 border-b">
{errors.nombre && ( <Input type="text" {...register("nombre")} />
<span className="text-red-500 text-xs"> {errors.nombre && (
{errors.nombre.message} <span className="text-red-500 text-xs">
</span> {errors.nombre.message}
)} </span>
</td> )}
<td className="py-2 px-4 border-b"> </td>
<Input type="email" {...register("correo")} /> <td className="py-2 px-4 border-b">
{errors.correo && ( <Input type="email" {...register("correo")} />
<span className="text-red-500 text-xs"> {errors.correo && (
{errors.correo.message} <span className="text-red-500 text-xs">
</span> {errors.correo.message}
)} </span>
</td> )}
<td className="py-2 px-4 border-b"> </td>
<Input type="text" {...register("telefono")} /> <td className="py-2 px-4 border-b">
{errors.telefono && ( <Input type="text" {...register("telefono")} />
<span className="text-red-500 text-xs"> {errors.telefono && (
{errors.telefono.message} <span className="text-red-500 text-xs">
</span> {errors.telefono.message}
)} </span>
</td> )}
<td className="py-2 px-4 border-b"> </td>
<Select <td className="py-2 px-4 border-b">
value={undefined} <Select
onValueChange={(value) => value={(alumno.curso_id || "").toString()}
setValue("cursoSeleccionado", value) onValueChange={(value) =>
} setValue("cursoSeleccionado", value)
{...register("cursoSeleccionado")} }
> {...register("cursoSeleccionado")}
<SelectTrigger> >
<SelectValue placeholder="Selecciona un curso" /> <SelectTrigger>
</SelectTrigger> <SelectValue placeholder="Selecciona un curso" />
<SelectContent> </SelectTrigger>
{cursos.map((curso) => ( <SelectContent>
<SelectItem {cursos.map((curso) => (
key={curso.id} <SelectItem
value={curso.id.toString()} key={curso.id}
> value={curso.id.toString()}
{curso.nombre} >
</SelectItem> {curso.nombre}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
{errors.cursoSeleccionado && ( </Select>
<span className="text-red-500 text-xs"> {errors.cursoSeleccionado && (
{errors.cursoSeleccionado.message} <span className="text-red-500 text-xs">
</span> {errors.cursoSeleccionado.message}
)} </span>
</td> )}
<td className="py-2 px-4 border-b flex justify-center"> </td>
<Button <td className="py-2 px-4 border-b flex justify-center">
className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-2 rounded" <Button
onClick={handleSubmit(guardarEdicion)} className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-1 rounded"
> onClick={handleSubmit(guardarEdicion)}
Guardar >
</Button> Guardar
<Button </Button>
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded" <Button
onClick={cancelarEdicion} className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-1 rounded"
> onClick={cancelarEdicion}
Cancelar >
</Button> Cancelar
</td> </Button>
</tr> </td>
) : ( </tr>
<tr key={alumno.id}> ) : (
<td className="py-2 px-4 border-b">{alumno.id}</td> <tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.nombre}</td> <td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td> <td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td> <td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b"> <td className="py-2 px-4 border-b">{alumno.telefono}</td>
{alumno.curso?.nombre || "Sin curso"} <td className="py-2 px-4 border-b">
</td> {alumno.curso?.nombre || "Sin curso"}
<td className="py-2 px-4 border-b space-x-2"> </td>
<Button <td className="py-2 px-4 border-b flex justify-center">
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded" <Button
onClick={() => iniciarEdicion(alumno)} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 m-1 rounded"
> onClick={() => iniciarEdicion(alumno)}
Editar >
</Button> Editar
</Button>
<Button <Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded" className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => confirmarEliminacion(alumno.id)} onClick={() => confirmarEliminacion(alumno.id)}
> >
Eliminar Eliminar
</Button> </Button>
</td> </td>
</tr> </tr>
) )
)} )}
</tbody> </tbody>
</table> </table>
</div>
</div> </div>
{/* Modal de confirmación */} {/* Modal de confirmación */}

View File

@ -0,0 +1,38 @@
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 } = req.body;
if (!nombre || !horas || !descripcion) {
return res.status(400).json({ error: "Faltan datos de la inyección" });
}
// Insertar la inyección
const { error } = await supabase
.from("inyeccion")
.insert([{ nombre, horas, descripcion }]);
if (error) {
return res
.status(500)
.json({
error: "Error al insertar la inyección",
detalles: error.message,
});
}
return res
.status(200)
.json({ mensaje: "Inyección registrada correctamente" });
} catch (err) {
return res
.status(500)
.json({ error: "Error interno del servidor", detalles: err.message });
}
}

View File

@ -0,0 +1,36 @@
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 } = req.body;
if (!nombre || !horas || !descripcion) {
return res.status(400).json({ error: "Faltan datos de la píldora" });
}
// Insertar la píldora
const { error } = await supabase
.from("pildoras")
.insert([{ nombre, horas, descripcion }]);
if (error) {
return res.status(500).json({
error: "Error al insertar la píldora",
detalles: error.message,
});
}
return res
.status(200)
.json({ mensaje: "Píldora registrada correctamente" });
} catch (err) {
return res
.status(500)
.json({ error: "Error interno del servidor", detalles: err.message });
}
}

View File

@ -5,14 +5,18 @@ sgMail.setApiKey(process.env.SENDGRID_API_KEY);
export default async function handler(req, res) { export default async function handler(req, res) {
if (req.method !== "POST") return res.status(405).end(); if (req.method !== "POST") return res.status(405).end();
const { email, nombre, curso, pdfBase64 } = req.body; // Recibe mensajeCorreo desde el body
const { email, nombre, curso, pdfBase64, mensajeCorreo } = req.body;
try { try {
await sgMail.send({ await sgMail.send({
to: email, to: email,
from: "rviverosgonzalez@outlook.com", // Cambia esto por tu correo verificado en SendGrid from: "rviverosgonzalez@outlook.com", // Cambia esto por tu correo verificado en SendGrid
subject: "Tu diploma", subject: "Tu diploma",
text: `Hola ${nombre}, has concluido tu curso ${curso} por lo que adjuntamos tu diploma.`, // Usa el mensajeCorreo personalizado, si no viene usa el texto por defecto
text:
mensajeCorreo ||
`Hola ${nombre}, has concluido tu curso ${curso} por lo que adjuntamos tu diploma.`,
attachments: [ attachments: [
{ {
content: pdfBase64, content: pdfBase64,

View File

@ -3,7 +3,15 @@ import Papa from "papaparse";
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout"; import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
export default function cursosArchivo() { export default function cursosArchivo() {
@ -53,7 +61,7 @@ export default function cursosArchivo() {
horas: curso.horas, horas: curso.horas,
descripcion: curso.descripcion, descripcion: curso.descripcion,
competencias: curso.competencias competencias: curso.competencias
? curso.competencias.split(",").map(c => c.trim()) ? curso.competencias.split(",").map((c) => c.trim())
: [], : [],
}), }),
}); });
@ -67,7 +75,9 @@ export default function cursosArchivo() {
setDialogoCargando(false); // Ocultar dialogo de carga setDialogoCargando(false); // Ocultar dialogo de carga
if (errores.length > 0) { if (errores.length > 0) {
setMensajeDialogo(`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`); setMensajeDialogo(
`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`
);
} else { } else {
setMensajeDialogo("Todos los cursos fueron registrados correctamente."); setMensajeDialogo("Todos los cursos fueron registrados correctamente.");
setArchivo(null); setArchivo(null);
@ -132,11 +142,9 @@ export default function cursosArchivo() {
return ( return (
<Layout> <Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black"> <div className="w-full pt-10 flex flex-col items-start md:items-center md:justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center"> <div className="bg-white font-sans text-center w-full flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black"> <h1 className="text-xl font-semibold mb-4 text-black">Nuevo curso</h1>
Nuevo curso
</h1>
<label <label
htmlFor="archivo" htmlFor="archivo"
onDrop={manejarSoltar} onDrop={manejarSoltar}
@ -146,7 +154,9 @@ export default function cursosArchivo() {
{archivo ? ( {archivo ? (
<span className="text-black font-medium">{archivo.name}</span> <span className="text-black font-medium">{archivo.name}</span>
) : ( ) : (
<span>Arrastra y suelta un archivo o haz clic para seleccionarlo</span> <span>
Arrastra y suelta un archivo o haz clic para seleccionarlo
</span>
)} )}
<input <input
type="file" type="file"
@ -171,7 +181,9 @@ export default function cursosArchivo() {
<thead className="bg-gray-100 text-gray-700"> <thead className="bg-gray-100 text-gray-700">
<tr> <tr>
{Object.keys(datos[0]).map((columna, index) => ( {Object.keys(datos[0]).map((columna, index) => (
<th key={index} className="border px-4 py-2">{columna}</th> <th key={index} className="border px-4 py-2">
{columna}
</th>
))} ))}
</tr> </tr>
</thead> </thead>
@ -179,7 +191,9 @@ export default function cursosArchivo() {
{datos.map((fila, index) => ( {datos.map((fila, index) => (
<tr key={index}> <tr key={index}>
{Object.values(fila).map((valor, i) => ( {Object.values(fila).map((valor, i) => (
<td key={i} className="border px-4 py-1">{valor}</td> <td key={i} className="border px-4 py-1">
{valor}
</td>
))} ))}
</tr> </tr>
))} ))}
@ -216,7 +230,8 @@ export default function cursosArchivo() {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle> <DialogTitle className="text-black">Advertencia</DialogTitle>
<DialogDescription> <DialogDescription>
Si cambias de ventana perderás la subida del archivo. ¿Deseas continuar? Si cambias de ventana perderás la subida del archivo. ¿Deseas
continuar?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>

View File

@ -6,7 +6,7 @@ import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { supabaseClient } from "@/utils/supabase"; // Importar el cliente de Supabase import { supabaseClient } from "@/utils/supabase";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -17,11 +17,11 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
export default function CursosManual() { export default function CursosManual() {
const [addCompetencia, setAddCompetencia] = useState(false); const [competencias, setCompetencias] = useState([]); // [{id, descripcion}]
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]); const [showDialog, setShowDialog] = useState(false);
const [dialogMsg, setDialogMsg] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false); const [addCompetencia, setAddCompetencia] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
const form = useForm({ const form = useForm({
resolver: zodResolver(cursosSchema), resolver: zodResolver(cursosSchema),
@ -32,7 +32,6 @@ export default function CursosManual() {
nuevaCompetencia: "", nuevaCompetencia: "",
}, },
}); });
const { const {
register, register,
handleSubmit, handleSubmit,
@ -41,348 +40,211 @@ export default function CursosManual() {
formState: { errors }, formState: { errors },
} = form; } = form;
const handleAddCompetencia = () => { // Añadir competencia (busca o crea en BD)
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) => { const handleSaveCompetencia = async (e) => {
e.preventDefault(); e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia").trim(); const desc = getValues("nuevaCompetencia").trim();
if (!nuevaCompetencia) return; if (!desc) return;
if (competencias.some((c) => c.descripcion === desc)) {
// Verifica si ya existe en el estado setDialogMsg("La competencia ya fue agregada.");
if (competenciasGuardadas.some((c) => c.descripcion === nuevaCompetencia)) { setShowDialog(true);
alert("La competencia ya fue agregada.");
return; return;
} }
// Verifica si ya existe en la base de datos
let competenciaId = null;
try { try {
// Busca si ya existe let { data: existente } = await supabaseClient
const { data: existente } = await supabaseClient
.from("competencia") .from("competencia")
.select("id") .select("id")
.eq("descripcion", nuevaCompetencia) .eq("descripcion", desc)
.maybeSingle(); .maybeSingle();
let id = existente?.id;
if (existente && existente.id) { if (!id) {
competenciaId = existente.id;
} else {
// Si no existe, la crea
const { data: insertada, error } = await supabaseClient const { data: insertada, error } = await supabaseClient
.from("competencia") .from("competencia")
.insert([{ descripcion: nuevaCompetencia }]) .insert([{ descripcion: desc }])
.select("id") .select("id")
.single(); .single();
if (error) throw error; if (error) throw error;
competenciaId = insertada.id; id = insertada.id;
} }
setCompetencias([...competencias, { id, descripcion: desc }]);
setCompetenciasGuardadas([
...competenciasGuardadas,
{ id: competenciaId, descripcion: nuevaCompetencia },
]);
setAddCompetencia(false); setAddCompetencia(false);
setValue("nuevaCompetencia", ""); setValue("nuevaCompetencia", "");
setMostrarDialogCompetencia(true); // Mostrar dialog de éxito setDialogMsg("¡La competencia fue agregada exitosamente!");
setShowDialog(true);
} catch (err) { } catch (err) {
alert("Error al guardar la competencia: " + (err.message || err)); setDialogMsg("Error al guardar la competencia: " + (err.message || err));
setShowDialog(true);
} }
}; };
// Eliminar competencia
const handleDeleteCompetencia = (index) => { const handleDeleteCompetencia = (index) => {
setCompetenciasGuardadas( setCompetencias(competencias.filter((_, i) => i !== index));
competenciasGuardadas.filter((_, i) => i !== index)
);
}; };
// Guardar curso y asociar competencias // Guardar curso y asociar competencias
const onSubmit = async (data) => { const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10);
setLoading(true); setLoading(true);
try { try {
// 1. Inserta el curso const { nombre, descripcion, horas } = data;
const { data: cursoInsertado, error: errorCurso } = await supabaseClient const { data: curso, error: errorCurso } = await supabaseClient
.from("curso") .from("curso")
.insert([{ nombre, descripcion, horas }]) .insert([{ nombre, descripcion, horas: parseInt(horas, 10) }])
.select("id") .select("id")
.single(); .single();
if (errorCurso) throw errorCurso;
if (errorCurso) { if (competencias.length) {
setMensajeDialog("Error al guardar el curso: " + errorCurso.message); const relaciones = competencias.map((c) => ({
setMostrarDialog(true); curso_id: curso.id,
setLoading(false); competencia_id: c.id,
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 const { error: errorPivote } = await supabaseClient
.from("curso_competencia") .from("curso_competencia")
.insert(relaciones); .insert(relaciones);
if (errorPivote) { if (errorPivote) throw errorPivote;
setMensajeDialog("Error al asociar competencias: " + errorPivote.message);
setMostrarDialog(true);
setLoading(false);
return;
}
} }
setDialogMsg("Curso guardado exitosamente");
setMensajeDialog("Curso guardado exitosamente"); setCompetencias([]);
setMostrarDialog(true);
form.reset(); form.reset();
setCompetenciasGuardadas([]);
} catch (err) { } catch (err) {
alert("Ocurrió un error inesperado"); setDialogMsg("Error: " + (err.message || err));
} finally { } finally {
setShowDialog(true);
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<form onSubmit={handleSubmit(onSubmit)} className="w-full"> <Layout>
<Input <div className="w-full bg-white pt-5 font-sans text-center md:w-[80%] flex flex-col items-center justify-start text-black">
type="text" <h1 className="text-xl font-semibold mb-10 text-black">Nuevo curso</h1>
placeholder="Nombre del curso" <form onSubmit={handleSubmit(onSubmit)} className="w-full">
{...register("nombre")} <Input
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black" type="text"
/> placeholder="Nombre del curso"
{errors.nombre && ( {...register("nombre")}
<p className="text-red-500 text-sm">{errors.nombre.message}</p> 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 <Textarea
placeholder="Descripción" placeholder="Descripción"
{...register("descripcion")} {...register("descripcion")}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 h-24 text-black" className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 h-24 text-black"
/> />
{errors.descripcion && ( {errors.descripcion && (
<p className="text-red-500 text-sm">{errors.descripcion.message}</p> <p className="text-red-500 text-sm">{errors.descripcion.message}</p>
)} )}
<Input <Input
type="number" type="number"
placeholder="Horas del curso" placeholder="Horas del curso"
{...register("horas", { valueAsNumber: true })} {...register("horas", { valueAsNumber: true })}
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black" className="w-full px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/> />
{errors.horas && ( {errors.horas && (
<p className="text-red-500 text-sm">{errors.horas.message}</p> <p className="text-red-500 text-sm">{errors.horas.message}</p>
)} )}
<h2 className="text-lg font-semibold mb-3 text-black">Competencias</h2> <h2 className="text-lg font-semibold mb-3 text-black">
<p className="text-xs text-gray-500 mb-2"> Competencias
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. </h2>
</p> <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 && ( {competencias.length > 0 && (
<div className="mt-5 w-full flex-wrap"> <div className="mt-5 w-full flex-wrap">
{competenciasGuardadas.map((competencia, index) => ( {competencias.map((c, i) => (
<div <div
key={index} key={i}
className="w-full flex justify-between items-center px-2 mb-2" className="w-full flex justify-between items-center px-2 mb-2"
> >
<span className="text-black">{competencia.descripcion}</span> <span className="text-black">{c.descripcion}</span>
<Button <Button
type="button" type="button"
onClick={() => handleDeleteCompetencia(index)} onClick={() => handleDeleteCompetencia(i)}
className="bg-red-400 hover:bg-red-500 text-white font-bold py-1 px-3 rounded-md" className="bg-red-400 hover:bg-red-500 text-white font-bold py-1 px-3 rounded-md"
> >
X X
</Button> </Button>
</div>
))}
</div> </div>
))} )}
</div>
)}
{addCompetencia && ( {addCompetencia && (
<div className="w-full flex flex-col md:flex-row mt-5"> <div className="w-full flex flex-col md:flex-row mt-5">
<div className="flex flex-col"> <div className="flex flex-col">
<Input <Input
type="text" type="text"
placeholder="Nueva competencia" placeholder="Nueva competencia"
{...register("nuevaCompetencia")} {...register("nuevaCompetencia")}
className="w-80 px-3 py-2 border border-gray-300 rounded-md mb-3 text-black" className="w-80 px-3 py-2 border border-gray-300 rounded-md mb-3 text-black"
/> />
{errors.nuevaCompetencia && ( {errors.nuevaCompetencia && (
<p className="text-red-500 text-sm"> <p className="text-red-500 text-sm">
{errors.nuevaCompetencia.message} {errors.nuevaCompetencia.message}
</p> </p>
)} )}
</div> </div>
<div className="flex flex-row"> <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 <Button
type="button" type="submit"
onClick={handleSaveCompetencia} className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md"
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md mr-2" disabled={loading}
> >
Guardar {loading ? "Guardando..." : "Guardar curso"}
</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> </Button>
</div> </div>
</div>
)}
<Button <Dialog open={showDialog} onOpenChange={setShowDialog}>
type="button" <DialogContent>
onClick={() => setAddCompetencia(true)} <DialogHeader>
className="w-full bg-blue-400 hover:bg-blue-500 text-white font-bold py-2 px-4 rounded-md mt-5" <DialogTitle className="text-black">Resultado</DialogTitle>
> <DialogDescription>{dialogMsg}</DialogDescription>
Agregar competencia </DialogHeader>
</Button> <DialogFooter>
<Button onClick={() => setShowDialog(false)}>Cerrar</Button>
<div className="flex justify-center w-full mt-5"> </DialogFooter>
<Button </DialogContent>
type="submit" </Dialog>
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md" </form>
disabled={loading}
>
{loading ? "Guardando..." : "Guardar curso"}
</Button>
</div> </div>
</Layout>
<Dialog open={mostrarDialog} onOpenChange={setMostrarDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Resultado</DialogTitle>
<DialogDescription>{mensajeDialog}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialog(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={mostrarDialogCompetencia} onOpenChange={setMostrarDialogCompetencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Competencia agregada</DialogTitle>
<DialogDescription>
¡La competencia fue agregada exitosamente!
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarDialogCompetencia(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
); );
} }

View File

@ -39,7 +39,8 @@ export default function CursosVista() {
const cargarCursos = async () => { const cargarCursos = async () => {
const { data, error } = await supabaseClient const { data, error } = await supabaseClient
.from("curso") .from("curso")
.select(` .select(
`
id, id,
nombre, nombre,
descripcion, descripcion,
@ -50,7 +51,8 @@ export default function CursosVista() {
descripcion descripcion
) )
) )
`) `
)
.order("id", { ascending: true }); .order("id", { ascending: true });
if (error) { if (error) {
console.error("Error al cargar cursos:", error.message); console.error("Error al cargar cursos:", error.message);
@ -90,7 +92,7 @@ export default function CursosVista() {
// Guardar cambios en curso y competencias // Guardar cambios en curso y competencias
const guardarEdicion = async (id) => { const guardarEdicion = async (id) => {
// Validar que no haya competencias repetidas // Validar que no haya competencias repetidas
const ids = competenciasGuardadas.map(c => c?.id).filter(Boolean); const ids = competenciasGuardadas.map((c) => c?.id).filter(Boolean);
const setIds = new Set(ids); const setIds = new Set(ids);
if (ids.length !== setIds.size) { if (ids.length !== setIds.size) {
setModalMensaje("No puedes repetir competencias en un curso."); setModalMensaje("No puedes repetir competencias en un curso.");
@ -109,15 +111,12 @@ export default function CursosVista() {
// Actualiza competencias (tabla pivote) // Actualiza competencias (tabla pivote)
// 1. Elimina todas las competencias actuales del curso // 1. Elimina todas las competencias actuales del curso
await supabaseClient await supabaseClient.from("curso_competencia").delete().eq("curso_id", id);
.from("curso_competencia")
.delete()
.eq("curso_id", id);
// 2. Inserta las nuevas competencias seleccionadas // 2. Inserta las nuevas competencias seleccionadas
const competenciasAInsertar = competenciasGuardadas const competenciasAInsertar = competenciasGuardadas
.filter(c => c && c.id) .filter((c) => c && c.id)
.map(c => ({ .map((c) => ({
curso_id: id, curso_id: id,
competencia_id: c.id, competencia_id: c.id,
})); }));
@ -157,7 +156,9 @@ export default function CursosVista() {
} }
if (alumnosInscritos && alumnosInscritos.length > 0) { if (alumnosInscritos && alumnosInscritos.length > 0) {
setModalMensaje("No se puede eliminar el curso porque hay alumnos inscritos a este curso."); setModalMensaje(
"No se puede eliminar el curso porque hay alumnos inscritos a este curso."
);
setConfirmarEliminar(false); setConfirmarEliminar(false);
setMostrarModal(true); setMostrarModal(true);
return; return;
@ -192,150 +193,167 @@ export default function CursosVista() {
}; };
const quitarCompetencia = () => { const quitarCompetencia = () => {
setCompetenciasGuardadas(competenciasGuardadas.filter((_, i) => i !== compAEliminar)); setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== compAEliminar)
);
setDialogQuitarComp(false); setDialogQuitarComp(false);
setCompAEliminar(null); setCompAEliminar(null);
}; };
return ( return (
<Layout> <Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black"> <div className="w-full pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Lista de Cursos</h1> <h1 className="text-2xl font-semibold mb-6">Lista de Cursos</h1>
<table className="min-w-full bg-white border"> <div className="overflow-x-auto w-full">
<thead> <table className="min-w-full bg-white border">
<tr className="bg-gray-100"> <thead>
<th className="py-2 border-b">ID</th> <tr className="bg-gray-100">
<th className="py-2 border-b">Nombre</th> <th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Descripción</th> <th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Horas</th> <th className="py-2 border-b">Descripción</th>
<th className="py-2 border-b">Competencias</th> <th className="py-2 border-b">Horas</th>
<th className="py-2 border-b">Acciones</th> <th className="py-2 border-b">Competencias</th>
</tr> <th className="py-2 border-b">Acciones</th>
</thead> </tr>
<tbody> </thead>
{cursos.map((curso) => <tbody>
cursoEditando === curso.id ? ( {cursos.map((curso) =>
<tr key={curso.id}> cursoEditando === curso.id ? (
<td className="py-2 px-4 border-b text-center">{curso.id}</td> <tr key={curso.id}>
<td className="py-2 px-4 border-b"> <td className="py-2 px-4 border-b text-center">
<Input {curso.id}
value={nuevoNombre} </td>
onChange={(e) => setNuevoNombre(e.target.value)} <td className="py-2 px-4 border-b">
/> <Input
</td> value={nuevoNombre}
<td className="py-2 px-4 border-b"> onChange={(e) => setNuevoNombre(e.target.value)}
<Input />
value={nuevaDescripcion} </td>
onChange={(e) => setNuevaDescripcion(e.target.value)} <td className="py-2 px-4 border-b">
/> <Input
</td> value={nuevaDescripcion}
<td className="py-2 px-4 border-b"> onChange={(e) => setNuevaDescripcion(e.target.value)}
<Input />
type="number" </td>
value={nuevaHoras} <td className="py-2 px-4 border-b">
onChange={(e) => setNuevaHoras(e.target.value)} <Input
/> type="number"
</td> value={nuevaHoras}
<td className="py-2 px-4 border-b"> onChange={(e) => setNuevaHoras(e.target.value)}
<div className="flex flex-col gap-2"> />
{competenciasGuardadas.map((comp, idx) => ( </td>
<div key={idx} className="flex items-center gap-2"> <td className="py-2 px-4 border-b">
<select <div className="flex flex-col gap-2">
className="border rounded px-2 py-1" {competenciasGuardadas.map((comp, idx) => (
value={comp?.id || ""} <div key={idx} className="flex items-center gap-2">
onChange={e => { <select
const nuevaLista = [...competenciasGuardadas]; className="border rounded px-2 py-1"
const nuevaComp = todasCompetencias.find(c => c.id === Number(e.target.value)); value={comp?.id || ""}
nuevaLista[idx] = nuevaComp; onChange={(e) => {
setCompetenciasGuardadas(nuevaLista); const nuevaLista = [...competenciasGuardadas];
}} const nuevaComp = todasCompetencias.find(
> (c) => c.id === Number(e.target.value)
<option value="">Selecciona competencia</option> );
{todasCompetencias.map(tc => ( nuevaLista[idx] = nuevaComp;
<option setCompetenciasGuardadas(nuevaLista);
key={tc.id} }}
value={tc.id} >
disabled={ <option value="">Selecciona competencia</option>
// Deshabilita si ya está seleccionada en otro select {todasCompetencias.map((tc) => (
competenciasGuardadas.some( <option
(c, i) => c && c.id === tc.id && i !== idx key={tc.id}
) value={tc.id}
} disabled={
> // Deshabilita si ya está seleccionada en otro select
{tc.descripcion} competenciasGuardadas.some(
</option> (c, i) => c && c.id === tc.id && i !== idx
))} )
</select> }
<Button >
type="button" {tc.descripcion}
className="bg-red-500 hover:bg-red-700 text-white px-2 py-1 rounded" </option>
onClick={() => pedirConfirmacionQuitarComp(idx)} ))}
> </select>
Quitar <Button
</Button> type="button"
</div> 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 <Button
type="button" className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-2 rounded"
className="bg-blue-500 hover:bg-blue-700 text-white px-2 py-1 rounded mt-2" onClick={() => guardarEdicion(curso.id)}
onClick={() => setCompetenciasGuardadas([...competenciasGuardadas, null])}
disabled={competenciasGuardadas.length >= todasCompetencias.length}
> >
Agregar competencia Guardar
</Button> </Button>
</div> <Button
</td> className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded"
<td className="py-2 px-4 border-b flex justify-center"> onClick={cancelarEdicion}
<Button >
className="bg-green-500 hover:bg-green-700 text-white font-bold py-1 px-3 m-2 rounded" Cancelar
onClick={() => guardarEdicion(curso.id)} </Button>
> </td>
Guardar </tr>
</Button> ) : (
<Button <tr key={curso.id}>
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded" <td className="py-2 px-4 border-b">{curso.id}</td>
onClick={cancelarEdicion} <td className="py-2 px-4 border-b">{curso.nombre}</td>
> <td className="py-2 px-4 border-b">{curso.descripcion}</td>
Cancelar <td className="py-2 px-4 border-b">{curso.horas}</td>
</Button> <td className="py-2 px-4 border-b">
</td> {Array.isArray(curso.competencias) &&
</tr> curso.competencias.length > 0 ? (
) : (
<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"> <ul className="list-disc pl-4">
{curso.competencias.map((comp) => ( {curso.competencias.map((comp) => (
<li key={comp.id}>{comp.descripcion}</li> <li key={comp.id}>{comp.descripcion}</li>
))} ))}
</ul> </ul>
) ) : (
: "Sin competencias"} "Sin competencias"
</td> )}
<td className="py-2 px-4 border-b space-x-2"> </td>
<Button <td className="py-2 px-4 border-b flex justify-center">
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded" <Button
onClick={() => iniciarEdicion(curso)} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 m-1 rounded"
> onClick={() => iniciarEdicion(curso)}
Editar >
</Button> Editar
<Button </Button>
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded" <Button
onClick={() => confirmarEliminacion(curso.id)} className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 m-1 rounded"
> onClick={() => confirmarEliminacion(curso.id)}
Eliminar >
</Button> Eliminar
</td> </Button>
</tr> </td>
) </tr>
)} )
</tbody> )}
</table> </tbody>
</table>
</div>
</div> </div>
{/* Dialog para eliminar curso */} {/* Dialog para eliminar curso */}
@ -371,9 +389,7 @@ export default function CursosVista() {
<Dialog open={dialogQuitarComp} onOpenChange={setDialogQuitarComp}> <Dialog open={dialogQuitarComp} onOpenChange={setDialogQuitarComp}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-black"> <DialogTitle className="text-black">Quitar competencia</DialogTitle>
Quitar competencia
</DialogTitle>
<DialogDescription> <DialogDescription>
¿Estás seguro de que deseas quitar esta competencia del curso? ¿Estás seguro de que deseas quitar esta competencia del curso?
</DialogDescription> </DialogDescription>

View File

@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout"; import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase"; import { supabaseClient } from "@/utils/supabase";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import CrearDiplomaDialog from "@/components/dialogs/crearDiplomaDialog";
import VistaPreviaDiplomaDialog from "@/components/dialogs/vistaPreviaDiplomaDialog"; import VistaPreviaDiplomaDialog from "@/components/dialogs/vistaPreviaDiplomaDialog";
export default function DiplomasVista() { export default function DiplomasVista() {
@ -53,60 +52,51 @@ export default function DiplomasVista() {
return ( return (
<Layout> <Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black"> <div className="w-full pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Vista de Diplomas</h1> <h1 className="text-2xl font-semibold mb-6">Vista de Diplomas</h1>
<table className="min-w-full bg-white border"> <div className="overflow-x-auto w-full">
<thead> <table className="w-full bg-white border">
<tr className="bg-gray-100"> <thead>
<th className="py-2 border-b">ID</th> <tr className="bg-gray-100">
<th className="py-2 border-b">Nombre</th> <th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Correo</th> <th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Teléfono</th> <th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Curso</th> <th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Acciones</th> <th className="py-2 border-b">Curso</th>
</tr> <th className="py-2 border-b">Acciones</th>
</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> </tr>
))} </thead>
</tbody> <tbody>
</table> {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>
</div> </div>
{/* Dialog para crear diploma y vista previa juntos */} {/* Dialog para crear diploma y vista previa juntos */}
{mostrarDialog && ( {mostrarDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center"> <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"> <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 <VistaPreviaDiplomaDialog
open={mostrarDialog} open={mostrarDialog}
onOpenChange={handleCloseDialog} onOpenChange={handleCloseDialog}

View File

@ -0,0 +1,263 @@
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,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useRouter } from "next/router";
export default function InyeccionesArchivo() {
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();
// eslint-disable-next-line
}, [archivo]);
useEffect(() => {
const handleRouteChange = (url) => {
if (archivo && datos.length > 0) {
setDialogoAdvertencia(true);
setRutaPendiente(url);
throw "Bloqueo de navegación por archivo pendiente";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [archivo, datos, router]);
const registrarInyecciones = async () => {
if (datos.length === 0) return;
setDialogoCargando(true);
const errores = [];
for (const inyeccion of datos) {
const res = await fetch("/api/inyeccion", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: inyeccion.nombre,
horas: inyeccion.horas,
descripcion: inyeccion.descripcion,
}),
});
const resultado = await res.json();
if (!res.ok) {
errores.push({
inyeccion,
error: resultado.error || "Error desconocido",
});
}
}
setDialogoCargando(false);
if (errores.length > 0) {
setMensajeDialogo(
`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`
);
} else {
setMensajeDialogo(
"Todas las inyecciones fueron registradas 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-full pt-10 flex flex-col items-start md:items-center md:justify-center text-black">
<div className="bg-white font-sans text-center w-full flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">
Nueva inyección (archivo)
</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={registrarInyecciones}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md mt-4"
>
Registrar inyecciones
</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 las inyecciones.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={dialogoAdvertencia} onOpenChange={setDialogoAdvertencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle>
<DialogDescription>
Si cambias de ventana perderás la subida del archivo. ¿Deseas
continuar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={() => {
setDialogoAdvertencia(false);
setArchivo(null);
setDatos([]);
if (rutaPendiente) router.push(rutaPendiente);
}}
>
, continuar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setDialogoAdvertencia(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,118 @@
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Schema } from "@/schemas/Schema";
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";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
export default function InyeccionesManual() {
const [showDialog, setShowDialog] = useState(false);
const [dialogMsg, setDialogMsg] = useState("");
const [loading, setLoading] = useState(false);
const form = useForm({
resolver: zodResolver(Schema),
defaultValues: {
nombre: "",
descripcion: "",
horas: 0,
},
});
const {
register,
handleSubmit,
formState: { errors },
reset,
} = form;
const onSubmit = async (data) => {
setLoading(true);
try {
const { nombre, descripcion, horas } = data;
const { error } = await supabaseClient
.from("inyeccion")
.insert([{ nombre, descripcion, horas: parseInt(horas, 10) }]);
if (error) throw error;
setDialogMsg("Inyección guardada exitosamente");
reset();
} catch (err) {
setDialogMsg("Error: " + (err.message || err));
} finally {
setShowDialog(true);
setLoading(false);
}
};
return (
<Layout>
<div className="w-full bg-white pt-5 font-sans text-center md:w-[80%] flex flex-col items-center justify-start text-black">
<h1 className="text-xl font-semibold mb-10 text-black">
Nueva inyección
</h1>
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<Input
type="text"
placeholder="Nombre de la inyección"
{...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"
{...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>
)}
<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 inyección"}
</Button>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Resultado</DialogTitle>
<DialogDescription>{dialogMsg}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setShowDialog(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
</div>
</Layout>
);
}

View File

@ -0,0 +1,229 @@
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 InyeccionesVista() {
const [inyecciones, setInyecciones] = useState([]);
const [inyeccionEditando, setInyeccionEditando] = useState(null);
const [nuevoNombre, setNuevoNombre] = useState("");
const [nuevaDescripcion, setNuevaDescripcion] = useState("");
const [nuevaHoras, setNuevaHoras] = useState("");
const [mostrarModal, setMostrarModal] = useState(false);
const [modalMensaje, setModalMensaje] = useState("");
const [confirmarEliminar, setConfirmarEliminar] = useState(false);
const [inyeccionAEliminar, setInyeccionAEliminar] = useState(null);
useEffect(() => {
cargarInyecciones();
}, []);
const cargarInyecciones = async () => {
const { data, error } = await supabaseClient
.from("inyeccion")
.select("*")
.order("id", { ascending: true });
if (error) {
setModalMensaje("Error al cargar inyecciones: " + error.message);
setMostrarModal(true);
} else {
setInyecciones(data);
}
};
const iniciarEdicion = (inyeccion) => {
setInyeccionEditando(inyeccion.id);
setNuevoNombre(inyeccion.nombre);
setNuevaDescripcion(inyeccion.descripcion);
setNuevaHoras(inyeccion.horas);
};
const cancelarEdicion = () => {
setInyeccionEditando(null);
setNuevoNombre("");
setNuevaDescripcion("");
setNuevaHoras("");
};
const guardarEdicion = async (id) => {
const { error } = await supabaseClient
.from("inyeccion")
.update({
nombre: nuevoNombre,
descripcion: nuevaDescripcion,
horas: nuevaHoras,
})
.eq("id", id);
if (error) {
setModalMensaje("Error al actualizar la inyección");
} else {
setModalMensaje("Inyección actualizada exitosamente");
await cargarInyecciones();
cancelarEdicion();
}
setMostrarModal(true);
};
const confirmarEliminacion = (id) => {
setInyeccionAEliminar(id);
setConfirmarEliminar(true);
};
const eliminarInyeccion = async () => {
const { error } = await supabaseClient
.from("inyeccion")
.delete()
.eq("id", inyeccionAEliminar);
if (error) {
setModalMensaje("Error al eliminar la inyección");
} else {
setModalMensaje("Inyección eliminada exitosamente");
await cargarInyecciones();
}
setConfirmarEliminar(false);
setMostrarModal(true);
};
return (
<Layout>
<div className="w-full pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Lista de Inyecciones</h1>
<div className="overflow-x-auto w-full">
<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">Acciones</th>
</tr>
</thead>
<tbody>
{inyecciones.map((inyeccion) =>
inyeccionEditando === inyeccion.id ? (
<tr key={inyeccion.id}>
<td className="py-2 px-4 border-b text-center">
{inyeccion.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 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(inyeccion.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={inyeccion.id}>
<td className="py-2 px-4 border-b">{inyeccion.id}</td>
<td className="py-2 px-4 border-b">{inyeccion.nombre}</td>
<td className="py-2 px-4 border-b">
{inyeccion.descripcion}
</td>
<td className="py-2 px-4 border-b">{inyeccion.horas}</td>
<td className="py-2 px-4 border-b flex justify-center">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => iniciarEdicion(inyeccion)}
>
Editar
</Button>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => confirmarEliminacion(inyeccion.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
{/* Dialog para eliminar inyección */}
<Dialog open={confirmarEliminar} onOpenChange={setConfirmarEliminar}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Confirmar eliminación
</DialogTitle>
<DialogDescription>
¿Estás seguro de que deseas eliminar esta inyección? Esta acción
no se puede deshacer.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={eliminarInyeccion}
>
Eliminar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setConfirmarEliminar(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de resultado */}
<Dialog open={mostrarModal} onOpenChange={setMostrarModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Resultado de la operación
</DialogTitle>
<DialogDescription>{modalMensaje}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarModal(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,261 @@
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,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useRouter } from "next/router";
export default function PildorasArchivo() {
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();
// eslint-disable-next-line
}, [archivo]);
useEffect(() => {
const handleRouteChange = (url) => {
if (archivo && datos.length > 0) {
setDialogoAdvertencia(true);
setRutaPendiente(url);
throw "Bloqueo de navegación por archivo pendiente";
}
};
router.events.on("routeChangeStart", handleRouteChange);
return () => {
router.events.off("routeChangeStart", handleRouteChange);
};
}, [archivo, datos, router]);
const registrarPildoras = async () => {
if (datos.length === 0) return;
setDialogoCargando(true);
const errores = [];
for (const pildora of datos) {
const res = await fetch("/api/pildora", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nombre: pildora.nombre,
horas: pildora.horas,
descripcion: pildora.descripcion,
}),
});
const resultado = await res.json();
if (!res.ok) {
errores.push({
pildora,
error: resultado.error || "Error desconocido",
});
}
}
setDialogoCargando(false);
if (errores.length > 0) {
setMensajeDialogo(
`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`
);
} else {
setMensajeDialogo("Todas las píldoras fueron registradas 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-full pt-10 flex flex-col items-start md:items-center md:justify-center text-black">
<div className="bg-white font-sans text-center w-full flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">
Nueva píldora (archivo)
</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={registrarPildoras}
className="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded-md mt-4"
>
Registrar píldoras
</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 las píldoras.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={dialogoAdvertencia} onOpenChange={setDialogoAdvertencia}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle>
<DialogDescription>
Si cambias de ventana perderás la subida del archivo. ¿Deseas
continuar?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={() => {
setDialogoAdvertencia(false);
setArchivo(null);
setDatos([]);
if (rutaPendiente) router.push(rutaPendiente);
}}
>
, continuar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setDialogoAdvertencia(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,127 @@
import React, { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
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";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
// Puedes mover este schema a un archivo separado si lo deseas
const pildoraSchema = z.object({
nombre: z.string().nonempty("Escribe el nombre"),
descripcion: z.string().nonempty("Escribe la descripción"),
horas: z
.number({ invalid_type_error: "Las horas deben ser un número" })
.min(1, "Las horas deben ser mayor a 0"),
});
export default function PildorasManual() {
const [showDialog, setShowDialog] = useState(false);
const [dialogMsg, setDialogMsg] = useState("");
const [loading, setLoading] = useState(false);
const form = useForm({
resolver: zodResolver(pildoraSchema),
defaultValues: {
nombre: "",
descripcion: "",
horas: 0,
},
});
const {
register,
handleSubmit,
formState: { errors },
reset,
} = form;
const onSubmit = async (data) => {
setLoading(true);
try {
const { nombre, descripcion, horas } = data;
const { error } = await supabaseClient
.from("pildoras")
.insert([{ nombre, descripcion, horas: parseInt(horas, 10) }]);
if (error) throw error;
setDialogMsg("Píldora guardada exitosamente");
reset();
} catch (err) {
setDialogMsg("Error: " + (err.message || err));
} finally {
setShowDialog(true);
setLoading(false);
}
};
return (
<Layout>
<div className="w-full bg-white pt-5 font-sans text-center md:w-[80%] flex flex-col items-center justify-start text-black">
<h1 className="text-xl font-semibold mb-10 text-black">
Nueva píldora
</h1>
<form onSubmit={handleSubmit(onSubmit)} className="w-full">
<Input
type="text"
placeholder="Nombre de la píldora"
{...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"
{...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>
)}
<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 píldora"}
</Button>
</div>
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">Resultado</DialogTitle>
<DialogDescription>{dialogMsg}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setShowDialog(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
</div>
</Layout>
);
}

View File

@ -0,0 +1,229 @@
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 PildorasVista() {
const [pildoras, setPildoras] = useState([]);
const [pildoraEditando, setPildoraEditando] = useState(null);
const [nuevoNombre, setNuevoNombre] = useState("");
const [nuevaDescripcion, setNuevaDescripcion] = useState("");
const [nuevaHoras, setNuevaHoras] = useState("");
const [mostrarModal, setMostrarModal] = useState(false);
const [modalMensaje, setModalMensaje] = useState("");
const [confirmarEliminar, setConfirmarEliminar] = useState(false);
const [pildoraAEliminar, setPildoraAEliminar] = useState(null);
useEffect(() => {
cargarPildoras();
}, []);
const cargarPildoras = async () => {
const { data, error } = await supabaseClient
.from("pildoras")
.select("*")
.order("id", { ascending: true });
if (error) {
setModalMensaje("Error al cargar píldoras: " + error.message);
setMostrarModal(true);
} else {
setPildoras(data);
}
};
const iniciarEdicion = (pildora) => {
setPildoraEditando(pildora.id);
setNuevoNombre(pildora.nombre);
setNuevaDescripcion(pildora.descripcion);
setNuevaHoras(pildora.horas);
};
const cancelarEdicion = () => {
setPildoraEditando(null);
setNuevoNombre("");
setNuevaDescripcion("");
setNuevaHoras("");
};
const guardarEdicion = async (id) => {
const { error } = await supabaseClient
.from("pildoras")
.update({
nombre: nuevoNombre,
descripcion: nuevaDescripcion,
horas: nuevaHoras,
})
.eq("id", id);
if (error) {
setModalMensaje("Error al actualizar la píldora");
} else {
setModalMensaje("Píldora actualizada exitosamente");
await cargarPildoras();
cancelarEdicion();
}
setMostrarModal(true);
};
const confirmarEliminacion = (id) => {
setPildoraAEliminar(id);
setConfirmarEliminar(true);
};
const eliminarPildora = async () => {
const { error } = await supabaseClient
.from("pildoras")
.delete()
.eq("id", pildoraAEliminar);
if (error) {
setModalMensaje("Error al eliminar la píldora");
} else {
setModalMensaje("Píldora eliminada exitosamente");
await cargarPildoras();
}
setConfirmarEliminar(false);
setMostrarModal(true);
};
return (
<Layout>
<div className="w-full pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Lista de Píldoras</h1>
<div className="overflow-x-auto w-full">
<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">Acciones</th>
</tr>
</thead>
<tbody>
{pildoras.map((pildora) =>
pildoraEditando === pildora.id ? (
<tr key={pildora.id}>
<td className="py-2 px-4 border-b text-center">
{pildora.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 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(pildora.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={pildora.id}>
<td className="py-2 px-4 border-b">{pildora.id}</td>
<td className="py-2 px-4 border-b">{pildora.nombre}</td>
<td className="py-2 px-4 border-b">
{pildora.descripcion}
</td>
<td className="py-2 px-4 border-b">{pildora.horas}</td>
<td className="py-2 px-4 border-b flex justify-center">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => iniciarEdicion(pildora)}
>
Editar
</Button>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => confirmarEliminacion(pildora.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
{/* Dialog para eliminar píldora */}
<Dialog open={confirmarEliminar} onOpenChange={setConfirmarEliminar}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Confirmar eliminación
</DialogTitle>
<DialogDescription>
¿Estás seguro de que deseas eliminar esta píldora? Esta acción no
se puede deshacer.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
className="bg-red-500 hover:bg-red-700 text-white"
onClick={eliminarPildora}
>
Eliminar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white"
onClick={() => setConfirmarEliminar(false)}
>
Cancelar
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Modal de resultado */}
<Dialog open={mostrarModal} onOpenChange={setMostrarModal}>
<DialogContent>
<DialogHeader>
<DialogTitle className="text-black">
Resultado de la operación
</DialogTitle>
<DialogDescription>{modalMensaje}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button onClick={() => setMostrarModal(false)}>Cerrar</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Layout>
);
}

View File

@ -0,0 +1,186 @@
import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase";
export default function VistaGeneral() {
const [cursos, setCursos] = useState([]);
const [inyecciones, setInyecciones] = useState([]);
const [pildoras, setPildoras] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
cargarTodo();
}, []);
const cargarTodo = async () => {
setLoading(true);
const [
{ data: cursosData },
{ data: inyeccionesData },
{ data: pildorasData },
] = await Promise.all([
supabaseClient
.from("curso")
.select(
`
id,
nombre,
descripcion,
horas,
curso_competencia (
competencia (
id,
descripcion
)
)
`
)
.order("id", { ascending: true }),
supabaseClient
.from("inyeccion")
.select("*")
.order("id", { ascending: true }),
supabaseClient
.from("pildoras")
.select("*")
.order("id", { ascending: true }),
]);
const cursosConCompetencias = (cursosData || []).map((curso) => ({
...curso,
competencias: Array.isArray(curso.curso_competencia)
? curso.curso_competencia.map((cc) => cc.competencia)
: [],
}));
setCursos(cursosConCompetencias);
setInyecciones(inyeccionesData || []);
setPildoras(pildorasData || []);
setLoading(false);
};
return (
<Layout>
<div className="w-full pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6">Vista General</h1>
{loading ? (
<div className="text-center text-lg">Cargando...</div>
) : (
<div className="w-full flex flex-col justify-center">
{/* Cursos */}
<div className="flex-1">
<h2 className="text-xl font-bold mb-2">Cursos</h2>
<table className="min-w-full bg-white border mb-8">
<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>
</tr>
</thead>
<tbody>
{cursos.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
Sin cursos registrados
</td>
</tr>
) : (
cursos.map((curso) => (
<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">
{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>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Inyecciones */}
<div className="flex-1">
<h2 className="text-xl font-bold mb-2">Inyecciones</h2>
<table className="min-w-full bg-white border mb-8">
<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>
</tr>
</thead>
<tbody>
{inyecciones.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
Sin inyecciones registradas
</td>
</tr>
) : (
inyecciones.map((iny) => (
<tr key={iny.id}>
<td className="py-2 px-4 border-b">{iny.id}</td>
<td className="py-2 px-4 border-b">{iny.nombre}</td>
<td className="py-2 px-4 border-b">
{iny.descripcion}
</td>
<td className="py-2 px-4 border-b">{iny.horas}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Píldoras */}
<div className="flex-1">
<h2 className="text-xl font-bold mb-2">Píldoras</h2>
<table className="min-w-full bg-white border mb-8">
<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>
</tr>
</thead>
<tbody>
{pildoras.length === 0 ? (
<tr>
<td colSpan={4} className="text-center py-4">
Sin píldoras registradas
</td>
</tr>
) : (
pildoras.map((p) => (
<tr key={p.id}>
<td className="py-2 px-4 border-b">{p.id}</td>
<td className="py-2 px-4 border-b">{p.nombre}</td>
<td className="py-2 px-4 border-b">{p.descripcion}</td>
<td className="py-2 px-4 border-b">{p.horas}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
</Layout>
);
}

View File

@ -12,5 +12,6 @@ export const alumnoSchema = z.object({
.regex(/^\d+$/, "El número de teléfono solo puede contener dígitos") .regex(/^\d+$/, "El número de teléfono solo puede contener dígitos")
.min(10, "El número de teléfono debe tener al menos 10 dígitos") .min(10, "El número de teléfono debe tener al menos 10 dígitos")
.max(10, "El número de teléfono no puede tener más de 10 dígitos"), .max(10, "El número de teléfono no puede tener más de 10 dígitos"),
cursoSeleccionado: z.string().nonempty("Selecciona un curso"), //tipo: z.string().nonempty("Selecciona un tipo de asignación"),
cursoSeleccionado: z.string().nonempty("Selecciona una opción"),
}); });

View File

@ -0,0 +1,10 @@
import { z } from "zod";
export const Schema = 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(),
});

View File

@ -0,0 +1,6 @@
import { z } from "zod";
export const mensajesSchema = z.object({
correo: z.string().min(1, "El mensaje de correo es obligatorio"),
whatsapp: z.string().min(1, "El mensaje de WhatsApp es obligatorio"),
});