From 9a1d8279a149cd75d08b3165df0091ae64dfc7e2 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 28 Nov 2025 02:55:04 -0600 Subject: [PATCH] los planes de estudios ya tienen la estructura de la plantilla de la sep. Ya se visualizan con esa estructura y se pueden crear planes con dicha estructura --- src/components/planes/CreatePlanDialog.tsx | 4 +- src/components/planes/DownloadPlanPDF.tsx | 460 +++------------------ 2 files changed, 49 insertions(+), 415 deletions(-) diff --git a/src/components/planes/CreatePlanDialog.tsx b/src/components/planes/CreatePlanDialog.tsx index c25b7e2..6675a32 100644 --- a/src/components/planes/CreatePlanDialog.tsx +++ b/src/components/planes/CreatePlanDialog.tsx @@ -217,7 +217,7 @@ export function CreatePlanDialog({ { headers: { Authorization: `Bearer ${token}` }, body: { - carreraId, + carrera_id: carreraId, prompt_usuario: prompt, insert: true, archivos_a_usar: [], @@ -226,7 +226,7 @@ export function CreatePlanDialog({ ); if (error) throw error; - const res = JSON.parse(data as string); + const res = data; const newId = (res as any)?.id || (res as any)?.plan?.id || (res as any)?.data?.id; diff --git a/src/components/planes/DownloadPlanPDF.tsx b/src/components/planes/DownloadPlanPDF.tsx index 5784e71..09fbab4 100644 --- a/src/components/planes/DownloadPlanPDF.tsx +++ b/src/components/planes/DownloadPlanPDF.tsx @@ -1,428 +1,62 @@ -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 // CORREGIDO: Se agregó 'object' +export type PlanLike = Record -// 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", - }) - console.log(plan); - - 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) + async function fetchPDF() { + try { + const response = await fetch( + "https://reportes-template.nicesand-99c6cbb8.westus2.azurecontainerapps.io/api/report", + { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/pdf", + }, + body: JSON.stringify({ + template: { name: "plan-estudios" }, + data: { + nombre_autorizado_de_la_institucion: "Probando pdf", + // (plan["nombre"] ? String(plan["nombre"]) : "Plan de estudios"), + }, + }), } + ) - // 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() - } - } + if (!response.ok) { + const text = await response.text().catch(() => "") + console.error("Error HTTP al obtener PDF:", response.status, text) + throw new Error(`HTTP ${response.status}`) + } - /** - * 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 + const ct = response.headers.get("Content-Type") || "" + if (!ct.includes("application/pdf")) { + const text = await response.text().catch(() => "") + console.error("Respuesta no-PDF recibida:", text) + } - // 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" }) + const blob = await response.blob() + const pdfUrl = URL.createObjectURL(blob) + window.open(pdfUrl, "_blank") - 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["nombre"] !== null && plan["nombre"] !== undefined ? String(plan["nombre"]) : "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`) + // Limpia el objeto URL después de un tiempo + setTimeout(() => URL.revokeObjectURL(pdfUrl), 60_000) + } catch (error) { + console.error("Error al obtener PDF:", error) } - - return ( - - - ) + ) } export default DownloadPlanPDF \ No newline at end of file