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/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 2ced129..daf750e 100644 --- a/src/data/api/document.api.ts +++ b/src/data/api/document.api.ts @@ -1,52 +1,86 @@ // document.api.ts -const DOCUMENT_PDF_URL = - 'https://n8n.app.lci.ulsa.mx/webhook/62ca84ec-0adb-4006-aba1-32282d27d434' +import { supabaseBrowser } from '../supabase/client' +import { invokeEdge } from '../supabase/invokeEdge' -const DOCUMENT_PDF_ASIGNATURA_URL = - 'https://n8n.app.lci.ulsa.mx/webhook/041a68be-7568-46d0-bc08-09ded12d017d' +import { requireData, throwIfError } from './_helpers' + +import type { Tables } from '@/types/supabase' + +const EDGE = { + carbone_io_wrapper: 'carbone-io-wrapper', +} as const interface GeneratePdfParams { plan_estudio_id: string + convertTo?: 'pdf' } interface GeneratePdfParamsAsignatura { asignatura_id: string + convertTo?: 'pdf' } export async function fetchPlanPdf({ plan_estudio_id, + convertTo, }: 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: convertTo ? { convertTo } : {}, }, - 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({ asignatura_id, + convertTo, }: GeneratePdfParamsAsignatura): Promise { - const response = await fetch(DOCUMENT_PDF_ASIGNATURA_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ asignatura_id }), - }) + const supabase = supabaseBrowser() - if (!response.ok) { - throw new Error('Error al generar el PDF') + 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 body: Record = { + data: row, } + if (convertTo) body.convertTo = convertTo - // n8n devuelve el archivo → lo tratamos como blob - return await response.blob() + return await invokeEdge( + EDGE.carbone_io_wrapper, + { + action: 'downloadReport', + asignatura_id, + body: { + ...body, + }, + }, + { + headers: { + 'Content-Type': 'application/json', + }, + responseType: 'blob', + }, + ) } diff --git a/src/data/supabase/invokeEdge.ts b/src/data/supabase/invokeEdge.ts index 2aeff2e..53ec695 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 { @@ -26,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?: @@ -42,10 +92,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) { @@ -104,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/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, +} diff --git a/src/routes/planes/$planId/_detalle/documento.tsx b/src/routes/planes/$planId/_detalle/documento.tsx index 782beff..2c2bdbf 100644 --- a/src/routes/planes/$planId/_detalle/documento.tsx +++ b/src/routes/planes/$planId/_detalle/documento.tsx @@ -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(null) + const pdfUrlRef = useRef(null) + const isMountedRef = useRef(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 (
{/* HEADER DE ACCIONES */} @@ -88,12 +122,17 @@ function RouteComponent() { > Regenerar - +
- + {isLoading ? (
@@ -149,7 +188,7 @@ function RouteComponent() { /* 3. VISOR DE PDF REAL */