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

View File

@ -36,6 +36,15 @@ const data = {
},
],
},
/*{
title: "Vista general",
items: [
{
title: "Vista general",
url: "/vistaGeneral",
},
],
},*/
{
title: "Cursos",
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",
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 { PDFDownloadLink, PDFViewer, pdf } from "@react-pdf/renderer";
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({
open,
onOpenChange,
alumno,
curso,
competencias: competenciasProp,
fecha,
competenciasAcreditadas,
@ -20,8 +25,77 @@ function VistaPreviaDiplomaDialog({
const [mostrarVistaPrevia, setMostrarVistaPrevia] = useState(false);
const [enviando, setEnviando] = useState(false);
const [mensaje, setMensaje] = useState("");
const [loadingMensajes, setLoadingMensajes] = useState(false);
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(() => {
if (alumno && alumno.curso?.id) {
supabaseClient
@ -41,28 +115,25 @@ function VistaPreviaDiplomaDialog({
? competencias.filter((comp) => competenciasAcreditadas.includes(comp.id))
: competencias;
// Simulación de envío de PDF por correo y WhatsApp
const handleEnviar = async () => {
setEnviando(true);
setMensaje("");
// Genera el PDF como blob
const blob = await pdf(
<Diploma
alumno={alumno}
curso={alumno.curso}
curso={curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>
).toBlob();
// Convierte el blob a base64
const pdfBase64 = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(",")[1]);
reader.readAsDataURL(blob);
});
// Llama a tu API de Next.js
const resp = await fetch("/api/send-diploma", {
method: "POST",
headers: { "Content-Type": "application/json" },
@ -71,32 +142,30 @@ function VistaPreviaDiplomaDialog({
nombre: alumno.nombre,
curso: alumno.curso?.nombre || "Sin curso",
pdfBase64,
mensajeCorreo: getValues("correo"),
}),
});
if (resp.ok) {
// WhatsApp real (abre ventana)
const telefono = alumno.telefono.replace(/\D/g, "");
const mensajeWhatsapp = encodeURIComponent(
`Hola ${alumno.nombre}, tu diploma ha sido generado y enviado a tu correo (${alumno.correo}). ¡Felicidades!`
);
const mensajeWhatsapp = encodeURIComponent(getValues("whatsapp"));
window.open(
`https://wa.me/${telefono}?text=${mensajeWhatsapp}`,
"_blank"
);
setMensaje(
`Diploma enviado por correo a ${alumno.correo} y mensaje enviado por WhatsApp al ${alumno.telefono}.`
);
setMensaje(`Diploma enviado por correo a ${alumno.correo}.`);
} else {
setMensaje("Error enviando el diploma por correo.");
setMensaje("Error enviando el diploma.");
}
setEnviando(false);
};
if (!alumno) return null;
return (
<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>
<DialogTitle>Diploma</DialogTitle>
</DialogHeader>
@ -117,15 +186,35 @@ function VistaPreviaDiplomaDialog({
<div className="text-lg mb-2">
<b>Fecha:</b> {fecha || new Date().toLocaleDateString()}
</div>
<div className="mt-auto text-gray-400 text-xs text-right">
Vista previa
</div>
<div className="mt-4 flex gap-2 justify-center flex-wrap">
{/* Campos de mensaje */}
<form>
<div className="mb-4">
<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
document={
<Diploma
alumno={alumno}
curso={alumno.curso}
curso={curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>
@ -144,12 +233,14 @@ function VistaPreviaDiplomaDialog({
)
}
</PDFDownloadLink>
<button
className="bg-blue-500 hover:bg-blue-700 text-white px-4 py-2 rounded"
onClick={() => setMostrarVistaPrevia(true)}
>
Ver vista previa PDF
</button>
<button
className="bg-purple-600 hover:bg-purple-800 text-white px-4 py-2 rounded"
onClick={handleEnviar}
@ -157,20 +248,31 @@ function VistaPreviaDiplomaDialog({
>
{enviando ? "Enviando..." : "Enviar por correo y WhatsApp"}
</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>
{mensaje && (
<div className="mt-4 text-green-700 font-semibold text-center">
{mensaje}
</div>
)}
{/* Vista previa PDF */}
{mostrarVistaPrevia && (
<div className="fixed inset-0 bg-black bg-opacity-60 flex flex-col items-center justify-center z-50">
<div className="bg-white rounded shadow-lg p-4 flex flex-col items-center">
<div className="w-[80vw] h-[80vh] lg:h-[90vh] mb-4 border">
<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">
<div className="w-[80vw] h-[90vh] mb-4 border">
<PDFViewer width="100%" height="100%">
<Diploma
alumno={alumno}
curso={alumno.curso}
curso={curso}
competencias={competenciasMostradas}
fecha={fecha || new Date().toLocaleDateString()}
/>

View File

@ -1,7 +1,26 @@
"use client";
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 }) {
return (
@ -9,7 +28,8 @@ export default function Layout({ children }) {
<div className="flex">
<AppSidebar />
<SidebarInset>
<div className="p-4 w-full">{children}</div>
<SidebarTrigger className="-ml-1 text-black" />
<MainContent>{children}</MainContent>
</SidebarInset>
</div>
</SidebarProvider>

View File

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

View File

@ -3,9 +3,18 @@ import Papa from "papaparse";
import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { CursosManualForm } from "./cursosManual"; // Importa el formulario sin Layout
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { CursosManualForm } from "@/components/cursosManualForm";
import { supabaseClient } from "@/utils/supabase";
import { useRouter } from "next/router";
export default function AlumnosArchivo() {
const [archivo, setArchivo] = useState(null);
@ -14,11 +23,32 @@ export default function AlumnosArchivo() {
const [mensajeDialogo, setMensajeDialogo] = useState("");
const [mostrarDialogCurso, setMostrarDialogCurso] = useState(false);
const [cursoFaltante, setCursoFaltante] = useState("");
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);
// 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 () => {
if (datos.length === 0) return;
@ -26,11 +56,12 @@ export default function AlumnosArchivo() {
for (const alumno of datos) {
// 1. Verifica si el curso existe
const { data: cursosEncontrados, error: errorCurso } = await supabaseClient
.from("curso")
.select("id")
.eq("nombre", alumno.nombreCurso)
.maybeSingle();
const { data: cursosEncontrados, error: errorCurso } =
await supabaseClient
.from("curso")
.select("id")
.eq("nombre", alumno.nombreCurso)
.maybeSingle();
if (errorCurso) {
errores.push({ alumno, error: "Error al buscar el curso" });
@ -41,7 +72,9 @@ export default function AlumnosArchivo() {
// Si no existe el curso, muestra el dialog para registrar el curso
setCursoFaltante(alumno.nombreCurso);
setMostrarDialogCurso(true);
setMensajeDialogo(`El curso "${alumno.nombreCurso}" no existe. Por favor, regístralo primero.`);
setMensajeDialogo(
`El curso "${alumno.nombreCurso}" no existe. Por favor, regístralo primero.`
);
setDialogoAbierto(true);
return; // Detiene el registro de alumnos
}
@ -54,7 +87,7 @@ export default function AlumnosArchivo() {
nombre: alumno.nombre,
correo: alumno.correo,
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) {
setMensajeDialogo(`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`);
setMensajeDialogo(
`Se registraron algunos errores:\n${JSON.stringify(errores, null, 2)}`
);
} else {
setMensajeDialogo("Todos los alumnos fueron registrados correctamente.");
setArchivo(null);
@ -130,8 +165,8 @@ export default function AlumnosArchivo() {
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<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">
Nuevo alumno
</h1>
@ -144,7 +179,9 @@ export default function AlumnosArchivo() {
{archivo ? (
<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
type="file"
@ -169,7 +206,9 @@ export default function AlumnosArchivo() {
<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>
<th key={index} className="border px-4 py-2">
{columna}
</th>
))}
</tr>
</thead>
@ -177,7 +216,9 @@ export default function AlumnosArchivo() {
{datos.map((fila, index) => (
<tr key={index}>
{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>
))}
@ -196,7 +237,8 @@ export default function AlumnosArchivo() {
Registrar curso faltante
</DialogTitle>
<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>
</DialogHeader>
<CursosManualForm nombreSugerido={cursoFaltante} />
@ -215,6 +257,38 @@ export default function AlumnosArchivo() {
</DialogHeader>
</DialogContent>
</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>
);
}
}

View File

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

View File

@ -142,124 +142,126 @@ export default function AlumnosVista() {
return (
<Layout>
<div className="w-[80vw] pt-10 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mb-6 text-black">
<div className="w-full pt-5 flex flex-col items-center text-black">
<h1 className="text-2xl font-semibold mt-5 mb-10 text-black">
Lista de Alumnos
</h1>
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{alumnos.map((alumno) =>
alumnoEditando === alumno.id ? (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b text-center">
{alumno.id}
</td>
<td className="py-2 px-4 border-b">
<Input type="text" {...register("nombre")} />
{errors.nombre && (
<span className="text-red-500 text-xs">
{errors.nombre.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Input type="email" {...register("correo")} />
{errors.correo && (
<span className="text-red-500 text-xs">
{errors.correo.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Input type="text" {...register("telefono")} />
{errors.telefono && (
<span className="text-red-500 text-xs">
{errors.telefono.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Select
value={undefined}
onValueChange={(value) =>
setValue("cursoSeleccionado", value)
}
{...register("cursoSeleccionado")}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona un curso" />
</SelectTrigger>
<SelectContent>
{cursos.map((curso) => (
<SelectItem
key={curso.id}
value={curso.id.toString()}
>
{curso.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.cursoSeleccionado && (
<span className="text-red-500 text-xs">
{errors.cursoSeleccionado.message}
</span>
)}
</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={handleSubmit(guardarEdicion)}
>
Guardar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-2 rounded"
onClick={cancelarEdicion}
>
Cancelar
</Button>
</td>
</tr>
) : (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">
{alumno.curso?.nombre || "Sin curso"}
</td>
<td className="py-2 px-4 border-b space-x-2">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => iniciarEdicion(alumno)}
>
Editar
</Button>
<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">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{alumnos.map((alumno) =>
alumnoEditando === alumno.id ? (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b text-center">
{alumno.id}
</td>
<td className="py-2 px-4 border-b">
<Input type="text" {...register("nombre")} />
{errors.nombre && (
<span className="text-red-500 text-xs">
{errors.nombre.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Input type="email" {...register("correo")} />
{errors.correo && (
<span className="text-red-500 text-xs">
{errors.correo.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Input type="text" {...register("telefono")} />
{errors.telefono && (
<span className="text-red-500 text-xs">
{errors.telefono.message}
</span>
)}
</td>
<td className="py-2 px-4 border-b">
<Select
value={(alumno.curso_id || "").toString()}
onValueChange={(value) =>
setValue("cursoSeleccionado", value)
}
{...register("cursoSeleccionado")}
>
<SelectTrigger>
<SelectValue placeholder="Selecciona un curso" />
</SelectTrigger>
<SelectContent>
{cursos.map((curso) => (
<SelectItem
key={curso.id}
value={curso.id.toString()}
>
{curso.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.cursoSeleccionado && (
<span className="text-red-500 text-xs">
{errors.cursoSeleccionado.message}
</span>
)}
</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-1 rounded"
onClick={handleSubmit(guardarEdicion)}
>
Guardar
</Button>
<Button
className="bg-gray-400 hover:bg-gray-600 text-white font-bold py-1 px-3 m-1 rounded"
onClick={cancelarEdicion}
>
Cancelar
</Button>
</td>
</tr>
) : (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">
{alumno.curso?.nombre || "Sin curso"}
</td>
<td className="py-2 px-4 border-b 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(alumno)}
>
Editar
</Button>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 rounded"
onClick={() => confirmarEliminacion(alumno.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
<Button
className="bg-red-500 hover:bg-red-700 text-white font-bold py-1 px-3 m-1 rounded"
onClick={() => confirmarEliminacion(alumno.id)}
>
Eliminar
</Button>
</td>
</tr>
)
)}
</tbody>
</table>
</div>
</div>
{/* 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) {
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 {
await sgMail.send({
to: email,
from: "rviverosgonzalez@outlook.com", // Cambia esto por tu correo verificado en SendGrid
subject: "Tu diploma",
text: `Hola ${nombre}, has concluido tu curso ${curso} por lo que adjuntamos tu diploma.`,
// 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: [
{
content: pdfBase64,

View File

@ -3,7 +3,15 @@ import Papa from "papaparse";
import * as XLSX from "xlsx";
import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { useRouter } from "next/router";
export default function cursosArchivo() {
@ -53,7 +61,7 @@ export default function cursosArchivo() {
horas: curso.horas,
descripcion: curso.descripcion,
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
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 {
setMensajeDialogo("Todos los cursos fueron registrados correctamente.");
setArchivo(null);
@ -132,11 +142,9 @@ export default function cursosArchivo() {
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center text-black">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">
Nuevo curso
</h1>
<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">Nuevo curso</h1>
<label
htmlFor="archivo"
onDrop={manejarSoltar}
@ -146,7 +154,9 @@ export default function cursosArchivo() {
{archivo ? (
<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
type="file"
@ -171,7 +181,9 @@ export default function cursosArchivo() {
<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>
<th key={index} className="border px-4 py-2">
{columna}
</th>
))}
</tr>
</thead>
@ -179,7 +191,9 @@ export default function cursosArchivo() {
{datos.map((fila, index) => (
<tr key={index}>
{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>
))}
@ -216,7 +230,8 @@ export default function cursosArchivo() {
<DialogHeader>
<DialogTitle className="text-black">Advertencia</DialogTitle>
<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>
</DialogHeader>
<DialogFooter>
@ -242,4 +257,4 @@ export default function cursosArchivo() {
</Dialog>
</Layout>
);
}
}

View File

@ -6,7 +6,7 @@ import Layout from "@/components/layout/Layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { supabaseClient } from "@/utils/supabase"; // Importar el cliente de Supabase
import { supabaseClient } from "@/utils/supabase";
import {
Dialog,
DialogContent,
@ -17,11 +17,11 @@ import {
} from "@/components/ui/dialog";
export default function CursosManual() {
const [addCompetencia, setAddCompetencia] = useState(false);
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]);
const [competencias, setCompetencias] = useState([]); // [{id, descripcion}]
const [showDialog, setShowDialog] = useState(false);
const [dialogMsg, setDialogMsg] = useState("");
const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
const [addCompetencia, setAddCompetencia] = useState(false);
const form = useForm({
resolver: zodResolver(cursosSchema),
@ -32,7 +32,6 @@ export default function CursosManual() {
nuevaCompetencia: "",
},
});
const {
register,
handleSubmit,
@ -41,348 +40,211 @@ export default function CursosManual() {
formState: { errors },
} = form;
const handleAddCompetencia = () => {
setAddCompetencia(true);
};
const handleCancel = () => {
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
};
const handleSaveCompetencia = (e) => {
e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia");
if (nuevaCompetencia.trim() !== "") {
setCompetenciasGuardadas([
...competenciasGuardadas,
nuevaCompetencia.trim(),
]);
handleCancel();
}
};
const handleDeleteCompetencia = (index) => {
setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== index)
);
};
const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10); // Convertir horas a número
const competencias = competenciasGuardadas;
setLoading(true); // Mostrar estado de carga
try {
const { error } = await supabaseClient.from("curso").insert([
{
nombre,
descripcion,
horas,
competencias, // Guardar competencias como array
},
]);
if (error) {
console.error("Error al guardar en Supabase:", error.message);
alert("Error al guardar el curso: " + error.message);
} else {
setMensajeDialog("Curso guardado exitosamente");
setMostrarDialog(true);
form.reset(); // Reiniciar el formulario
setCompetenciasGuardadas([]); // Limpiar competencias guardadas
}
} catch (err) {
console.error("Error inesperado:", err);
alert("Ocurrió un error inesperado");
} finally {
setLoading(false); // Ocultar estado de carga
}
};
return (
<Layout>
<div className="w-[60vw] pt-10 flex flex-col items-end justify-center">
<div className="bg-white p-8 font-sans text-center w-[70%] flex flex-col items-center">
<h1 className="text-xl font-semibold mb-4 text-black">Nuevo curso</h1>
<CursosManualForm nombreSugerido="" />
</div>
</div>
</Layout>
);
}
export function CursosManualForm({ nombreSugerido = "" }) {
const [addCompetencia, setAddCompetencia] = useState(false);
const [competenciasGuardadas, setCompetenciasGuardadas] = useState([]); // [{id, descripcion}]
const [loading, setLoading] = useState(false);
const [mostrarDialog, setMostrarDialog] = useState(false);
const [mensajeDialog, setMensajeDialog] = useState("");
// Estado para dialog de competencia agregada
const [mostrarDialogCompetencia, setMostrarDialogCompetencia] = useState(false);
const form = useForm({
resolver: zodResolver(cursosSchema),
defaultValues: {
nombre: nombreSugerido,
descripcion: "",
horas: 0,
nuevaCompetencia: "",
},
});
const {
register,
handleSubmit,
setValue,
getValues,
formState: { errors },
} = form;
// Cambia handleSaveCompetencia para mostrar el dialog
// Añadir competencia (busca o crea en BD)
const handleSaveCompetencia = async (e) => {
e.preventDefault();
const nuevaCompetencia = getValues("nuevaCompetencia").trim();
if (!nuevaCompetencia) return;
// Verifica si ya existe en el estado
if (competenciasGuardadas.some((c) => c.descripcion === nuevaCompetencia)) {
alert("La competencia ya fue agregada.");
const desc = getValues("nuevaCompetencia").trim();
if (!desc) return;
if (competencias.some((c) => c.descripcion === desc)) {
setDialogMsg("La competencia ya fue agregada.");
setShowDialog(true);
return;
}
// Verifica si ya existe en la base de datos
let competenciaId = null;
try {
// Busca si ya existe
const { data: existente } = await supabaseClient
let { data: existente } = await supabaseClient
.from("competencia")
.select("id")
.eq("descripcion", nuevaCompetencia)
.eq("descripcion", desc)
.maybeSingle();
if (existente && existente.id) {
competenciaId = existente.id;
} else {
// Si no existe, la crea
let id = existente?.id;
if (!id) {
const { data: insertada, error } = await supabaseClient
.from("competencia")
.insert([{ descripcion: nuevaCompetencia }])
.insert([{ descripcion: desc }])
.select("id")
.single();
if (error) throw error;
competenciaId = insertada.id;
id = insertada.id;
}
setCompetenciasGuardadas([
...competenciasGuardadas,
{ id: competenciaId, descripcion: nuevaCompetencia },
]);
setCompetencias([...competencias, { id, descripcion: desc }]);
setAddCompetencia(false);
setValue("nuevaCompetencia", "");
setMostrarDialogCompetencia(true); // Mostrar dialog de éxito
setDialogMsg("¡La competencia fue agregada exitosamente!");
setShowDialog(true);
} 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) => {
setCompetenciasGuardadas(
competenciasGuardadas.filter((_, i) => i !== index)
);
setCompetencias(competencias.filter((_, i) => i !== index));
};
// Guardar curso y asociar competencias
const onSubmit = async (data) => {
const { nombre, descripcion } = data;
const horas = parseInt(data.horas, 10);
setLoading(true);
try {
// 1. Inserta el curso
const { data: cursoInsertado, error: errorCurso } = await supabaseClient
const { nombre, descripcion, horas } = data;
const { data: curso, error: errorCurso } = await supabaseClient
.from("curso")
.insert([{ nombre, descripcion, horas }])
.insert([{ nombre, descripcion, horas: parseInt(horas, 10) }])
.select("id")
.single();
if (errorCurso) {
setMensajeDialog("Error al guardar el curso: " + errorCurso.message);
setMostrarDialog(true);
setLoading(false);
return;
}
// 2. Inserta en la tabla pivote curso_competencia
const cursoId = cursoInsertado.id;
const relaciones = competenciasGuardadas.map((c) => ({
curso_id: cursoId,
competencia_id: c.id,
}));
if (relaciones.length > 0) {
if (errorCurso) throw errorCurso;
if (competencias.length) {
const relaciones = competencias.map((c) => ({
curso_id: curso.id,
competencia_id: c.id,
}));
const { error: errorPivote } = await supabaseClient
.from("curso_competencia")
.insert(relaciones);
if (errorPivote) {
setMensajeDialog("Error al asociar competencias: " + errorPivote.message);
setMostrarDialog(true);
setLoading(false);
return;
}
if (errorPivote) throw errorPivote;
}
setMensajeDialog("Curso guardado exitosamente");
setMostrarDialog(true);
setDialogMsg("Curso guardado exitosamente");
setCompetencias([]);
form.reset();
setCompetenciasGuardadas([]);
} catch (err) {
alert("Ocurrió un error inesperado");
setDialogMsg("Error: " + (err.message || err));
} finally {
setShowDialog(true);
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>
)}
<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">Nuevo curso</h1>
<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>
)}
<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>
)}
<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>
<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>
{competencias.length > 0 && (
<div className="mt-5 w-full flex-wrap">
{competencias.map((c, i) => (
<div
key={i}
className="w-full flex justify-between items-center px-2 mb-2"
>
<span className="text-black">{c.descripcion}</span>
<Button
type="button"
onClick={() => handleDeleteCompetencia(i)}
className="bg-red-400 hover:bg-red-500 text-white font-bold py-1 px-3 rounded-md"
>
X
</Button>
</div>
))}
</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">
{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="button"
onClick={handleSaveCompetencia}
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md mr-2"
type="submit"
className="bg-green-400 hover:bg-green-500 text-white font-bold py-2 px-4 rounded-md"
disabled={loading}
>
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
{loading ? "Guardando..." : "Guardar curso"}
</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>
<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>
<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>
</Layout>
);
}

View File

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

View File

@ -2,7 +2,6 @@ import React, { useEffect, useState } from "react";
import Layout from "@/components/layout/Layout";
import { supabaseClient } from "@/utils/supabase";
import { Button } from "@/components/ui/button";
import CrearDiplomaDialog from "@/components/dialogs/crearDiplomaDialog";
import VistaPreviaDiplomaDialog from "@/components/dialogs/vistaPreviaDiplomaDialog";
export default function DiplomasVista() {
@ -53,60 +52,51 @@ export default function DiplomasVista() {
return (
<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>
<table className="min-w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
</thead>
<tbody>
{alumnos.map((alumno) => (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">
{alumno.curso?.nombre || "Sin curso"}
</td>
<td className="py-2 px-4 border-b">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => {
setAlumnoSeleccionado(alumno);
setMostrarDialog(true);
}}
>
Crear Diploma
</Button>
</td>
<div className="overflow-x-auto w-full">
<table className="w-full bg-white border">
<thead>
<tr className="bg-gray-100">
<th className="py-2 border-b">ID</th>
<th className="py-2 border-b">Nombre</th>
<th className="py-2 border-b">Correo</th>
<th className="py-2 border-b">Teléfono</th>
<th className="py-2 border-b">Curso</th>
<th className="py-2 border-b">Acciones</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{alumnos.map((alumno) => (
<tr key={alumno.id}>
<td className="py-2 px-4 border-b">{alumno.id}</td>
<td className="py-2 px-4 border-b">{alumno.nombre}</td>
<td className="py-2 px-4 border-b">{alumno.correo}</td>
<td className="py-2 px-4 border-b">{alumno.telefono}</td>
<td className="py-2 px-4 border-b">
{alumno.curso?.nombre || "Sin curso"}
</td>
<td className="py-2 px-4 border-b">
<Button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-1 px-3 rounded"
onClick={() => {
setAlumnoSeleccionado(alumno);
setMostrarDialog(true);
}}
>
Crear Diploma
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Dialog para crear diploma y vista previa juntos */}
{mostrarDialog && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="flex gap-8 bg-black bg-opacity-30 p-8 rounded">
{/*<CrearDiplomaDialog
open={mostrarDialog}
onOpenChange={handleCloseDialog}
alumno={alumnoSeleccionado}
competencias={competencias}
setCompetencias={setCompetencias}
competenciasAcreditadas={competenciasAcreditadas}
setCompetenciasAcreditadas={setCompetenciasAcreditadas}
fecha={fecha}
setFecha={setFecha}
/>*/}
<VistaPreviaDiplomaDialog
open={mostrarDialog}
onOpenChange={handleCloseDialog}

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")
.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"),
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"),
});