Merge pull request 'Se renderizan las previsualizaciones del plan y de la asignatura y también se pueden descargar como word o pdf' (#211) from issue/200-renderizado-de-plantillas-con-edge-function-de-car into main
All checks were successful
Deploy to Azure Static Web Apps / build-and-deploy (push) Successful in 1m21s

Reviewed-on: #211
This commit was merged in pull request #211.
This commit is contained in:
2026-03-20 23:47:37 +00:00
8 changed files with 398 additions and 146 deletions

View File

@@ -8,10 +8,11 @@ import {
Clock,
FileJson,
} from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { usePlan } from '@/data'
import { fetchPlanPdf } from '@/data/api/document.api'
export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
@@ -20,30 +21,41 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const { data: plan } = usePlan(planId)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const planFileBaseName = sanitizeFileBaseName(plan?.nombre ?? 'plan_estudios')
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [planId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
@@ -51,12 +63,13 @@ function RouteComponent() {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'plan_estudios.pdf'
link.download = `${planFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
@@ -67,6 +80,27 @@ function RouteComponent() {
alert('No se pudo generar el PDF')
}
}
const handleDownloadWord = async () => {
try {
const docBlob = await fetchPlanPdf({
plan_estudio_id: planId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${planFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
setTimeout(() => window.URL.revokeObjectURL(url), 1000)
} catch (error) {
console.error(error)
alert('No se pudo generar el Word')
}
}
return (
<div className="flex min-h-screen flex-col gap-6 bg-slate-50/30 p-6">
{/* HEADER DE ACCIONES */}
@@ -88,12 +122,17 @@ function RouteComponent() {
>
<RefreshCcw size={16} /> Regenerar
</Button>
<Button variant="outline" size="sm" className="gap-2">
<Download size={16} /> Descargar Word
</Button>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Download size={16} /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleDownloadPdf}
>
<Download size={16} /> Descargar PDF
@@ -139,7 +178,7 @@ function RouteComponent() {
)}
</div>
<CardContent className="flex min-h-[800px] justify-center bg-slate-500 p-0">
<CardContent className="flex min-h-200 justify-center bg-slate-500 p-0">
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 text-white">
<RefreshCcw size={40} className="animate-spin opacity-50" />
@@ -149,7 +188,7 @@ function RouteComponent() {
/* 3. VISOR DE PDF REAL */
<iframe
src={`${pdfUrl}#toolbar=0&navpanes=0`}
className="h-[1000px] w-full max-w-[1000px] border-none shadow-2xl"
className="h-250 w-full max-w-250 border-none shadow-2xl"
title="PDF Preview"
/>
) : (
@@ -163,6 +202,24 @@ function RouteComponent() {
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}
// Componente pequeño para las tarjetas de estado superior
function StatusCard({
icon,

View File

@@ -1,7 +1,8 @@
import { createFileRoute, useParams } from '@tanstack/react-router'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { DocumentoSEPTab } from '@/components/asignaturas/detalle/DocumentoSEPTab'
import { useSubject } from '@/data'
import { fetchAsignaturaPdf } from '@/data/api/document.api'
export const Route = createFileRoute(
@@ -15,48 +16,75 @@ function RouteComponent() {
from: '/planes/$planId/asignaturas/$asignaturaId/documento',
})
const { data: asignatura } = useSubject(asignaturaId)
const asignaturaFileBaseName = sanitizeFileBaseName(
asignatura?.nombre ?? 'documento_sep',
)
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const isMountedRef = useRef<boolean>(false)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
if (isMountedRef.current) setIsLoading(true)
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
if (!isMountedRef.current) return
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
return url
})
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
pdfUrlRef.current = url
setPdfUrl(url)
} catch (error) {
console.error('Error cargando PDF:', error)
} finally {
setIsLoading(false)
if (isMountedRef.current) setIsLoading(false)
}
}, [asignaturaId])
useEffect(() => {
isMountedRef.current = true
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
isMountedRef.current = false
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
const handleDownload = async () => {
const handleDownloadPdf = async () => {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = url
link.download = 'documento_sep.pdf'
link.download = `${asignaturaFileBaseName}.pdf`
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleDownloadWord = async () => {
const docBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
})
const url = window.URL.createObjectURL(docBlob)
const link = document.createElement('a')
link.href = url
link.download = `${asignaturaFileBaseName}.docx`
document.body.appendChild(link)
link.click()
link.remove()
@@ -77,9 +105,28 @@ function RouteComponent() {
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>
)
}
function sanitizeFileBaseName(input: string): string {
const text = String(input)
const withoutControlChars = Array.from(text)
.filter((ch) => {
const code = ch.charCodeAt(0)
return code >= 32 && code !== 127
})
.join('')
const cleaned = withoutControlChars
.replace(/[<>:"/\\|?*]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/[. ]+$/g, '')
return (cleaned || 'documento').slice(0, 150)
}