Se crea funcionalidad de exportar pdf desde front y generar historial de version de cambios se agrego una libreri jspdf

This commit is contained in:
2025-11-05 15:19:38 -06:00
parent daac6f3f6d
commit 9462e25a20
8 changed files with 6622 additions and 91 deletions

5810
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"gsap": "^3.13.0", "gsap": "^3.13.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lucide-react": "^0.540.0", "lucide-react": "^0.540.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "react" import { useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase" import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { Button } from "../ui/button" import { Button } from "../ui/button"
import { import {
Dialog, Dialog,
@@ -35,11 +35,13 @@ export function EditBibliografiaButton({
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [text, setText] = useState("") const [text, setText] = useState("")
const auth = useSupabaseAuth()
const initialTextRef = useRef("") const initialTextRef = useRef("")
const lines = useMemo(() => parseLines(text), [text]) const lines = useMemo(() => parseLines(text), [text])
const dirty = useMemo(() => initialTextRef.current !== text, [text]) const dirty = useMemo(() => initialTextRef.current !== text, [text])
// 🔹 Abre el editor y carga los valores actuales
function openEditor() { function openEditor() {
const start = (value ?? []).join("\n") const start = (value ?? []).join("\n")
setText(start) setText(start)
@@ -47,52 +49,110 @@ export function EditBibliografiaButton({
setOpen(true) setOpen(true)
} }
// ✅ Función para generar diferencias tipo JSON Patch
function generateDiff(oldRefs: string[], newRefs: string[]) {
const changes: any[] = []
// Si son distintos en contenido o longitud
if (JSON.stringify(oldRefs) !== JSON.stringify(newRefs)) {
changes.push({
op: "replace",
path: "/bibliografia",
from: oldRefs,
value: newRefs,
})
}
return changes
}
async function save() { async function save() {
try {
setSaving(true) setSaving(true)
const refs = parseLines(text) try {
// 1⃣ Obtener bibliografía anterior
const { data: oldData, error: oldError } = await supabase
.from("asignaturas")
.select("bibliografia")
.eq("id", asignaturaId)
.maybeSingle()
if (oldError) throw oldError
const oldRefs = oldData?.bibliografia ?? []
const newRefs = parseLines(text)
// 2⃣ Generar diferencias
const diff = generateDiff(oldRefs, newRefs)
// 3⃣ Guardar respaldo si hay cambios
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // misma tabla de respaldo
.insert({
id_asignatura: asignaturaId,
json_cambios: diff, // jsonb
user_id: auth.user?.id,
created_at: new Date().toISOString(),
})
if (backupError) throw backupError
}
// 4⃣ Actualizar bibliografía en asignaturas
const { data, error } = await supabase const { data, error } = await supabase
.from("asignaturas") .from("asignaturas")
.update({ bibliografia: refs }) .update({ bibliografia: newRefs })
.eq("id", asignaturaId) .eq("id", asignaturaId)
.select() .select()
.maybeSingle() .maybeSingle()
if (error) throw error if (error) throw error
onSaved((data as any)?.bibliografia ?? refs) // 5⃣ Refrescar estado local
initialTextRef.current = refs.join("\n") onSaved((data as any)?.bibliografia ?? newRefs)
toast.success(`${refs.length} referencia(s) guardada(s).`) initialTextRef.current = newRefs.join("\n")
toast.success(`${newRefs.length} referencia(s) guardada(s).`)
setOpen(false) setOpen(false)
} catch (e: any) { } catch (err: any) {
toast.error(e?.message ?? "No se pudo guardar") toast.error(err.message ?? "No se pudo guardar la bibliografía")
} finally { } finally {
setSaving(false) setSaving(false)
} }
} }
// Acciones // 🔧 Acciones extra
function actionTrim() { function actionTrim() {
const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim()) const next = parseLines(text).map((s) => s.replace(/\s+/g, " ").trim())
setText(next.join("\n")) setText(next.join("\n"))
} }
function actionDedupe() { function actionDedupe() {
const seen = new Set<string>() const seen = new Set<string>()
const next: string[] = [] const next: string[] = []
for (const l of parseLines(text)) { for (const l of parseLines(text)) {
const k = l.toLowerCase() const k = l.toLowerCase()
if (!seen.has(k)) { seen.add(k); next.push(l) } if (!seen.has(k)) {
seen.add(k)
next.push(l)
}
} }
setText(next.join("\n")) setText(next.join("\n"))
} }
function actionSort() { function actionSort() {
const next = [...parseLines(text)].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) const next = [...parseLines(text)].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
)
setText(next.join("\n")) setText(next.join("\n"))
} }
async function actionImportClipboard() { async function actionImportClipboard() {
try { try {
const clip = await navigator.clipboard.readText() const clip = await navigator.clipboard.readText()
if (!clip) { toast("Portapapeles vacío"); return } if (!clip) {
toast("Portapapeles vacío")
return
}
const next = [...parseLines(text), ...parseLines(clip)] const next = [...parseLines(text), ...parseLines(clip)]
setText(next.join("\n")) setText(next.join("\n"))
toast.success("Texto importado") toast.success("Texto importado")
@@ -100,6 +160,7 @@ export function EditBibliografiaButton({
toast.error(e?.message ?? "No se pudo leer el portapapeles") toast.error(e?.message ?? "No se pudo leer el portapapeles")
} }
} }
async function actionExportClipboard() { async function actionExportClipboard() {
try { try {
await navigator.clipboard.writeText(parseLines(text).join("\n")) await navigator.clipboard.writeText(parseLines(text).join("\n"))
@@ -109,7 +170,7 @@ export function EditBibliografiaButton({
} }
} }
// Atajo guardar // ⌨️ Atajo Ctrl+S
useEffect(() => { useEffect(() => {
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (!open) return if (!open) return
@@ -120,7 +181,6 @@ export function EditBibliografiaButton({
} }
window.addEventListener("keydown", onKey) window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, saving, dirty, text]) }, [open, saving, dirty, text])
return ( return (

View File

@@ -0,0 +1,427 @@
import { jsPDF } from "jspdf"
import { Button } from "../ui/button"
import { Download } from "lucide-react"
// Importamos 'react' para poder usar el hook de estado si fuera necesario.
/**
* Tipo mínimo para el plan. Hemos añadido 'number' a la unión
* para permitir propiedades como 'total_creditos' que son numéricas,
* lo cual resuelve el error de asignación con PlanFull.
*/
export type PlanLike = Record<string, string | number | object | null | undefined> // CORREGIDO: Se agregó 'object'
// Usamos el tipo corregido PlanLike en la prop 'plan'
export function DownloadPlanPDF({ plan }: { plan: PlanLike }) {
// console.log(plan) // Mantener el log para debug
function generatePDF() {
// Inicialización del documento
const doc = new jsPDF({
orientation: "portrait",
unit: "mm",
format: "letter",
})
const pageWidth = doc.internal.pageSize.getWidth()
const pageHeight = doc.internal.pageSize.getHeight()
const margin = 20
const maxWidth = pageWidth - margin * 2
// Parámetros de estilo institucional (basados en las capturas)
const lineHeight = 5.0 // mm por línea (ajustado para más texto por página)
const sectionGap = 10 // Espacio entre recuadros de sección
const bodyFontSize = 10.5
const headingFontSize = 12
const subHeadingFontSize = 10
const bulletGlifo = "\u21D2" // Flecha doble (⇒) para el glifo
const bulletIndent = 6 // Sangría para el texto de la lista
let cursorY = margin
// Variable para controlar si ya se dibujaron todas las secciones (para la última caja)
let totalSections = 0;
let drawnSections = 0;
// --- Utilidades de Dibujo ---
// Dibuja el encabezado ("Anexo 1") y pie de página (Numeración) en todas las páginas
const drawHeaderAndFooter = () => {
// FIX: Usamos (doc as any) para acceder a getNumberOfPages() y evitar el error de TS
const pageCount = (doc as any).internal.getNumberOfPages()
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i)
// Encabezado (Anexo 1)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.text("Anexo 1", pageWidth - margin, margin - 5, { align: "right" })
// Pie de página (Numeración)
// Usamos el mismo tamaño y posición que en el ejemplo
doc.setFontSize(8)
doc.text(
`Página ${i} de ${pageCount}`,
pageWidth - margin, // Posicionado a la derecha
pageHeight - 10,
{ align: "right" }
)
}
// Regresar al último estado de la página para continuar dibujando
doc.setPage(pageCount)
}
// Verifica si se necesita una nueva página antes de dibujar una línea o un elemento.
const addPageIfNeeded = (neededHeight: number = lineHeight) => {
// Aseguramos que haya espacio para la altura necesaria + un poco de margen de seguridad
// El margen de seguridad ayuda a que la línea de pie de página no se solape
if (cursorY + neededHeight > pageHeight - 15) {
doc.addPage()
cursorY = margin
// El encabezado "Anexo 1" se dibuja al final en drawHeaderAndFooter()
}
}
/**
* Dibuja un título de sección con el estilo de recuadro gris (como en las capturas).
* Retorna la altura ocupada por el recuadro para el cálculo de la altura total de la sección.
*/
const drawHeadingBox = (text: string, marginTop: number = 0): number => {
doc.setFont("helvetica", "bold")
doc.setFontSize(headingFontSize)
// Espacio antes del título
cursorY += marginTop
const titleLines = doc.splitTextToSize(text.toUpperCase(), maxWidth - 4) // Pequeño padding
const titleHeight = titleLines.length * lineHeight + 4 // Texto + padding vertical
// 1. Verificar si el recuadro cabe en la página
addPageIfNeeded(titleHeight + 5) // 5mm de margen de seguridad
// 2. Dibujar Recuadro Gris (Relleno)
doc.setFillColor(230, 230, 230) // Gris claro
doc.rect(margin, cursorY, maxWidth, titleHeight, "F")
// 3. Dibujar texto centrado
const textX = pageWidth / 2
const textY = cursorY + titleHeight / 2 + 0.8 // 0.8mm para centrado óptico
doc.text(titleLines, textX, textY, { align: "center" })
cursorY += titleHeight // Avanzar el cursor justo después del recuadro
return titleHeight
}
/**
* Dibuja un bloque de texto (párrafo o lista) manejando el salto de página línea por línea,
* y envuelto en un recuadro.
*/
const drawContentBox = (text?: string | null, isList: boolean = false, isLastSection: boolean = false) => {
// Manejamos 'text' que ahora puede ser string o number
const content = (text !== null && text !== undefined) ? String(text).trim() : "Sin información."
doc.setFont("helvetica", "normal")
doc.setFontSize(bodyFontSize)
let initialY = cursorY // Guardar Y inicial para dibujar el recuadro final
// El contenido se dibuja en un recuadro. Dejamos un padding interno.
const innerMargin = margin + 2
const innerMaxWidth = maxWidth - 4
let currentContentY = cursorY + 2 // Iniciar con 2mm de padding superior
// Dividir el contenido en bloques (párrafos o ítems de lista)
const blocks = isList ?
content.split('\n').filter(line => line.trim().length > 0) :
content.split('\n').filter(line => line.trim().length > 0)
let contentDrawn = false
for (const block of blocks) {
let cleanBlock = block.trim()
// Si es lista, limpiamos los posibles marcadores (1., *, -)
if (isList) {
cleanBlock = cleanBlock.replace(/^(\d+\.|\*|[\-\•]|\u27A2|\u21D2)\s*/, '').trim()
}
if (!cleanBlock) continue
// Líneas que componen el bloque actual
const textWidth = isList ? innerMaxWidth - bulletIndent : innerMaxWidth
const lines = doc.splitTextToSize(cleanBlock, textWidth)
for (let i = 0; i < lines.length; i++) {
// 1. Verificar si se necesita un salto de página ANTES de dibujar la línea
if (currentContentY + lineHeight > pageHeight - 15) {
// Cierra el recuadro en la página actual
doc.rect(margin, initialY, maxWidth, pageHeight - 15 - initialY)
doc.addPage()
// En la nueva página, el punto de inicio del recuadro es el margen superior
initialY = margin
currentContentY = margin + 2 // Iniciar con padding
cursorY = margin // El cursorY global se actualiza para la siguiente sección/línea
}
const currentLine = lines[i]
if (isList && i === 0) {
// Dibujar el glifo solo en la primera línea del ítem
doc.text(bulletGlifo, innerMargin, currentContentY)
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
} else if (isList && i > 0) {
// Dibujar líneas subsiguientes con sangría (sin glifo)
doc.text(currentLine, innerMargin + bulletIndent, currentContentY)
} else {
// Dibujar párrafo normal
doc.text(currentLine, innerMargin, currentContentY)
}
currentContentY += lineHeight // Avanzar el cursor de contenido
}
// Espacio entre ítems de lista o entre párrafos
currentContentY += isList ? 1.5 : 4
contentDrawn = true
}
// 2. Después de dibujar todo el contenido, dibujar el recuadro exterior
if (contentDrawn) {
let finalY = currentContentY - 2 // Ajuste final de padding y espacio
// FIX: Usamos (doc as any) para acceder a los métodos internos y evitar el error de TS
if (isLastSection &&
(doc as any).internal.getCurrentPageInfo().pageNumber === (doc as any).internal.getNumberOfPages()) {
// Si es la ÚLTIMA sección Y estamos en la ÚLTIMA página,
// forzamos el recuadro a ir hasta el final (pageHeight - 15)
finalY = pageHeight - 15;
}
// Dibujar el recuadro completo (desde el Y inicial guardado hasta el Y final)
doc.rect(margin, initialY, maxWidth, finalY - initialY)
cursorY = finalY + sectionGap // Actualizar el cursor global para la siguiente sección
} else {
// Si no se dibuja contenido, solo saltar la altura del recuadro vacío.
doc.rect(margin, initialY, maxWidth, 10) // Dibuja una caja vacía de 10mm
cursorY += 10 + sectionGap
}
}
// --- Portada (Estilo Institucional) ---
const drawTitlePage = () => {
cursorY = 40 // Empezar más abajo
// UNIVERSIDAD LA SALLE
doc.setFont("helvetica", "bold")
doc.setFontSize(14)
doc.text("UNIVERSIDAD LA SALLE", pageWidth / 2, cursorY, { align: "center" })
cursorY += 5
// Separador horizontal
doc.line(margin, cursorY, pageWidth - margin, cursorY)
cursorY += 15
// LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES
doc.setFontSize(18)
// Manejamos la conversión a string si es necesario
const mainTitle = (plan["titulo"] !== null && plan["titulo"] !== undefined ? String(plan["titulo"]) : "LICENCIATURA EN INGENIERÍA CIBERNÉTICA Y SISTEMAS COMPUTACIONALES").toUpperCase()
const mainTitleLines = doc.splitTextToSize(mainTitle, maxWidth - 20)
doc.text(mainTitleLines, pageWidth / 2, cursorY, { align: "center" })
cursorY += mainTitleLines.length * 8
// Nivel y Nombre del Plan de Estudios
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
doc.text("Nivel y Nombre del Plan de Estudios", pageWidth / 2, cursorY, { align: "center" })
cursorY += 5
// Separador horizontal
doc.line(margin, cursorY, pageWidth - margin, cursorY)
cursorY += 10
// Escolar / Presencial (Modalidad Educativa)
doc.setFont("helvetica", "bold")
doc.setFontSize(14)
doc.text("Escolar / Presencial", pageWidth / 2, cursorY, { align: "center" })
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
cursorY += 5
doc.text("Modalidad Educativa", pageWidth / 2, cursorY, { align: "center" })
cursorY += 15
// Recuadros de Vigencia, Antecedente y Área (Simulación del Layout)
// Recuadro Vigencia (Parte superior central)
const boxWidth = maxWidth * 0.5
const boxX = (pageWidth - boxWidth) / 2
const boxY = cursorY
doc.rect(boxX, boxY, boxWidth, 20)
doc.rect(boxX, boxY + 15, boxWidth, 5)
doc.setFontSize(10)
doc.text("Vigencia", boxX + boxWidth / 2, boxY + 18, { align: "center" })
cursorY += 30 // Espacio para el primer recuadro
// Recuadro Antecedente Académico (Izquierda)
const smallBoxWidth = maxWidth * 0.4
const smallBoxHeight = 35
const smallBoxX1 = margin
doc.rect(smallBoxX1, cursorY, smallBoxWidth, smallBoxHeight)
doc.rect(smallBoxX1, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
doc.setFontSize(10)
doc.text("Bachillerato", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
doc.text("Antecedente Académico", smallBoxX1 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
// Recuadro Área de Estudio (Derecha)
const smallBoxX2 = pageWidth - margin - smallBoxWidth
doc.rect(smallBoxX2, cursorY, smallBoxWidth, smallBoxHeight)
doc.rect(smallBoxX2, cursorY + smallBoxHeight - 5, smallBoxWidth, 5)
doc.setFontSize(10)
doc.text("Ingeniería, manufactura y construcción", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight / 2, { align: "center" })
doc.text("Área de Estudio", smallBoxX2 + smallBoxWidth / 2, cursorY + smallBoxHeight - 2, { align: "center" })
cursorY += smallBoxHeight + 10
// Datos Fijos (Abajo)
doc.setFont("helvetica", "normal")
doc.setFontSize(10)
const drawDataPair = (label: string, value: string) => {
const labelX = margin
const valueX = margin + maxWidth * 0.4
doc.text(label + ":", labelX, cursorY)
doc.setFont("helvetica", "bold")
doc.text(value, valueX, cursorY)
doc.setFont("helvetica", "normal")
cursorY += 5
}
drawDataPair("Clave del Plan de Estudios", "2020")
drawDataPair("Diseño Curricular", "Rígido")
// Usamos plan.total_ciclos si existe
drawDataPair("Total de Ciclos del Plan de Estudios", plan["total_ciclos"] ? String(plan["total_ciclos"]) : "9")
drawDataPair("Duración del Ciclo Escolar", "16 semanas")
drawDataPair("Carga Horaria a la Semana", "27")
// Pie de página institucional (simulado)
doc.text(
"Dirección de Asuntos Académicos - Anexo 1",
pageWidth / 2,
pageHeight - margin,
{ align: "center" }
)
}
// --- Ejecución Principal ---
// 1. Dibuja la portada
drawTitlePage()
// 2. Comienza el contenido del plan en la nueva página
doc.addPage()
cursorY = margin
// Las secciones se ajustan a las que generas, pero también a las adicionales del documento de referencia
const SECTIONS: Array<{ key: string; title: string; isList: boolean }> = [
{ key: "objetivo_general", title: "Objetivo General", isList: false },
// La sección FIN DE APRENDIZAJE O FORMACIÓN es el Objetivo General del documento institucional, la mapearemos aquí.
{ key: "fin_aprendizaje", title: "FIN DE APRENDIZAJE O FORMACIÓN", isList: false }, // Mapea al objetivo general
{ key: "perfil_ingreso", title: "PERFIL DE INGRESO", isList: true },
{ key: "perfil_egreso", title: "PERFIL DE EGRESO", isList: true },
{ key: "competencias_genericas", title: "COMPETENCIAS GENÉRICAS", isList: true },
{ key: "competencias_especificas", title: "COMPETENCIAS ESPECÍFICAS", isList: true },
{ key: "indicadores_desempeno", title: "INDICADORES DE DESEMPEÑO", isList: true },
{ key: "sistema_evaluacion", title: "SISTEMA DE EVALUACIÓN", isList: false },
{ key: "pertinencia", title: "PERTINENCIA", isList: false },
// Nuevas secciones basadas en las imágenes que suelen ir con "No aplica"
{ key: "administracion", title: "ADMINISTRACIÓN Y OPERATIVIDAD DEL PLAN DE ESTUDIOS", isList: false },
{ key: "sustento_teorico", title: "SUSTENTO TEÓRICO DEL MODELO CURRICULAR", isList: false },
{ key: "justificacion_curricular", title: "JUSTIFICACIÓN DE LA PROPUESTA CURRICULAR EN LA MODALIDAD NO ESCOLARIZADA O MIXTA", isList: false },
{ key: "programa_investigacion", title: "PROGRAMA DE INVESTIGACIÓN", isList: false },
{ key: "curso_propedeutico", title: "CURSO PROPEDÉUTICO", isList: false },
{ key: "propuesta_evaluacion", title: "PROPUESTA DE EVALUACIÓN PERIÓDICA DEL PLAN DE ESTUDIOS", isList: false },
]
// Contar el número total de secciones con contenido
totalSections = SECTIONS.length;
for (const s of SECTIONS) {
drawnSections++; // Incrementar contador de secciones dibujadas
// Obtenemos el valor (que puede ser string, number, object, null, o undefined)
let value = plan[s.key]
// Mapeo especial para el objetivo general institucional (si existe)
if (s.key === "fin_aprendizaje" && (value === null || value === undefined)) {
value = plan["objetivo_general"]
}
// Inicializar content como string para pasarlo a la función de dibujo
let content: string | null = null;
// Si el valor no es nulo/undefined, convertir a string
if (value !== null && value !== undefined) {
// Si es un objeto, lo ignoramos y usamos un string vacío.
// Esto es clave para 'carreras', que no tiene un formato textual.
if (typeof value === 'object' && !Array.isArray(value)) {
content = "";
} else {
content = String(value);
}
}
// Si el contenido es nulo o vacío, usamos un placeholder común en el documento institucional
if (!content || content.trim() === "") {
// Para las secciones del plan generado, si no hay contenido, usar "Sin información."
if (["objetivo_general", "perfil_ingreso", "perfil_egreso", "competencias_genericas", "competencias_especificas", "indicadores_desempeno", "sistema_evaluacion", "pertinencia"].includes(s.key)) {
content = "Sin información."
} else {
// Para las secciones auxiliares del formato institucional
if (s.key === "administracion" || s.key === "sustento_teorico" || s.key === "justificacion_curricular" || s.key === "programa_investigacion") {
content = "No aplica"
} else if (s.key === "curso_propedeutico") {
content = "No tiene"
} else if (s.key === "propuesta_evaluacion") {
// Texto de la Propuesta de Evaluación (última página)
content = "La Universidad La Salle aplica una metodología para la evaluación y modificación de los programas académicos de licenciatura o posgrado que imparte. Los principales niveles, estudios, acciones y plazos que comprende dicha metodología son los siguientes:\n\nNIVEL DE EVALUACIÓN CURRICULAR INTERNA: DIAGNÓSTICO DE ESTRUCTURA Y OPERACIÓN.\n1. Análisis técnico-pedagógico del planteamiento curricular vigente.\n2. Estudio con directivos del área académica correspondiente, para analizar y valorar las problemáticas en la estructura y gestión del programa académico durante el periodo en que se ha desarrollado.\n3. Consulta a profesores sobre: a) problemáticas percibidas en la formación académica, profesional y actitudinal de los estudiantes, b) problemáticas en la operación, c) necesidades sociales, avances disciplinarios y/o tecnológicos detectados en su propio ejercicio profesional, que consideran importante incluir en el planteamiento curricular.\n4. Estudio de opinión de estudiantes sobre las problemáticas que aprecian en la formación que reciben respecto a la operación y estructura del programa académico.\n\nNIVEL DE EVALUACIÓN CURRICULAR EXTERNA: DIAGNÓSTICO DE IMPACTO Y PRÁCTICAS PROFESIONALES.\n5. Estudio sobre el estado del conocimiento en que se encuentran el o los campos disciplinarios vinculados con el programa académico, en México y, de ser posible, en otros países.\n6. Análisis de la oferta y la evolución que, en términos estadísticos, han tenido programas académicos similares en el ámbito de influencia y/o en el país.\n7. Estudio sobre requerimientos y tendencias en la formación, a partir del análisis de criterios, perfiles, estándares y parámetros de organismos evaluadores o acreditadores de programas académicos (si existen para el campo profesional), así como de la comparación general del programa en evaluación con otros similares y prestigiosos, de IES nacionales y, de ser posible, extranjeras.\n8. Estudio con egresados del programa académico para conocer su opinión sobre: a) el mismo programa; b) formación recibida; c) sitios de inserción laboral y características de sus prácticas profesionales, y d) aspectos disciplinarios, tecnológicos y/o actitudinales que, a la luz de su experiencia, consideren necesario incluir como parte de la formación.\n9. Estudio con empleadores para conocer su valoración sobre las prácticas profesionales de los egresados del programa académico, y su apreciación sobre nuevos requerimientos en el campo."
} else {
continue; // Si sigue siendo nulo, saltar la sección
}
}
}
// Determinar si es la última sección que se dibujará
const isLastSection = drawnSections === totalSections;
// Dibuja el recuadro del título de la sección
drawHeadingBox(s.title, sectionGap)
// Dibuja el contenido de la sección dentro de su recuadro.
// Pasamos isLastSection para que drawContentBox sepa si debe forzar el cierre.
drawContentBox(content, s.isList, isLastSection)
}
// Finalizar y dibujar encabezados/pies en todas las páginas (se dibuja en el paso final)
drawHeaderAndFooter()
// Guardar el documento
const name = (plan["prompt"] ? `Plan_${plan["prompt"]}` : `Plan_de_estudios`).replace(/\s+/g, "_")
doc.save(`${name}_Institucional.pdf`)
}
return (
<Button variant="outline" className="flex items-center gap-2 " onClick={generatePDF}>
Descargar PDF
<Download className="w-4 h-4" />
</Button>
)
}
export default DownloadPlanPDF

View File

@@ -4,7 +4,7 @@ import { useSuspenseQuery, useMutation, useQueryClient, queryOptions } from "@ta
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { supabase } from "@/auth/supabase" import { supabase,useSupabaseAuth } from "@/auth/supabase"
import { toast } from "sonner" import { toast } from "sonner"
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
@@ -111,6 +111,7 @@ function SectionPanel({ title, icon: Icon, color, children, id }: { title: strin
===================================================== */ ===================================================== */
export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) { export function AcademicSections({ planId, color }: { planId: string; color?: string | null }) {
const qc = useQueryClient() const qc = useQueryClient()
const auth = useSupabaseAuth()
if(!planId) return <div>Cargando</div> if(!planId) return <div>Cargando</div>
const { data: plan } = useSuspenseQuery(planTextOptions(planId)) const { data: plan } = useSuspenseQuery(planTextOptions(planId))
@@ -197,18 +198,56 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
</div> </div>
{/* Diálogo de edición */} {/* Diálogo de edición */}
<Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}> <Dialog open={!!editing} onOpenChange={(o) => { if (!o) setEditing(null) }}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="font-mono" >{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}</DialogTitle> <DialogTitle className="font-mono">
{editing ? `Editar: ${sections.find((x) => x.key === editing.key)?.title}` : ""}
</DialogTitle>
</DialogHeader> </DialogHeader>
<Textarea value={draft} onChange={(e) => setDraft(e.target.value)} className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`} placeholder="Escribe aquí…" />
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
className={`min-h-[260px] ${editing?.key === "prompt" ? "font-mono" : ""}`}
placeholder="Escribe aquí…"
/>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button> <Button variant="outline" onClick={() => setEditing(null)}>Cancelar</Button>
<Button <Button
onClick={() => { onClick={async () => {
if (!editing) return if (!editing) return
// 1⃣ Obtener el valor anterior (por ejemplo, desde 'plan' o 'section')
const oldValue = (plan as any)[editing.key]
// 2⃣ Crear un diff tipo JSON Patch
const diff = [{
op: "replace",
path: `/${editing.key}`,
from: oldValue,
value: draft
}]
// 3⃣ Guardar respaldo antes de actualizar
const { error: backupError } = await supabase.from("historico_cambios").insert({
id_plan_estudios: planId, // asegúrate de tener `plan.id` disponible en este contexto
json_cambios: diff,
user_id:auth.user?.id,
created_at: new Date().toISOString()
})
if (backupError) {
console.error("Error al guardar respaldo:", backupError)
alert("No se pudo guardar el respaldo de los cambios")
return
}
// 4⃣ Ejecutar la mutación original
updateField.mutate({ key: editing.key, value: draft }) updateField.mutate({ key: editing.key, value: draft })
// 5⃣ Cerrar el diálogo
setEditing(null) setEditing(null)
}} }}
disabled={updateField.isPending} disabled={updateField.isPending}
@@ -218,6 +257,7 @@ export function AcademicSections({ planId, color }: { planId: string; color?: st
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
) )
} }

View File

@@ -0,0 +1,67 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { supabase } from "@/auth/supabase";
/**
* Hook genérico para actualizar una tabla y guardar respaldo en historico_cambios
*
* @param tableName Nombre de la tabla a actualizar
* @param idKey Campo que se usa para hacer eq (por defecto 'id')
*/
export function useSupabaseUpdateWithHistory<T extends Record<string, any>>(
tableName: string,
idKey: keyof T = "id" as keyof T
) {
const qc = useQueryClient();
// Generar diferencias tipo JSON Patch
function generateDiff(oldData: T, newData: Partial<T>) {
const changes: any[] = [];
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key];
const newValue = (newData as any)[key];
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue,
});
}
}
return changes;
}
const mutation = useMutation({
mutationFn: async ({
oldData,
newData,
}: {
oldData: T;
newData: Partial<T>;
}) => {
const diff = generateDiff(oldData, newData);
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios")
.insert({
id_plan_estudios: oldData.id ?? null, // ajusta si es otra tabla
tabla_afectada: tableName,
json_cambios: diff,
created_at: new Date().toISOString(),
});
if (backupError) throw backupError;
}
const { error } = await supabase
.from(tableName)
.update(newData)
.eq(idKey as string, oldData[idKey]);
if (error) throw error;
},
});
return { mutation };
}

View File

@@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { createFileRoute, Link, useRouter } from "@tanstack/react-router" import { createFileRoute, Link, useRouter } from "@tanstack/react-router"
import * as Icons from "lucide-react" import * as Icons from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { supabase } from "@/auth/supabase" import { supabase, useSupabaseAuth } from "@/auth/supabase"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -403,11 +403,32 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [form, setForm] = useState<Partial<Asignatura>>({}) const [form, setForm] = useState<Partial<Asignatura>>({})
const auth = useSupabaseAuth()
const openAndFill = () => { setForm(asignatura); setOpen(true) } const openAndFill = () => { setForm(asignatura); setOpen(true) }
// ✅ Función que genera las diferencias entre los datos anteriores y los nuevos
function generateDiff(oldData: Asignatura, newData: Partial<Asignatura>) {
const changes: any[] = []
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key]
const newValue = (newData as any)[key]
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue
})
}
}
return changes
}
async function save() { async function save() {
setSaving(true) setSaving(true)
try {
// 1⃣ Preparar el payload final
const payload = { const payload = {
nombre: form.nombre ?? asignatura.nombre, nombre: form.nombre ?? asignatura.nombre,
clave: form.clave ?? asignatura.clave, clave: form.clave ?? asignatura.clave,
@@ -417,15 +438,44 @@ function EditAsignaturaButton({ asignatura, onUpdate }: {
horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas, horas_teoricas: form.horas_teoricas ?? asignatura.horas_teoricas,
horas_practicas: form.horas_practicas ?? asignatura.horas_practicas, horas_practicas: form.horas_practicas ?? asignatura.horas_practicas,
} }
// 2⃣ Detectar cambios
const diff = generateDiff(asignatura, payload)
// 3⃣ Guardar respaldo si hubo cambios
if (diff.length > 0) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // 👈 usa el nombre real de tu tabla
.insert({
id_asignatura: asignatura.id,
json_cambios: diff, // jsonb
user_id: auth.user?.id,
created_at: new Date().toISOString()
})
if (backupError) throw backupError
}
// 4⃣ Actualizar el registro principal
const { data, error } = await supabase const { data, error } = await supabase
.from("asignaturas") .from("asignaturas")
.update(payload) .update(payload)
.eq("id", asignatura.id) .eq("id", asignatura.id)
.select() .select()
.maybeSingle() .maybeSingle()
if (error) throw error
// 5⃣ Actualizar vista local
if (data) {
onUpdate(data as Asignatura)
setOpen(false)
}
} catch (err: any) {
alert(err.message ?? "Error al guardar")
} finally {
setSaving(false) setSaving(false)
if (!error && data) { onUpdate(data as Asignatura); setOpen(false) } }
else alert(error?.message ?? "Error al guardar")
} }
return ( return (
@@ -650,6 +700,7 @@ export function EditContenidosButton({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [units, setUnits] = useState<UnitDraft[]>([]) const [units, setUnits] = useState<UnitDraft[]>([])
const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([]) const [initialUnits, setInitialUnits] = useState<UnitDraft[]>([])
const auth = useSupabaseAuth() // 👈 para registrar el usuario que edita
// --- Normaliza entrada flexible a estructura estable // --- Normaliza entrada flexible a estructura estable
const normalize = useCallback((v: any): UnitDraft[] => { const normalize = useCallback((v: any): UnitDraft[] => {
@@ -685,7 +736,7 @@ export function EditContenidosButton({
} }
}, []) }, [])
// --- Construye payload consistente { "1": { titulo, subtemas:{ "1": "t1" } } } // --- Construye payload consistente
const buildPayload = useCallback((us: UnitDraft[]) => { const buildPayload = useCallback((us: UnitDraft[]) => {
const out: Record<string, any> = {} const out: Record<string, any> = {}
us.forEach((u, idx) => { us.forEach((u, idx) => {
@@ -702,9 +753,9 @@ export function EditContenidosButton({
return out return out
}, []) }, [])
// --- Limpia UI: recorta espacios, elimina líneas vacías/duplicadas (case-insensitive) // --- Limpia UI
const cleanUnits = useCallback((us: UnitDraft[]) => { const cleanUnits = useCallback((us: UnitDraft[]) => {
return us.map((u, idx) => { return us.map((u) => {
const seen = new Set<string>() const seen = new Set<string>()
const temas = u.temas const temas = u.temas
.map((t) => t.trim()) .map((t) => t.trim())
@@ -715,10 +766,7 @@ export function EditContenidosButton({
seen.add(key) seen.add(key)
return true return true
}) })
return { return { title: (u.title || "").trim(), temas }
title: (u.title || "").trim(),
temas,
}
}) })
}, []) }, [])
@@ -734,7 +782,7 @@ export function EditContenidosButton({
[units, initialUnits, cleanUnits], [units, initialUnits, cleanUnits],
) )
// --- Atajos: Guardar con Ctrl/Cmd + Enter // --- Atajos: Ctrl/Cmd + Enter
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@@ -746,7 +794,6 @@ export function EditContenidosButton({
} }
window.addEventListener("keydown", handler) window.addEventListener("keydown", handler)
return () => window.removeEventListener("keydown", handler) return () => window.removeEventListener("keydown", handler)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, units, saving]) }, [open, units, saving])
// --- Acciones por unidad // --- Acciones por unidad
@@ -754,6 +801,7 @@ export function EditContenidosButton({
if (!confirm("¿Eliminar esta unidad?")) return if (!confirm("¿Eliminar esta unidad?")) return
setUnits((prev) => prev.filter((_, i) => i !== idx)) setUnits((prev) => prev.filter((_, i) => i !== idx))
} }
const moveUnit = (idx: number, dir: -1 | 1) => { const moveUnit = (idx: number, dir: -1 | 1) => {
setUnits((prev) => { setUnits((prev) => {
const next = [...prev] const next = [...prev]
@@ -763,6 +811,7 @@ export function EditContenidosButton({
return next return next
}) })
} }
const duplicateUnit = (idx: number) => { const duplicateUnit = (idx: number) => {
setUnits((prev) => { setUnits((prev) => {
const next = [...prev] const next = [...prev]
@@ -774,24 +823,54 @@ export function EditContenidosButton({
}) })
} }
// ✅ Función para guardar con respaldo histórico
async function save() { async function save() {
setSaving(true) setSaving(true)
try {
const cleaned = cleanUnits(units) const cleaned = cleanUnits(units)
const contenidos = buildPayload(cleaned) const contenidos = buildPayload(cleaned)
// 1⃣ Generar diff entre valor anterior y nuevo
const diff = [
{
op: "replace",
path: "/contenidos",
from: value,
value: contenidos,
},
]
// 2⃣ Guardar respaldo en tabla de histórico (solo si hay cambios)
if (JSON.stringify(value) !== JSON.stringify(contenidos)) {
const { error: backupError } = await supabase
.from("historico_cambios_asignaturas") // 👈 nombre de tu tabla de respaldo
.insert({
id_asignatura: asignaturaId,
json_cambios: diff,
user_id: auth.user?.id,
created_at: new Date().toISOString(),
})
if (backupError) throw backupError
}
// 3⃣ Actualizar campo contenidos
const { data, error } = await supabase const { data, error } = await supabase
.from("asignaturas") .from("asignaturas")
.update({ contenidos }) .update({ contenidos })
.eq("id", asignaturaId) .eq("id", asignaturaId)
.select() .select()
.maybeSingle() .maybeSingle()
setSaving(false)
if (error) { if (error) throw error
alert(error.message || "No se pudo guardar")
return
}
setInitialUnits(cleaned) setInitialUnits(cleaned)
onSaved((data as any)?.contenidos ?? contenidos) onSaved((data as any)?.contenidos ?? contenidos)
setOpen(false) setOpen(false)
} catch (err: any) {
alert(err.message || "Error al guardar contenidos")
} finally {
setSaving(false)
}
} }
const cancel = () => { const cancel = () => {

View File

@@ -20,6 +20,7 @@ import { AuroraButton } from "@/components/effect/aurora-button"
import { DeletePlanButton } from "@/components/planes/DeletePlan" import { DeletePlanButton } from "@/components/planes/DeletePlan"
import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton" import { AddAsignaturaButton } from "@/components/planes/AddAsignaturaButton"
import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton" import { DescargarPdfButton } from "@/components/planes/GenerarPdfButton"
import { DownloadPlanPDF } from "@/components/planes/DownloadPlanPDF"
type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] } type LoaderData = { plan: PlanFull; asignaturas: AsignaturaLite[] }
@@ -105,7 +106,8 @@ function RouteComponent() {
{/* <div className='flex gap-2'> */} {/* <div className='flex gap-2'> */}
<EditPlanButton plan={plan} /> <EditPlanButton plan={plan} />
<AdjustAIButton plan={plan} /> <AdjustAIButton plan={plan} />
<DescargarPdfButton planId={plan.id} opcion="plan" /> {/* <DescargarPdfButton planId={plan.id} opcion="plan" /> */}
<DownloadPlanPDF plan={plan} />
<DescargarPdfButton planId={plan.id} opcion="asignaturas" /> <DescargarPdfButton planId={plan.id} opcion="asignaturas" />
<DeletePlanButton planId={plan.id} /> <DeletePlanButton planId={plan.id} />
{/* </div> */} {/* </div> */}
@@ -203,33 +205,77 @@ function StatCard({ label, value = "—", Icon = Icons.Info, accent, className =
/* ===== Editar ===== */ /* ===== Editar ===== */
function EditPlanButton({ plan }: { plan: PlanFull }) { function EditPlanButton({ plan }: { plan: PlanFull }) {
const auth = useSupabaseAuth()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [form, setForm] = useState<Partial<PlanFull>>({}) const [form, setForm] = useState<Partial<PlanFull>>({})
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const qc = useQueryClient() const qc = useQueryClient()
// Función para comparar valores y generar diffs tipo JSON Patch
function generateDiff(oldData: PlanFull, newData: Partial<PlanFull>) {
const changes: any[] = []
for (const key of Object.keys(newData)) {
const oldValue = (oldData as any)[key]
const newValue = (newData as any)[key]
if (newValue !== undefined && newValue !== oldValue) {
changes.push({
op: "replace",
path: `/${key}`,
from: oldValue,
value: newValue
})
}
}
return changes
}
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (payload: Partial<PlanFull>) => { mutationFn: async (payload: Partial<PlanFull>) => {
const { error } = await supabase.from('plan_estudios').update({ // 1⃣ Generar las diferencias antes del update
const diff = generateDiff(plan, payload)
// 2⃣ Guardar respaldo (solo si hay cambios)
if (diff.length > 0) {
const { error: backupError } = await supabase.from("historico_cambios").insert({
id_plan_estudios: plan.id,
json_cambios: diff, // jsonb
user_id:auth.user?.id,
created_at: new Date().toISOString()
})
if (backupError) throw backupError
}
// 3⃣ Actualizar el plan principal
const { error } = await supabase
.from("plan_estudios")
.update({
nombre: payload.nombre ?? plan.nombre, nombre: payload.nombre ?? plan.nombre,
nivel: payload.nivel ?? plan.nivel, nivel: payload.nivel ?? plan.nivel,
duracion: payload.duracion ?? plan.duracion, duracion: payload.duracion ?? plan.duracion,
total_creditos: payload.total_creditos ?? plan.total_creditos, total_creditos: payload.total_creditos ?? plan.total_creditos,
}).eq('id', plan.id) })
.eq("id", plan.id)
if (error) throw error if (error) throw error
}, },
onMutate: async (payload) => { onMutate: async (payload) => {
await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) }) await qc.cancelQueries({ queryKey: planKeys.byId(plan.id) })
const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id)) const prev = qc.getQueryData<PlanFull>(planKeys.byId(plan.id))
qc.setQueryData<PlanFull>(planKeys.byId(plan.id), (old) => old ? { ...old, ...payload } as PlanFull : old as any) qc.setQueryData<PlanFull>(
planKeys.byId(plan.id),
(old) => (old ? { ...old, ...payload } as PlanFull : old as any)
)
return { prev } return { prev }
}, },
onError: (_e, _vars, ctx) => { onError: (_e, _vars, ctx) => {
if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev) if (ctx?.prev) qc.setQueryData(planKeys.byId(plan.id), ctx.prev)
}, },
onSettled: async () => { onSettled: async () => {
await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) }) await qc.invalidateQueries({ queryKey: planKeys.byId(plan.id) })
} },
}) })
async function save() { async function save() {