import { useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { Loader2 } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import type { AISubjectUnifiedInput } from '@/data' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { TablesInsert } from '@/types/supabase' import type { RealtimeChannel } from '@supabase/supabase-js' import { Button } from '@/components/ui/button' import { supabaseBrowser, useGenerateSubjectAI, qk, useCreateSubjectManual, subjects_get_maybe, } from '@/data' export function WizardControls({ wizard, setWizard, errorMessage, onPrev, onNext, disablePrev, disableNext, disableCreate, isLastStep, }: { wizard: NewSubjectWizardState setWizard: React.Dispatch> errorMessage?: string | null onPrev: () => void onNext: () => void disablePrev: boolean disableNext: boolean disableCreate: boolean isLastStep: boolean }) { const navigate = useNavigate() const qc = useQueryClient() const generateSubjectAI = useGenerateSubjectAI() const createSubjectManual = useCreateSubjectManual() const [isSpinningIA, setIsSpinningIA] = useState(false) const cancelledRef = useRef(false) const realtimeChannelRef = useRef(null) const watchSubjectIdRef = useRef(null) const watchTimeoutRef = useRef(null) useEffect(() => { cancelledRef.current = false return () => { cancelledRef.current = true } }, []) const stopSubjectWatch = useCallback(() => { if (watchTimeoutRef.current) { window.clearTimeout(watchTimeoutRef.current) watchTimeoutRef.current = null } watchSubjectIdRef.current = null const ch = realtimeChannelRef.current if (ch) { realtimeChannelRef.current = null try { supabaseBrowser().removeChannel(ch) } catch { // noop } } }, []) useEffect(() => { return () => { stopSubjectWatch() } }, [stopSubjectWatch]) const handleSubjectReady = (args: { id: string plan_estudio_id: string estado?: unknown }) => { if (cancelledRef.current) return const estado = String(args.estado ?? '').toLowerCase() if (estado === 'generando') return stopSubjectWatch() setIsSpinningIA(false) setWizard((w) => ({ ...w, isLoading: false })) navigate({ to: `/planes/${args.plan_estudio_id}/asignaturas/${args.id}`, state: { showConfetti: true }, }) } const beginSubjectWatch = (args: { subjectId: string; planId: string }) => { stopSubjectWatch() watchSubjectIdRef.current = args.subjectId // Timeout de seguridad (mismo límite que teníamos con polling) watchTimeoutRef.current = window.setTimeout( () => { if (cancelledRef.current) return if (watchSubjectIdRef.current !== args.subjectId) return stopSubjectWatch() setIsSpinningIA(false) setWizard((w) => ({ ...w, isLoading: false, errorMessage: 'La generación está tardando demasiado. Intenta de nuevo en unos minutos.', })) }, 6 * 60 * 1000, ) const supabase = supabaseBrowser() const channel = supabase.channel(`asignaturas-status-${args.subjectId}`) realtimeChannelRef.current = channel channel.on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'asignaturas', filter: `id=eq.${args.subjectId}`, }, (payload) => { if (cancelledRef.current) return const next: any = (payload as any)?.new if (!next?.id || !next?.plan_estudio_id) return handleSubjectReady({ id: String(next.id), plan_estudio_id: String(next.plan_estudio_id), estado: next.estado, }) }, ) channel.subscribe((status) => { if (cancelledRef.current) return if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') { stopSubjectWatch() setIsSpinningIA(false) setWizard((w) => ({ ...w, isLoading: false, errorMessage: 'No se pudo suscribir al estado de la asignatura. Intenta de nuevo.', })) } }) } 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, isLoading: true, errorMessage: null, })) let startedWaiting = false try { if (wizard.tipoOrigen === 'IA_SIMPLE') { 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, 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) // Inicia watch realtime antes de disparar la Edge para no perder updates. startedWaiting = true beginSubjectWatch({ subjectId, planId: wizard.plan_estudio_id }) 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 ?? 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 ?? undefined, instruccionesAdicionalesIA: wizard.iaConfig?.instruccionesAdicionalesIA ?? undefined, archivosAdjuntos, }, } console.log( `${new Date().toISOString()} - Disparando Edge IA asignatura (unified)`, ) await generateSubjectAI.mutateAsync(payload as any) // Fallback: una lectura puntual por si el UPDATE llegó antes de suscribir. const latest = await subjects_get_maybe(subjectId) if (latest) { handleSubjectReady({ id: latest.id as any, plan_estudio_id: latest.plan_estudio_id as any, estado: (latest as any).estado, }) } return } if (wizard.tipoOrigen === 'IA_MULTIPLE') { const selected = wizard.sugerencias.filter((s) => s.selected) if (selected.length === 0) { throw new Error('Selecciona al menos una sugerencia.') } if (!wizard.plan_estudio_id) { throw new Error('Plan de estudio inválido.') } if (!wizard.estructuraId) { throw new Error('Selecciona una estructura para continuar.') } 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, estructura_id: wizard.estructuraId, estado: 'generando', nombre: s.nombre, codigo: s.codigo ?? null, tipo: s.tipo ?? undefined, creditos: s.creditos ?? 0, horas_academicas: s.horasAcademicas ?? null, horas_independientes: s.horasIndependientes ?? null, linea_plan_id: s.linea_plan_id ?? null, numero_ciclo: s.numero_ciclo ?? null, }), ) const { data: inserted, error: insertError } = await supabase .from('asignaturas') .insert(placeholders) .select('id') if (insertError) { throw new Error(insertError.message) } const insertedIds = inserted.map((r) => r.id) if (insertedIds.length !== selected.length) { throw new Error('No se pudieron crear todas las asignaturas.') } // Disparar generación en paralelo (no bloquear navegación) insertedIds.forEach((id, idx) => { const s = selected[idx] 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 as any).catch((e) => { console.error('Error generando asignatura IA (multiple):', e) }) }) // Invalidar la query del listado del plan (una vez) para que la lista // muestre el estado actualizado y recargue cuando lleguen updates. qc.invalidateQueries({ queryKey: qk.planAsignaturas(wizard.plan_estudio_id), }) navigate({ to: `/planes/${wizard.plan_estudio_id}/asignaturas`, resetScroll: false, }) setIsSpinningIA(false) return } if (wizard.tipoOrigen === 'MANUAL') { if (!wizard.plan_estudio_id) { throw new Error('Plan de estudio inválido.') } const asignatura = await createSubjectManual.mutateAsync({ plan_estudio_id: wizard.plan_estudio_id, estructura_id: wizard.datosBasicos.estructuraId!, nombre: wizard.datosBasicos.nombre, codigo: wizard.datosBasicos.codigo ?? null, tipo: wizard.datosBasicos.tipo ?? undefined, creditos: wizard.datosBasicos.creditos ?? 0, horas_academicas: wizard.datosBasicos.horasAcademicas ?? null, horas_independientes: wizard.datosBasicos.horasIndependientes ?? null, linea_plan_id: null, numero_ciclo: null, }) navigate({ to: `/planes/${wizard.plan_estudio_id}/asignaturas/${asignatura.id}`, state: { showConfetti: true }, resetScroll: false, }) } } catch (err: any) { setIsSpinningIA(false) stopSubjectWatch() setWizard((w) => ({ ...w, isLoading: false, errorMessage: err?.message ?? 'Error creando la asignatura', })) } finally { if (!startedWaiting) { setIsSpinningIA(false) setWizard((w) => ({ ...w, isLoading: false })) } } } return (
{(errorMessage ?? wizard.errorMessage) && ( {errorMessage ?? wizard.errorMessage} )}
{isLastStep ? ( ) : ( )}
) }