diff --git a/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx b/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx index e512e4a..e8f8de2 100644 --- a/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx +++ b/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx @@ -26,7 +26,7 @@ export default function PasoSugerenciasForm({ onChange: Dispatch> }) { const enfoque = wizard.iaMultiple?.enfoque ?? '' - const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10 + const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 5 const isLoading = wizard.iaMultiple?.isLoading ?? false const [showConservacionTooltip, setShowConservacionTooltip] = useState(false) @@ -163,7 +163,7 @@ export default function PasoSugerenciasForm({ Cantidad de sugerencias (null) + const cancelledRef = useRef(false) + const pollStartedAtRef = useRef(null) + + useEffect(() => { + cancelledRef.current = false + return () => { + cancelledRef.current = true + } + }, []) + + const subjectQuery = useQuery({ + queryKey: pollSubjectId + ? qk.asignaturaMaybe(pollSubjectId) + : ['asignaturas', 'detail-maybe', null], + queryFn: () => subjects_get_maybe(pollSubjectId as string), + enabled: Boolean(pollSubjectId), + refetchInterval: () => { + if (!pollSubjectId) return false + + const startedAt = pollStartedAtRef.current ?? Date.now() + if (!pollStartedAtRef.current) pollStartedAtRef.current = startedAt + + const elapsedMs = Date.now() - startedAt + return elapsedMs >= 6 * 60 * 1000 ? false : 3000 + }, + refetchIntervalInBackground: true, + staleTime: 0, + }) + + useEffect(() => { + if (!pollSubjectId) return + if (cancelledRef.current) return + + const asig = subjectQuery.data + if (!asig) return + + const estado = String(asig.estado).toLowerCase() + if (estado === 'generando') return + + setPollSubjectId(null) + pollStartedAtRef.current = null + setIsSpinningIA(false) + setWizard((w) => ({ ...w, isLoading: false })) + + navigate({ + to: `/planes/${asig.plan_estudio_id}/asignaturas/${asig.id}`, + state: { showConfetti: true }, + }) + }, [pollSubjectId, subjectQuery.data, navigate, setWizard]) + + useEffect(() => { + if (!pollSubjectId) return + if (!subjectQuery.isError) return + + setPollSubjectId(null) + pollStartedAtRef.current = null + setIsSpinningIA(false) + setWizard((w) => ({ + ...w, + isLoading: false, + errorMessage: + (subjectQuery.error as any)?.message ?? + 'Error consultando el estado de la asignatura', + })) + }, [pollSubjectId, subjectQuery.isError, subjectQuery.error, setWizard]) + + const uploadAiAttachments = async (args: { + planId: string + files: Array<{ file: File }> + }): Promise> => { + const supabase = supabaseBrowser() + if (!args.files.length) return [] + + const runId = crypto.randomUUID() + const basePath = `planes/${args.planId}/asignaturas/ai/${runId}` + + const keys: Array = [] + for (const f of args.files) { + const safeName = (f.file.name || 'archivo').replace(/[\\/]+/g, '_') + const key = `${basePath}/${crypto.randomUUID()}-${safeName}` + + const { error } = await supabase.storage + .from('ai-storage') + .upload(key, f.file, { + contentType: f.file.type || undefined, + }) + + if (error) throw new Error(error.message) + keys.push(key) + } + + return keys + } + const handleCreate = async () => { setWizard((w) => ({ ...w, @@ -48,48 +144,89 @@ export function WizardControls({ errorMessage: null, })) + let startedPolling = false + try { if (wizard.tipoOrigen === 'IA_SIMPLE') { - const aiInput: AIGenerateSubjectInput = { + if (!wizard.plan_estudio_id) { + throw new Error('Plan de estudio inválido.') + } + if (!wizard.datosBasicos.estructuraId) { + throw new Error('Estructura inválida.') + } + if (!wizard.datosBasicos.nombre.trim()) { + throw new Error('Nombre inválido.') + } + if (wizard.datosBasicos.creditos == null) { + throw new Error('Créditos inválidos.') + } + + console.log(`${new Date().toISOString()} - Insertando asignatura IA`) + + const supabase = supabaseBrowser() + const placeholder: TablesInsert<'asignaturas'> = { plan_estudio_id: wizard.plan_estudio_id, - datosBasicos: { + estructura_id: wizard.datosBasicos.estructuraId, + nombre: wizard.datosBasicos.nombre, + codigo: wizard.datosBasicos.codigo ?? null, + tipo: wizard.datosBasicos.tipo ?? undefined, + creditos: wizard.datosBasicos.creditos, + horas_academicas: wizard.datosBasicos.horasAcademicas ?? null, + horas_independientes: wizard.datosBasicos.horasIndependientes ?? null, + estado: 'generando', + tipo_origen: 'IA', + } + + const { data: inserted, error: insertError } = await supabase + .from('asignaturas') + .insert(placeholder) + .select('id,plan_estudio_id') + .single() + + if (insertError) throw new Error(insertError.message) + const subjectId = inserted.id + + setIsSpinningIA(true) + + const archivosAdjuntos = await uploadAiAttachments({ + planId: wizard.plan_estudio_id, + files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({ + file: x.file, + })), + }) + + const payload: AISubjectUnifiedInput = { + datosUpdate: { + id: subjectId, + plan_estudio_id: wizard.plan_estudio_id, + estructura_id: wizard.datosBasicos.estructuraId, nombre: wizard.datosBasicos.nombre, - codigo: wizard.datosBasicos.codigo, - tipo: wizard.datosBasicos.tipo!, - creditos: wizard.datosBasicos.creditos!, - horasIndependientes: wizard.datosBasicos.horasIndependientes, - horasAcademicas: wizard.datosBasicos.horasAcademicas, - estructuraId: wizard.datosBasicos.estructuraId!, + codigo: wizard.datosBasicos.codigo ?? null, + tipo: wizard.datosBasicos.tipo ?? null, + creditos: wizard.datosBasicos.creditos, + horas_academicas: wizard.datosBasicos.horasAcademicas ?? null, + horas_independientes: + wizard.datosBasicos.horasIndependientes ?? null, }, iaConfig: { descripcionEnfoqueAcademico: - wizard.iaConfig!.descripcionEnfoqueAcademico, + wizard.iaConfig?.descripcionEnfoqueAcademico ?? undefined, instruccionesAdicionalesIA: - wizard.iaConfig!.instruccionesAdicionalesIA, - archivosReferencia: wizard.iaConfig!.archivosReferencia, - repositoriosReferencia: - wizard.iaConfig!.repositoriosReferencia || [], - archivosAdjuntos: wizard.iaConfig!.archivosAdjuntos || [], + wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined, + archivosAdjuntos, }, } console.log( - `${new Date().toISOString()} - Enviando a generar asignatura con IA`, + `${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`, ) - setIsSpinningIA(true) - const asignatura = await generateSubjectAI.mutateAsync(aiInput) - // await new Promise((resolve) => setTimeout(resolve, 20000)) // debug - setIsSpinningIA(false) - // console.log( - // `${new Date().toISOString()} - Asignatura IA generada`, - // asignatura, - // ) + await generateSubjectAI.mutateAsync(payload as any) - navigate({ - to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`, - state: { showConfetti: true }, - }) + // Inicia polling; el efecto navega cuando deje de estar "generando". + startedPolling = true + pollStartedAtRef.current = Date.now() + setPollSubjectId(subjectId) return } @@ -108,6 +245,15 @@ export function WizardControls({ const supabase = supabaseBrowser() + setIsSpinningIA(true) + + const archivosAdjuntos = await uploadAiAttachments({ + planId: wizard.plan_estudio_id, + files: (wizard.iaConfig?.archivosAdjuntos ?? []).map((x) => ({ + file: x.file, + })), + }) + const placeholders: Array> = selected.map( (s): TablesInsert<'asignaturas'> => ({ plan_estudio_id: wizard.plan_estudio_id, @@ -141,16 +287,33 @@ export function WizardControls({ // Disparar generación en paralelo (no bloquear navegación) insertedIds.forEach((id, idx) => { const s = selected[idx] - const payload: AIGenerateSubjectJsonInput = { - id, - descripcionEnfoqueAcademico: s.descripcion, - // (opcionales) parches directos si el edge los usa - estructura_id: wizard.estructuraId, - linea_plan_id: s.linea_plan_id, - numero_ciclo: s.numero_ciclo, + const creditosForEdge = + typeof s.creditos === 'number' && s.creditos > 0 + ? s.creditos + : undefined + const payload: AISubjectUnifiedInput = { + datosUpdate: { + id, + plan_estudio_id: wizard.plan_estudio_id, + estructura_id: wizard.estructuraId ?? undefined, + nombre: s.nombre, + codigo: s.codigo ?? null, + tipo: s.tipo ?? null, + creditos: creditosForEdge, + horas_academicas: s.horasAcademicas ?? null, + horas_independientes: s.horasIndependientes ?? null, + numero_ciclo: s.numero_ciclo ?? null, + linea_plan_id: s.linea_plan_id ?? null, + }, + iaConfig: { + descripcionEnfoqueAcademico: s.descripcion, + instruccionesAdicionalesIA: + wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined, + archivosAdjuntos, + }, } - void generateSubjectAI.mutateAsync(payload).catch((e) => { + void generateSubjectAI.mutateAsync(payload as any).catch((e) => { console.error('Error generando asignatura IA (multiple):', e) }) }) @@ -166,6 +329,8 @@ export function WizardControls({ resetScroll: false, }) + setIsSpinningIA(false) + return } @@ -195,14 +360,17 @@ export function WizardControls({ } } catch (err: any) { setIsSpinningIA(false) + setPollSubjectId(null) setWizard((w) => ({ ...w, isLoading: false, errorMessage: err?.message ?? 'Error creando la asignatura', })) } finally { - setIsSpinningIA(false) - setWizard((w) => ({ ...w, isLoading: false })) + if (!startedPolling) { + setIsSpinningIA(false) + setWizard((w) => ({ ...w, isLoading: false })) + } } } diff --git a/src/components/planes/wizard/WizardControls.tsx b/src/components/planes/wizard/WizardControls.tsx index 9bd95d9..66d8bc9 100644 --- a/src/components/planes/wizard/WizardControls.tsx +++ b/src/components/planes/wizard/WizardControls.tsx @@ -45,6 +45,7 @@ export function WizardControls({ const [isSpinningIA, setIsSpinningIA] = useState(false) const [pollPlanId, setPollPlanId] = useState(null) const cancelledRef = useRef(false) + const pollStartedAtRef = useRef(null) // const supabaseClient = supabaseBrowser() // const persistPlanFromAI = usePersistPlanFromAI() @@ -61,7 +62,15 @@ export function WizardControls({ : ['planes', 'detail-maybe', null], queryFn: () => plans_get_maybe(pollPlanId as string), enabled: Boolean(pollPlanId), - refetchInterval: pollPlanId ? 3000 : false, + refetchInterval: () => { + if (!pollPlanId) return false + + const startedAt = pollStartedAtRef.current ?? Date.now() + if (!pollStartedAtRef.current) pollStartedAtRef.current = startedAt + + const elapsedMs = Date.now() - startedAt + return elapsedMs >= 6 * 60 * 1000 ? false : 3000 + }, refetchIntervalInBackground: true, staleTime: 0, }) @@ -80,6 +89,7 @@ export function WizardControls({ if (clave.startsWith('BORRADOR')) { setPollPlanId(null) + pollStartedAtRef.current = null setIsSpinningIA(false) setWizard((w) => ({ ...w, isLoading: false })) navigate({ @@ -92,6 +102,7 @@ export function WizardControls({ if (clave.startsWith('FALLID')) { // Detenemos el polling primero para evitar loops. setPollPlanId(null) + pollStartedAtRef.current = null setIsSpinningIA(false) deletePlan @@ -113,6 +124,7 @@ export function WizardControls({ if (!pollPlanId) return if (!planQuery.isError) return setPollPlanId(null) + pollStartedAtRef.current = null setIsSpinningIA(false) setWizard((w) => ({ ...w, @@ -175,6 +187,7 @@ export function WizardControls({ } // Inicia polling con React Query; el efecto navega o marca error. + pollStartedAtRef.current = Date.now() setPollPlanId(String(planId)) return } diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 56a8e11..0659c0f 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -15,7 +15,6 @@ import type { TipoAsignatura, UUID, } from '../types/domain' -import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/FileDropZone' import type { AsignaturaSugerida, DataAsignaturaSugerida, @@ -178,54 +177,49 @@ export async function subjects_create_manual( return requireData(data, 'No se pudo crear la asignatura.') } -export type AIGenerateSubjectInput = { - plan_estudio_id: Asignatura['plan_estudio_id'] - datosBasicos: { - nombre: Asignatura['nombre'] - codigo?: Asignatura['codigo'] - tipo: Asignatura['tipo'] | null - creditos: Asignatura['creditos'] | null - horasAcademicas?: Asignatura['horas_academicas'] | null - horasIndependientes?: Asignatura['horas_independientes'] | null - estructuraId: Asignatura['estructura_id'] | null +/** + * Nuevo payload unificado (JSON) para la Edge `ai_generate_subject`. + * - Siempre incluye `datosUpdate.plan_estudio_id`. + * - `datosUpdate.id` es opcional (si no existe, la Edge puede crear). + * En el frontend, insertamos primero y usamos `id` para actualizar. + */ +export type AISubjectUnifiedInput = { + datosUpdate: Partial<{ + id: string + plan_estudio_id: string + estructura_id: string + nombre: string + codigo: string | null + tipo: string | null + creditos: number + horas_academicas: number | null + horas_independientes: number | null + numero_ciclo: number | null + linea_plan_id: string | null + orden_celda: number | null + }> & { + plan_estudio_id: string } - // clonInterno?: { - // facultadId?: string - // carreraId?: string - // planOrigenId?: string - // asignaturaOrigenId?: string | null - // } - // clonTradicional?: { - // archivoWordAsignaturaId: string | null - // archivosAdicionalesIds: Array - // } iaConfig?: { - descripcionEnfoqueAcademico: string - instruccionesAdicionalesIA: string - archivosReferencia: Array - repositoriosReferencia?: Array - archivosAdjuntos?: Array + descripcionEnfoqueAcademico?: string + instruccionesAdicionalesIA?: string + archivosAdjuntos?: Array } } -/** - * Edge (JSON): actualizar/llenar una asignatura existente por id. - * Nota: este flujo NO acepta `instruccionesAdicionalesIA` (solo FormData lo usa). - */ -export type AIGenerateSubjectJsonInput = Partial<{ - plan_estudio_id: Asignatura['plan_estudio_id'] - nombre: Asignatura['nombre'] - codigo: Asignatura['codigo'] - tipo: Asignatura['tipo'] | null - creditos: Asignatura['creditos'] - horas_academicas: Asignatura['horas_academicas'] | null - horas_independientes: Asignatura['horas_independientes'] | null - estructura_id: Asignatura['estructura_id'] | null - linea_plan_id: Asignatura['linea_plan_id'] | null - numero_ciclo: Asignatura['numero_ciclo'] | null - descripcionEnfoqueAcademico: string -}> & { - id: Asignatura['id'] +export async function subjects_get_maybe( + subjectId: UUID, +): Promise { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('asignaturas') + .select('id,plan_estudio_id,estado') + .eq('id', subjectId) + .maybeSingle() + + throwIfError(error) + return (data ?? null) as unknown as Asignatura | null } export type GenerateSubjectSuggestionsInput = { @@ -263,30 +257,8 @@ export async function generate_subject_suggestions( } export async function ai_generate_subject( - input: AIGenerateSubjectInput | AIGenerateSubjectJsonInput, + input: AISubjectUnifiedInput, ): Promise { - if ('datosBasicos' in input) { - const edgeFunctionBody = new FormData() - edgeFunctionBody.append('plan_estudio_id', input.plan_estudio_id) - edgeFunctionBody.append('datosBasicos', JSON.stringify(input.datosBasicos)) - edgeFunctionBody.append( - 'iaConfig', - JSON.stringify({ - ...input.iaConfig, - archivosAdjuntos: undefined, // los manejamos aparte - }), - ) - input.iaConfig?.archivosAdjuntos?.forEach((file) => { - edgeFunctionBody.append(`archivosAdjuntos`, file.file) - }) - return invokeEdge( - EDGE.ai_generate_subject, - edgeFunctionBody, - undefined, - supabaseBrowser(), - ) - } - return invokeEdge(EDGE.ai_generate_subject, input, { headers: { 'Content-Type': 'application/json' }, }) diff --git a/src/data/hooks/usePlans.ts b/src/data/hooks/usePlans.ts index b578a02..4416227 100644 --- a/src/data/hooks/usePlans.ts +++ b/src/data/hooks/usePlans.ts @@ -85,7 +85,18 @@ export function usePlanAsignaturas(planId: UUID | null | undefined) { const hayGenerando = data.some( (a: any) => (a as { estado?: unknown }).estado === 'generando', ) - return hayGenerando ? 500 : false + + const qAny = query as any + if (!hayGenerando) { + qAny.__generandoSince = null + return false + } + + const startedAt = qAny.__generandoSince ?? Date.now() + if (!qAny.__generandoSince) qAny.__generandoSince = startedAt + + const elapsedMs = Date.now() - startedAt + return elapsedMs >= 6 * 60 * 1000 ? false : 3000 }, refetchIntervalInBackground: true, }) diff --git a/src/data/query/keys.ts b/src/data/query/keys.ts index 4e02e9c..9f50063 100644 --- a/src/data/query/keys.ts +++ b/src/data/query/keys.ts @@ -23,6 +23,8 @@ export const qk = { sugerenciasAsignaturas: () => ['asignaturas', 'sugerencias'] as const, asignatura: (asignaturaId: string) => ['asignaturas', 'detail', asignaturaId] as const, + asignaturaMaybe: (asignaturaId: string) => + ['asignaturas', 'detail-maybe', asignaturaId] as const, asignaturaBibliografia: (asignaturaId: string) => ['asignaturas', asignaturaId, 'bibliografia'] as const, asignaturaHistorial: (asignaturaId: string) => diff --git a/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx b/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx index 1088a3b..9f0fd10 100644 --- a/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx +++ b/src/routes/planes/$planId/asignaturas/$asignaturaId/route.tsx @@ -2,6 +2,7 @@ import { createFileRoute, Outlet, Link, + useLocation, useParams, useRouterState, } from '@tanstack/react-router' @@ -9,6 +10,7 @@ import { ArrowLeft, GraduationCap } from 'lucide-react' import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' +import { lateralConfetti } from '@/components/ui/lateral-confetti' import { useSubject, useUpdateAsignatura } from '@/data' export const Route = createFileRoute( @@ -62,8 +64,7 @@ interface DatosPlan { } function AsignaturaLayout() { - const routerState = useRouterState() - const state = routerState.location.state as any + const location = useLocation() const { asignaturaId } = useParams({ from: '/planes/$planId/asignaturas/$asignaturaId', }) @@ -117,6 +118,14 @@ function AsignaturaLayout() { select: (state) => state.location.pathname, }) + // Confetti al llegar desde creación IA + useEffect(() => { + if ((location.state as any)?.showConfetti) { + lateralConfetti() + window.history.replaceState({}, document.title) + } + }, [location.state]) + if (loadingAsig) { return (
@@ -130,7 +139,7 @@ function AsignaturaLayout() { return (
-
+