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 */