Se descargan correctamente los docx del plan y de la asignatura

This commit is contained in:
2026-03-20 17:31:59 -06:00
parent b986ec343e
commit 1bce226d15
5 changed files with 176 additions and 82 deletions

View File

@@ -18,7 +18,8 @@ import { Card } from '@/components/ui/card'
interface DocumentoSEPTabProps {
pdfUrl: string | null
isLoading: boolean
onDownload: () => void
onDownloadPdf: () => void
onDownloadWord: () => void
onRegenerate: () => void
isRegenerating: boolean
}
@@ -26,7 +27,8 @@ interface DocumentoSEPTabProps {
export function DocumentoSEPTab({
pdfUrl,
isLoading,
onDownload,
onDownloadPdf,
onDownloadWord,
onRegenerate,
isRegenerating,
}: DocumentoSEPTabProps) {
@@ -52,25 +54,23 @@ export function DocumentoSEPTab({
</div>
<div className="flex items-center gap-2">
{pdfUrl && !isLoading && (
<Button variant="outline" onClick={onDownload}>
<Download className="mr-2 h-4 w-4" />
Descargar
</Button>
)}
<AlertDialog
open={showConfirmDialog}
onOpenChange={setShowConfirmDialog}
>
<AlertDialogTrigger asChild>
<Button disabled={isRegenerating}>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={isRegenerating}
>
{isRegenerating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="mr-2 h-4 w-4" />
<RefreshCw className="h-4 w-4" />
)}
{isRegenerating ? 'Generando...' : 'Regenerar documento'}
{isRegenerating ? 'Generando...' : 'Regenerar'}
</Button>
</AlertDialogTrigger>
@@ -91,11 +91,31 @@ export function DocumentoSEPTab({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={onDownloadPdf}
>
<Download className="h-4 w-4" /> Descargar PDF
</Button>
</>
)}
</div>
</div>
{/* PDF Preview */}
<Card className="h-[800px] overflow-hidden">
<Card className="h-200 overflow-hidden">
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin" />

View File

@@ -7,74 +7,29 @@ import { requireData, throwIfError } from './_helpers'
import type { Tables } from '@/types/supabase'
import { columnParsers } from '@/lib/asignaturaColumnParsers'
const EDGE = {
carbone_io_wrapper: 'carbone-io-wrapper',
} as const
interface GeneratePdfParams {
plan_estudio_id: string
convertTo?: 'pdf'
}
interface GeneratePdfParamsAsignatura {
asignatura_id: string
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function toStringValue(value: unknown): string {
if (typeof value === 'string') return value
if (typeof value === 'number' || typeof value === 'boolean')
return String(value)
if (value === null || value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
function buildAsignaturaReportData(
row: Pick<
Tables<'asignaturas'>,
'datos' | 'contenido_tematico' | 'criterios_de_evaluacion'
>,
): Record<string, string> {
const out: Record<string, string> = {}
const datosRaw = row.datos
if (isPlainRecord(datosRaw)) {
for (const [k, v] of Object.entries(datosRaw)) {
if (v === null || v === undefined) continue
out[k] = toStringValue(v)
}
}
for (const [key, parser] of Object.entries(columnParsers)) {
if (!parser) continue
const current = out[key]
if (typeof current === 'string' && current.trim()) continue
const rawValue = (row as any)?.[key]
const parsed = parser(rawValue)
if (parsed.trim()) out[key] = parsed
}
return out
convertTo?: 'pdf'
}
export async function fetchPlanPdf({
plan_estudio_id,
convertTo,
}: GeneratePdfParams): Promise<Blob> {
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
{
action: 'downloadReport',
plan_estudio_id,
body: { convertTo: 'pdf' },
body: convertTo ? { convertTo } : {},
},
{
headers: {
@@ -87,6 +42,7 @@ export async function fetchPlanPdf({
export async function fetchAsignaturaPdf({
asignatura_id,
convertTo,
}: GeneratePdfParamsAsignatura): Promise<Blob> {
const supabase = supabaseBrowser()
@@ -106,7 +62,10 @@ export async function fetchAsignaturaPdf({
'Asignatura no encontrada',
)
// const reportData = buildAsignaturaReportData(row)
const body: Record<string, unknown> = {
data: row,
}
if (convertTo) body.convertTo = convertTo
return await invokeEdge<Blob>(
EDGE.carbone_io_wrapper,
@@ -114,8 +73,7 @@ export async function fetchAsignaturaPdf({
action: 'downloadReport',
asignatura_id,
body: {
data: row,
convertTo: 'pdf',
...body,
},
},
{

View File

@@ -27,6 +27,55 @@ export class EdgeFunctionError extends Error {
}
}
// Soporta base64 puro o data:...;base64,...
function decodeBase64ToUint8Array(input: string): Uint8Array {
const trimmed = input.trim()
const base64 = trimmed.startsWith('data:')
? trimmed.slice(trimmed.indexOf(',') + 1)
: trimmed
const bin = atob(base64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
function stripDataUrlPrefix(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith('data:')) return trimmed
const commaIdx = trimmed.indexOf(',')
return commaIdx >= 0 ? trimmed.slice(commaIdx + 1) : trimmed
}
function looksLikeBase64(s: string): boolean {
const t = stripDataUrlPrefix(s).replace(/\s+/g, '').replace(/=+$/g, '')
// base64 típico: solo chars permitidos y longitud razonable
if (t.length < 64) return false
return /^[A-Za-z0-9+/]+$/.test(t)
}
function startsWithZip(bytes: Uint8Array): boolean {
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b // "PK"
}
function startsWithPdf(bytes: Uint8Array): boolean {
return (
bytes.length >= 5 &&
bytes[0] === 0x25 &&
bytes[1] === 0x50 &&
bytes[2] === 0x44 &&
bytes[3] === 0x46 &&
bytes[4] === 0x2d
) // "%PDF-"
}
function binaryStringToUint8Array(input: string): Uint8Array {
const bytes = new Uint8Array(input.length)
for (let i = 0; i < input.length; i++) bytes[i] = input.charCodeAt(i) & 0xff
return bytes
}
export async function invokeEdge<TOut>(
functionName: string,
body?:
@@ -111,5 +160,20 @@ export async function invokeEdge<TOut>(
throw new EdgeFunctionError(message, functionName, status, details)
}
if (opts.responseType === 'blob') {
const anyData: unknown = data
if (anyData instanceof Blob) {
return anyData as TOut
}
throw new EdgeFunctionError(
'La Edge Function no devolvió un binario (Blob) válido.',
functionName,
undefined,
{ receivedType: typeof anyData, received: anyData },
)
}
return data as TOut
}

View File

@@ -8,7 +8,7 @@ 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'
@@ -21,18 +21,23 @@ export const Route = createFileRoute('/planes/$planId/_detalle/documento')({
function RouteComponent() {
const { planId } = useParams({ from: '/planes/$planId/_detalle/documento' })
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const loadPdfPreview = useCallback(async () => {
try {
setIsLoading(true)
const pdfBlob = await fetchPlanPdf({ plan_estudio_id: planId })
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
// Limpiar URL anterior si existe para evitar fugas de memoria
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
setPdfUrl(url)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
pdfUrlRef.current = url
return url
})
} catch (error) {
console.error('Error cargando preview:', error)
} finally {
@@ -43,7 +48,7 @@ function RouteComponent() {
useEffect(() => {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
if (pdfUrlRef.current) window.URL.revokeObjectURL(pdfUrlRef.current)
}
}, [loadPdfPreview])
@@ -51,6 +56,7 @@ function RouteComponent() {
try {
const pdfBlob = await fetchPlanPdf({
plan_estudio_id: planId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
@@ -67,6 +73,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 = 'plan_estudios.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 +115,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 +171,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 +181,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"
/>
) : (

View File

@@ -1,5 +1,5 @@
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 { fetchAsignaturaPdf } from '@/data/api/document.api'
@@ -16,6 +16,7 @@ function RouteComponent() {
})
const [pdfUrl, setPdfUrl] = useState<string | null>(null)
const pdfUrlRef = useRef<string | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isRegenerating, setIsRegenerating] = useState(false)
@@ -25,12 +26,14 @@ function RouteComponent() {
const pdfBlob = await fetchAsignaturaPdf({
asignatura_id: asignaturaId,
convertTo: 'pdf',
})
const url = window.URL.createObjectURL(pdfBlob)
setPdfUrl((prev) => {
if (prev) window.URL.revokeObjectURL(prev)
pdfUrlRef.current = url
return url
})
} catch (error) {
@@ -44,13 +47,14 @@ function RouteComponent() {
loadPdfPreview()
return () => {
if (pdfUrl) window.URL.revokeObjectURL(pdfUrl)
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)
@@ -63,6 +67,21 @@ function RouteComponent() {
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 = 'documento_sep.docx'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
}
const handleRegenerate = async () => {
try {
setIsRegenerating(true)
@@ -77,7 +96,8 @@ function RouteComponent() {
<DocumentoSEPTab
pdfUrl={pdfUrl}
isLoading={isLoading}
onDownload={handleDownload}
onDownloadPdf={handleDownloadPdf}
onDownloadWord={handleDownloadWord}
onRegenerate={handleRegenerate}
isRegenerating={isRegenerating}
/>