From 857b1795fc5c96a716b046698c6e67b3bc417bed Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Wed, 11 Feb 2026 16:03:52 -0600 Subject: [PATCH] Add AI progress loader and enhance suggestion generation logic - Introduced AIProgressLoader component to display loading progress and messages during suggestion generation. - Updated PasoSugerenciasForm to manage loading state and display tooltip for preserved suggestions. - Adjusted suggestion limits and removed unused ciclo input from state. --- .../PasoBasicosForm/PasoSugerenciasForm.tsx | 148 +++++++++-------- src/data/api/subjects.api.ts | 1 - .../asignaturas/nueva/AIProgressLoader.tsx | 151 ++++++++++++++++++ .../nueva/hooks/useNuevaAsignaturaWizard.ts | 2 +- src/features/asignaturas/nueva/types.ts | 2 +- 5 files changed, 237 insertions(+), 67 deletions(-) create mode 100644 src/features/asignaturas/nueva/AIProgressLoader.tsx diff --git a/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx b/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx index d703a6d..e512e4a 100644 --- a/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx +++ b/src/components/asignaturas/wizard/PasoBasicosForm/PasoSugerenciasForm.tsx @@ -1,4 +1,5 @@ -import { RefreshCw, Sparkles } from 'lucide-react' +import { RefreshCw, Sparkles, X } from 'lucide-react' +import { useState } from 'react' import type { NewSubjectWizardState } from '@/features/asignaturas/nueva/types' import type { Dispatch, SetStateAction } from 'react' @@ -7,7 +8,14 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { generate_subject_suggestions, usePlan } from '@/data' +import { AIProgressLoader } from '@/features/asignaturas/nueva/AIProgressLoader' import { cn } from '@/lib/utils' export default function PasoSugerenciasForm({ @@ -17,9 +25,11 @@ export default function PasoSugerenciasForm({ wizard: NewSubjectWizardState onChange: Dispatch> }) { - const ciclo = wizard.iaMultiple?.ciclo ?? '' const enfoque = wizard.iaMultiple?.enfoque ?? '' const cantidadDeSugerencias = wizard.iaMultiple?.cantidadDeSugerencias ?? 10 + const isLoading = wizard.iaMultiple?.isLoading ?? false + + const [showConservacionTooltip, setShowConservacionTooltip] = useState(false) const setIaMultiple = ( patch: Partial>, @@ -28,9 +38,9 @@ export default function PasoSugerenciasForm({ (w): NewSubjectWizardState => ({ ...w, iaMultiple: { - ciclo: w.iaMultiple?.ciclo ?? null, enfoque: w.iaMultiple?.enfoque ?? '', cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10, + isLoading: w.iaMultiple?.isLoading ?? false, ...patch, }, }), @@ -48,32 +58,31 @@ export default function PasoSugerenciasForm({ } const onGenerarSugerencias = async () => { + const hadNoSugerenciasBefore = wizard.sugerencias.length === 0 const sugerenciasConservadas = wizard.sugerencias.filter((s) => s.selected) onChange((w) => ({ ...w, - isLoading: true, errorMessage: null, sugerencias: sugerenciasConservadas, + iaMultiple: { + enfoque: w.iaMultiple?.enfoque ?? '', + cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10, + isLoading: true, + }, })) try { - const numeroCiclo = wizard.iaMultiple?.ciclo - if (!numeroCiclo || !Number.isFinite(numeroCiclo) || numeroCiclo <= 0) { - onChange((w) => ({ - ...w, - isLoading: false, - errorMessage: 'Ingresa un número de ciclo válido.', - })) - return - } - const cantidad = wizard.iaMultiple?.cantidadDeSugerencias ?? 10 - if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 50) { + if (!Number.isFinite(cantidad) || cantidad <= 0 || cantidad > 15) { onChange((w) => ({ ...w, - isLoading: false, - errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 50.', + errorMessage: 'La cantidad de sugerencias debe ser entre 1 y 15.', + iaMultiple: { + enfoque: w.iaMultiple?.enfoque ?? '', + cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10, + isLoading: false, + }, })) return } @@ -82,7 +91,6 @@ export default function PasoSugerenciasForm({ const nuevasSugerencias = await generate_subject_suggestions({ plan_estudio_id: wizard.plan_estudio_id, - numero_de_ciclo: numeroCiclo, enfoque: enfoqueTrim ? enfoqueTrim : undefined, cantidad_de_sugerencias: cantidad, sugerencias_conservadas: sugerenciasConservadas.map((s) => ({ @@ -91,11 +99,19 @@ export default function PasoSugerenciasForm({ })), }) + if (hadNoSugerenciasBefore && nuevasSugerencias.length > 0) { + setShowConservacionTooltip(true) + } + onChange( (w): NewSubjectWizardState => ({ ...w, - isLoading: false, sugerencias: [...nuevasSugerencias, ...sugerenciasConservadas], + iaMultiple: { + enfoque: w.iaMultiple?.enfoque ?? '', + cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10, + isLoading: false, + }, }), ) } catch (err) { @@ -104,8 +120,12 @@ export default function PasoSugerenciasForm({ onChange( (w): NewSubjectWizardState => ({ ...w, - isLoading: false, errorMessage: message, + iaMultiple: { + enfoque: w.iaMultiple?.enfoque ?? '', + cantidadDeSugerencias: w.iaMultiple?.cantidadDeSugerencias ?? 10, + isLoading: false, + }, }), ) } @@ -122,48 +142,23 @@ export default function PasoSugerenciasForm({ -
- {/* Input Ciclo */} -
- - { - const raw = e.target.value - if (raw === '') { - setIaMultiple({ ciclo: null }) - return - } - const asNumber = Number(raw) - if (!Number.isFinite(asNumber)) return - const n = Math.floor(Math.abs(asNumber)) - const capped = Math.min(n >= 1 ? n : 1, 999) - setIaMultiple({ ciclo: capped }) - }} - /> -
- - {/* Input Enfoque */} -
+
+
- setIaMultiple({ enfoque: e.target.value })} />
-
-
+
+
@@ -172,7 +167,7 @@ export default function PasoSugerenciasForm({ value={cantidadDeSugerencias} type="number" min={1} - max={50} + max={15} step={1} inputMode="numeric" onKeyDown={(e) => { @@ -186,7 +181,7 @@ export default function PasoSugerenciasForm({ const asNumber = Number(raw) if (!Number.isFinite(asNumber)) return const n = Math.floor(Math.abs(asNumber)) - const capped = Math.min(n >= 1 ? n : 1, 50) + const capped = Math.min(n >= 1 ? n : 1, 15) setIaMultiple({ cantidadDeSugerencias: capped }) }} /> @@ -197,19 +192,21 @@ export default function PasoSugerenciasForm({ variant="outline" className="h-9 gap-1.5" onClick={onGenerarSugerencias} - disabled={wizard.isLoading} + disabled={isLoading} > - Generar sugerencias + {wizard.sugerencias.length > 0 + ? 'Generar más sugerencias' + : 'Generar sugerencias'}
- -

- Al generar más sugerencias, solo se conservarán las asignaturas que - hayas seleccionado. -

+ + {/* --- HEADER LISTA --- */}
@@ -221,9 +218,32 @@ export default function PasoSugerenciasForm({ {plan ? `${plan.nivel} en ${plan.nombre}` : '...'}

-
- {wizard.sugerencias.filter((s) => s.selected).length} seleccionadas -
+ + +
+ 📌 + {wizard.sugerencias.filter((s) => s.selected).length}{' '} + seleccionadas +
+
+ +
+ + Al generar más sugerencias, se conservarán las asignaturas + seleccionadas. + + +
+
+
{/* --- LISTA DE ASIGNATURAS --- */} diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 590f170..f64dd2b 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -140,7 +140,6 @@ export type AIGenerateSubjectInput = { export type GenerateSubjectSuggestionsInput = { plan_estudio_id: UUID - numero_de_ciclo: number enfoque?: string cantidad_de_sugerencias: number sugerencias_conservadas: Array<{ nombre: string; descripcion: string }> diff --git a/src/features/asignaturas/nueva/AIProgressLoader.tsx b/src/features/asignaturas/nueva/AIProgressLoader.tsx new file mode 100644 index 0000000..4e9177d --- /dev/null +++ b/src/features/asignaturas/nueva/AIProgressLoader.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect, useMemo } from 'react' + +// --- DEFINICIÓN DE MENSAJES --- +const MENSAJES_CORTOS = [ + // Hasta 5 sugerencias (6 mensajes) + 'Analizando el plan de estudios...', + 'Identificando áreas de oportunidad...', + 'Consultando bases de datos académicas...', + 'Redactando competencias específicas...', + 'Calculando créditos y horas...', + 'Afinando los últimos detalles...', +] + +const MENSAJES_MEDIOS = [ + // Hasta 10 sugerencias (10 mensajes) + 'Conectando con el motor de IA...', + 'Analizando estructura curricular...', + 'Buscando asignaturas compatibles...', + 'Verificando prerrequisitos...', + 'Generando descripciones detalladas...', + 'Balanceando cargas académicas...', + 'Asignando horas independientes...', + 'Validando coherencia temática...', + 'Formateando resultados...', + 'Finalizando generación...', +] + +const MENSAJES_LARGOS = [ + // Más de 10 sugerencias (14 mensajes) + 'Iniciando procesamiento masivo...', + 'Escaneando retícula completa...', + 'Detectando líneas de investigación...', + 'Generando primer bloque de asignaturas...', + 'Evaluando pertinencia académica...', + 'Optimizando créditos por ciclo...', + 'Redactando objetivos de aprendizaje...', + 'Generando segundo bloque...', + 'Revisando duplicidad de contenidos...', + 'Ajustando tiempos teóricos y prácticos...', + 'Verificando normatividad...', + 'Compilando sugerencias...', + 'Aplicando formato final...', + 'Casi listo, gracias por tu paciencia...', +] + +interface AIProgressLoaderProps { + isLoading: boolean + cantidadDeSugerencias: number +} + +export const AIProgressLoader: React.FC = ({ + isLoading, + cantidadDeSugerencias, +}) => { + const [progress, setProgress] = useState(0) + const [currentMessageIndex, setCurrentMessageIndex] = useState(0) + + // 1. Seleccionar el grupo de mensajes según la cantidad + const messages = useMemo(() => { + if (cantidadDeSugerencias <= 5) return MENSAJES_CORTOS + if (cantidadDeSugerencias <= 10) return MENSAJES_MEDIOS + return MENSAJES_LARGOS + }, [cantidadDeSugerencias]) + + useEffect(() => { + if (!isLoading) { + setProgress(0) + setCurrentMessageIndex(0) + return + } + + // --- CÁLCULO DEL TIEMPO TOTAL --- + // y = 4.07x + 10.93 (en segundos) + const estimatedSeconds = 4.07 * cantidadDeSugerencias + 10.93 + const durationMs = estimatedSeconds * 1000 + + // Intervalo de actualización de la barra (cada 50ms para suavidad) + const updateInterval = 50 + const totalSteps = durationMs / updateInterval + const incrementPerStep = 99 / totalSteps // Llegamos al 99% para esperar la respuesta real + + // --- TIMER 1: BARRA DE PROGRESO --- + const progressTimer = setInterval(() => { + setProgress((prev) => { + const next = prev + incrementPerStep + return next >= 99 ? 99 : next // Topar en 99% + }) + }, updateInterval) + + // --- TIMER 2: MENSAJES (CADA 5 SEGUNDOS) --- + const messagesTimer = setInterval(() => { + setCurrentMessageIndex((prev) => { + // Si ya es el último mensaje, no avanzar más (no ciclar) + if (prev >= messages.length - 1) return prev + return prev + 1 + }) + }, 5000) + + // Cleanup al desmontar o cuando isLoading cambie + return () => { + clearInterval(progressTimer) + clearInterval(messagesTimer) + } + }, [isLoading, cantidadDeSugerencias, messages]) + + if (!isLoading) return null + + return ( +
+ {/* Contenedor de la barra */} +
+
+
+ + Generando IA + +
+
+ + {Math.floor(progress)}% + +
+
+ + {/* Barra de fondo */} +
+ {/* Barra de progreso dinámica */} +
+
+ + {/* Mensajes cambiantes */} +
+ {' '} + {/* Altura fija para evitar saltos */} +

+ {messages[currentMessageIndex]} +

+
+ + {/* Nota de tiempo estimado (Opcional, transparencia operacional) */} +

+ Tiempo estimado: ~{Math.ceil(4.07 * cantidadDeSugerencias + 10.93)}{' '} + segs +

+
+
+ ) +} diff --git a/src/features/asignaturas/nueva/hooks/useNuevaAsignaturaWizard.ts b/src/features/asignaturas/nueva/hooks/useNuevaAsignaturaWizard.ts index d920134..bdff38d 100644 --- a/src/features/asignaturas/nueva/hooks/useNuevaAsignaturaWizard.ts +++ b/src/features/asignaturas/nueva/hooks/useNuevaAsignaturaWizard.ts @@ -30,9 +30,9 @@ export function useNuevaAsignaturaWizard(planId: string) { archivosAdjuntos: [], }, iaMultiple: { - ciclo: null, enfoque: '', cantidadDeSugerencias: 10, + isLoading: false, }, resumen: {}, isLoading: false, diff --git a/src/features/asignaturas/nueva/types.ts b/src/features/asignaturas/nueva/types.ts index d529f13..8c7a8de 100644 --- a/src/features/asignaturas/nueva/types.ts +++ b/src/features/asignaturas/nueva/types.ts @@ -65,9 +65,9 @@ export type NewSubjectWizardState = { archivosAdjuntos?: Array } iaMultiple?: { - ciclo: number | null enfoque: string cantidadDeSugerencias: number + isLoading: boolean } resumen: { previewAsignatura?: AsignaturaPreview