From 96848e1793699d75723654b8b0d40214b5c58e6a Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 12:24:17 -0600 Subject: [PATCH 1/5] Se utiliza la edge function de carbone para obtener el pdf del anexo del plan de estudios a partir del id del plan --- src/data/api/document.api.ts | 32 +++++++++++++++++--------------- src/data/supabase/invokeEdge.ts | 9 ++++++++- 2 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/data/api/document.api.ts b/src/data/api/document.api.ts index 2ced129..8c85d58 100644 --- a/src/data/api/document.api.ts +++ b/src/data/api/document.api.ts @@ -1,7 +1,10 @@ // document.api.ts -const DOCUMENT_PDF_URL = - 'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434' +import { invokeEdge } from '../supabase/invokeEdge' + +const EDGE = { + carbone_io_wrapper: 'carbone-io-wrapper', +} as const const DOCUMENT_PDF_ASIGNATURA_URL = 'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d' @@ -16,20 +19,19 @@ interface GeneratePdfParamsAsignatura { export async function fetchPlanPdf({ plan_estudio_id, }: GeneratePdfParams): Promise { - const response = await fetch(DOCUMENT_PDF_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + return await invokeEdge( + EDGE.carbone_io_wrapper, + { + action: 'downloadReport', + plan_estudio_id, }, - body: JSON.stringify({ plan_estudio_id }), - }) - - if (!response.ok) { - throw new Error('Error al generar el PDF') - } - - // n8n devuelve el archivo → lo tratamos como blob - return await response.blob() + { + headers: { + 'Content-Type': 'application/json', + }, + responseType: 'blob', + }, + ) } export async function fetchAsignaturaPdf({ diff --git a/src/data/supabase/invokeEdge.ts b/src/data/supabase/invokeEdge.ts index 2aeff2e..b7df21f 100644 --- a/src/data/supabase/invokeEdge.ts +++ b/src/data/supabase/invokeEdge.ts @@ -12,6 +12,7 @@ import type { SupabaseClient } from '@supabase/supabase-js' export type EdgeInvokeOptions = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' headers?: Record + responseType?: 'json' | 'text' | 'blob' | 'arrayBuffer' } export class EdgeFunctionError extends Error { @@ -42,10 +43,16 @@ export async function invokeEdge( ): Promise { const supabase = client ?? supabaseBrowser() - const { data, error } = await supabase.functions.invoke(functionName, { + // Nota: algunas versiones/defs de @supabase/supabase-js no tipan `responseType` + // aunque el runtime lo soporte. Usamos `any` para no bloquear el uso de Blob. + const invoke: any = (supabase.functions as any).invoke.bind( + supabase.functions, + ) + const { data, error } = await invoke(functionName, { body, method: opts.method ?? 'POST', headers: opts.headers, + responseType: opts.responseType, }) if (error) { From d9a5cec3c5605b5cad01ef28431077814e4f8139 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 13:22:23 -0600 Subject: [PATCH 2/5] =?UTF-8?q?En=20el=20body=20se=20manda=20el=20par?= =?UTF-8?q?=C3=A1metro=20para=20convertir=20el=20documento=20a=20pdf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/data/api/document.api.ts | 1 + src/types/supabase.ts | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/data/api/document.api.ts b/src/data/api/document.api.ts index 8c85d58..cd6ead1 100644 --- a/src/data/api/document.api.ts +++ b/src/data/api/document.api.ts @@ -24,6 +24,7 @@ export async function fetchPlanPdf({ { action: 'downloadReport', plan_estudio_id, + body: { convertTo: 'pdf' }, }, { headers: { diff --git a/src/types/supabase.ts b/src/types/supabase.ts index 9a38201..bafbe23 100644 --- a/src/types/supabase.ts +++ b/src/types/supabase.ts @@ -154,9 +154,9 @@ export type Database = { numero_ciclo: number | null orden_celda: number | null plan_estudio_id: string + prerrequisito_asignatura_id: string | null tipo: Database['public']['Enums']['tipo_asignatura'] tipo_origen: Database['public']['Enums']['tipo_origen'] | null - prerrequisito_asignatura_id?: string } Insert: { actualizado_en?: string @@ -180,6 +180,7 @@ export type Database = { numero_ciclo?: number | null orden_celda?: number | null plan_estudio_id: string + prerrequisito_asignatura_id?: string | null tipo?: Database['public']['Enums']['tipo_asignatura'] tipo_origen?: Database['public']['Enums']['tipo_origen'] | null } @@ -205,6 +206,7 @@ export type Database = { numero_ciclo?: number | null orden_celda?: number | null plan_estudio_id?: string + prerrequisito_asignatura_id?: string | null tipo?: Database['public']['Enums']['tipo_asignatura'] tipo_origen?: Database['public']['Enums']['tipo_origen'] | null } @@ -258,6 +260,20 @@ export type Database = { referencedRelation: 'plantilla_plan' referencedColumns: ['plan_estudio_id'] }, + { + foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey' + columns: ['prerrequisito_asignatura_id'] + isOneToOne: false + referencedRelation: 'asignaturas' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'asignaturas_prerrequisito_asignatura_id_fkey' + columns: ['prerrequisito_asignatura_id'] + isOneToOne: false + referencedRelation: 'plantilla_asignatura' + referencedColumns: ['asignatura_id'] + }, ] } bibliografia_asignatura: { @@ -1377,6 +1393,7 @@ export type Database = { Args: { p_append: Json; p_id: string } Returns: undefined } + suma_porcentajes: { Args: { '': Json }; Returns: number } unaccent: { Args: { '': string }; Returns: string } unaccent_immutable: { Args: { '': string }; Returns: string } } From b986ec343e5727ff115996341c5bc719b1171099 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 16:00:10 -0600 Subject: [PATCH 3/5] Se visualiza y descarga el pdf de la asignatura --- .../detalle/AsignaturaDetailPage.tsx | 82 +------------- src/data/api/document.api.ts | 105 +++++++++++++++--- src/lib/asignaturaColumnParsers.ts | 78 +++++++++++++ 3 files changed, 172 insertions(+), 93 deletions(-) create mode 100644 src/lib/asignaturaColumnParsers.ts diff --git a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx index 3d22627..ba19041 100644 --- a/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx +++ b/src/components/asignaturas/detalle/AsignaturaDetailPage.tsx @@ -15,6 +15,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip' import { useSubject, useUpdateAsignatura } from '@/data/hooks/useSubjects' +import { columnParsers } from '@/lib/asignaturaColumnParsers' export interface BibliografiaEntry { id: string @@ -38,6 +39,10 @@ export interface AsignaturaResponse { datos: AsignaturaDatos } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + type CriterioEvaluacionRow = { criterio: string porcentaje: number @@ -791,80 +796,3 @@ function EvaluationView({ items }: { items: Array }) { ) } - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function parseContenidoTematicoToPlainText(value: unknown): string { - if (!Array.isArray(value)) return '' - - const blocks: Array = [] - - for (const item of value) { - if (!isRecord(item)) continue - - const unidad = - typeof item.unidad === 'number' && Number.isFinite(item.unidad) - ? item.unidad - : undefined - const titulo = typeof item.titulo === 'string' ? item.titulo : '' - - const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim() - if (!header) continue - - const lines: Array = [header] - - const temas = Array.isArray(item.temas) ? item.temas : [] - temas.forEach((tema, idx) => { - const temaNombre = - typeof tema === 'string' - ? tema - : isRecord(tema) && typeof tema.nombre === 'string' - ? tema.nombre - : '' - if (!temaNombre) return - - if (unidad != null) { - lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim()) - } else { - lines.push(`${idx + 1}. ${temaNombre}`) - } - }) - - blocks.push(lines.join('\n')) - } - - return blocks.join('\n\n').trimEnd() -} - -function parseCriteriosEvaluacionToPlainText(value: unknown): string { - if (!Array.isArray(value)) return '' - - const lines: Array = [] - for (const item of value) { - if (!isRecord(item)) continue - const label = typeof item.criterio === 'string' ? item.criterio.trim() : '' - const valueNum = - typeof item.porcentaje === 'number' - ? item.porcentaje - : typeof item.porcentaje === 'string' - ? Number(item.porcentaje) - : NaN - - if (!label) continue - if (!Number.isFinite(valueNum)) continue - - const v = Math.trunc(valueNum) - if (v < 1 || v > 100) continue - - lines.push(`${label}: ${v}%`) - } - - return lines.join('\n') -} - -const columnParsers: Partial string>> = { - contenido_tematico: parseContenidoTematicoToPlainText, - criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText, -} diff --git a/src/data/api/document.api.ts b/src/data/api/document.api.ts index cd6ead1..0e8b322 100644 --- a/src/data/api/document.api.ts +++ b/src/data/api/document.api.ts @@ -1,14 +1,18 @@ // document.api.ts +import { supabaseBrowser } from '../supabase/client' import { invokeEdge } from '../supabase/invokeEdge' +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 -const DOCUMENT_PDF_ASIGNATURA_URL = - 'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d' - interface GeneratePdfParams { plan_estudio_id: string } @@ -16,6 +20,52 @@ interface GeneratePdfParamsAsignatura { asignatura_id: string } +function isPlainRecord(value: unknown): value is Record { + 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 { + const out: Record = {} + + 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 +} + export async function fetchPlanPdf({ plan_estudio_id, }: GeneratePdfParams): Promise { @@ -38,18 +88,41 @@ export async function fetchPlanPdf({ export async function fetchAsignaturaPdf({ asignatura_id, }: GeneratePdfParamsAsignatura): Promise { - const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('asignaturas') + .select('*') + .eq('id', asignatura_id) + .single() + + throwIfError(error) + + const row = requireData( + data as Pick< + Tables<'asignaturas'>, + 'datos' | 'contenido_tematico' | 'criterios_de_evaluacion' + >, + 'Asignatura no encontrada', + ) + + // const reportData = buildAsignaturaReportData(row) + + return await invokeEdge( + EDGE.carbone_io_wrapper, + { + action: 'downloadReport', + asignatura_id, + body: { + data: row, + convertTo: 'pdf', + }, }, - body: JSON.stringify({ asignatura_id }), - }) - - if (!response.ok) { - throw new Error('Error al generar el PDF') - } - - // n8n devuelve el archivo → lo tratamos como blob - return await response.blob() + { + headers: { + 'Content-Type': 'application/json', + }, + responseType: 'blob', + }, + ) } diff --git a/src/lib/asignaturaColumnParsers.ts b/src/lib/asignaturaColumnParsers.ts new file mode 100644 index 0000000..1b285ee --- /dev/null +++ b/src/lib/asignaturaColumnParsers.ts @@ -0,0 +1,78 @@ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseContenidoTematicoToPlainText(value: unknown): string { + if (!Array.isArray(value)) return '' + + const blocks: Array = [] + + for (const item of value) { + if (!isRecord(item)) continue + + const unidad = + typeof item.unidad === 'number' && Number.isFinite(item.unidad) + ? item.unidad + : undefined + const titulo = typeof item.titulo === 'string' ? item.titulo : '' + + const header = `${unidad ?? ''}${unidad ? '.' : ''} ${titulo}`.trim() + if (!header) continue + + const lines: Array = [header] + + const temas = Array.isArray(item.temas) ? item.temas : [] + temas.forEach((tema, idx) => { + const temaNombre = + typeof tema === 'string' + ? tema + : isRecord(tema) && typeof tema.nombre === 'string' + ? tema.nombre + : '' + if (!temaNombre) return + + if (unidad != null) { + lines.push(`${unidad}.${idx + 1} ${temaNombre}`.trim()) + } else { + lines.push(`${idx + 1}. ${temaNombre}`) + } + }) + + blocks.push(lines.join('\n')) + } + + return blocks.join('\n\n').trimEnd() +} + +export function parseCriteriosEvaluacionToPlainText(value: unknown): string { + if (!Array.isArray(value)) return '' + + const lines: Array = [] + for (const item of value) { + if (!isRecord(item)) continue + const label = typeof item.criterio === 'string' ? item.criterio.trim() : '' + const valueNum = + typeof item.porcentaje === 'number' + ? item.porcentaje + : typeof item.porcentaje === 'string' + ? Number(item.porcentaje) + : NaN + + if (!label) continue + if (!Number.isFinite(valueNum)) continue + + const v = Math.trunc(valueNum) + if (v < 1 || v > 100) continue + + lines.push(`${label}: ${v}%`) + } + + return lines.join('\n') +} + +export const columnParsers: Partial< + Record string> +> = { + contenido_tematico: parseContenidoTematicoToPlainText, + criterios_de_evaluacion: parseCriteriosEvaluacionToPlainText, +} From 1bce226d15a6809b1d9944e8c5628c1b0d7d7de6 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Fri, 20 Mar 2026 17:31:59 -0600 Subject: [PATCH 4/5] Se descargan correctamente los docx del plan y de la asignatura --- .../asignaturas/detalle/DocumentoSEPTab.tsx | 48 ++++++++++---- src/data/api/document.api.ts | 62 +++--------------- src/data/supabase/invokeEdge.ts | 64 +++++++++++++++++++ .../planes/$planId/_detalle/documento.tsx | 56 ++++++++++++---- .../asignaturas/$asignaturaId/documento.tsx | 28 ++++++-- 5 files changed, 176 insertions(+), 82 deletions(-) diff --git a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx index 9349a12..1e6e160 100644 --- a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx +++ b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx @@ -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({
- {pdfUrl && !isLoading && ( - - )} - - @@ -91,11 +91,31 @@ export function DocumentoSEPTab({ + + {pdfUrl && !isLoading && ( + <> + + + + )}
{/* PDF Preview */} - + {isLoading ? (
diff --git a/src/data/api/document.api.ts b/src/data/api/document.api.ts index 0e8b322..daf750e 100644 --- a/src/data/api/document.api.ts +++ b/src/data/api/document.api.ts @@ -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 { - 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 { - const out: Record = {} - - 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 { return await invokeEdge( 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 { const supabase = supabaseBrowser() @@ -106,7 +62,10 @@ export async function fetchAsignaturaPdf({ 'Asignatura no encontrada', ) - // const reportData = buildAsignaturaReportData(row) + const body: Record = { + data: row, + } + if (convertTo) body.convertTo = convertTo return await invokeEdge( EDGE.carbone_io_wrapper, @@ -114,8 +73,7 @@ export async function fetchAsignaturaPdf({ action: 'downloadReport', asignatura_id, body: { - data: row, - convertTo: 'pdf', + ...body, }, }, { diff --git a/src/data/supabase/invokeEdge.ts b/src/data/supabase/invokeEdge.ts index b7df21f..53ec695 100644 --- a/src/data/supabase/invokeEdge.ts +++ b/src/data/supabase/invokeEdge.ts @@ -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( functionName: string, body?: @@ -111,5 +160,20 @@ export async function invokeEdge( 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 } diff --git a/src/routes/planes/$planId/_detalle/documento.tsx b/src/routes/planes/$planId/_detalle/documento.tsx index 782beff..2df3f2a 100644 --- a/src/routes/planes/$planId/_detalle/documento.tsx +++ b/src/routes/planes/$planId/_detalle/documento.tsx @@ -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(null) + const pdfUrlRef = useRef(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 (
{/* HEADER DE ACCIONES */} @@ -88,12 +115,17 @@ function RouteComponent() { > Regenerar - +
- + {isLoading ? (
@@ -149,7 +181,7 @@ function RouteComponent() { /* 3. VISOR DE PDF REAL */