diff --git a/public/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png b/public/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png new file mode 100644 index 0000000..3c87fa3 Binary files /dev/null and b/public/4_IMAGOTIPO_LASALLE_MEXICO_COLOR_RGB_2020.png differ diff --git a/public/fondo_login.png b/public/fondo_login.png new file mode 100644 index 0000000..96c0863 Binary files /dev/null and b/public/fondo_login.png differ diff --git a/src/components/asignaturas/detalle/BibliographyItem.tsx b/src/components/asignaturas/detalle/BibliographyItem.tsx index a6c4b37..1580fb4 100644 --- a/src/components/asignaturas/detalle/BibliographyItem.tsx +++ b/src/components/asignaturas/detalle/BibliographyItem.tsx @@ -169,7 +169,7 @@ export function BibliographyItem() { resetScroll: false, }) } - className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 items-center justify-center gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors" + className="shadow-md" > Agregar Bibliografía diff --git a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx index 1e6e160..7b558b3 100644 --- a/src/components/asignaturas/detalle/DocumentoSEPTab.tsx +++ b/src/components/asignaturas/detalle/DocumentoSEPTab.tsx @@ -94,11 +94,7 @@ export function DocumentoSEPTab({ {pdfUrl && !isLoading && ( <> - + {format(cambio.fecha, 'HH:mm')} diff --git a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx index 1e274d5..4d083d4 100644 --- a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx +++ b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx @@ -15,7 +15,7 @@ import { Archive, History, Edit2, - Loader2, // Agregado + Loader2, } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' @@ -62,6 +62,7 @@ export function IAAsignaturaTab() { from: '/planes/$planId/asignaturas/$asignaturaId', }) const location = useLocation() + // --- ESTADOS --- const [activeChatId, setActiveChatId] = useState( undefined, @@ -109,7 +110,6 @@ export function IAAsignaturaTab() { setIsSending(true) try { - // 1. Consolidar el Patch const patchData: any = { datos: { ...datosGenerales.datos }, } @@ -124,13 +124,11 @@ export function IAAsignaturaTab() { } } - // 2. Actualización única de la asignatura await updateAsignatura.mutateAsync({ asignaturaId: asignaturaId as any, patch: patchData, }) - // 3. Marcar recomendaciones una por una (Secuencialmente para evitar colisiones) for (const sug of sugerencias) { await updateRecommendation.mutateAsync({ mensajeId: sug.messageId, @@ -138,14 +136,12 @@ export function IAAsignaturaTab() { }) } - // 4. Limpieza de estados const appliedKeys = sugerencias.map((s) => s.campoKey) setSelectedFields((prev) => prev.filter((f) => !appliedKeys.includes(f.key)), ) setSelectedImprovements([]) - // Forzar actualización visual queryClient.invalidateQueries({ queryKey: ['subject', asignaturaId] }) } catch (error) { console.error('Error en aplicación masiva:', error) @@ -153,6 +149,7 @@ export function IAAsignaturaTab() { setIsSending(false) } } + const toggleImprovementSelection = (sugId: string) => { setSelectedImprovements((prev) => prev.includes(sugId) @@ -176,7 +173,6 @@ export function IAAsignaturaTab() { } } - // Cálculo del total para el Badge del botón const totalReferencias = selectedArchivoIds.length + selectedRepositorioIds.length + @@ -186,14 +182,12 @@ export function IAAsignaturaTab() { if (isSending) return true if (!rawMessages || rawMessages.length === 0) return false - // Verificamos si el último mensaje está en estado de procesamiento const lastMessage = rawMessages[rawMessages.length - 1] return ( lastMessage.estado === 'PROCESANDO' || lastMessage.estado === 'PENDIENTE' ) }, [isSending, rawMessages]) - // --- AUTO-SCROLL --- useEffect(() => { const viewport = scrollRef.current?.querySelector( '[data-radix-scroll-area-viewport]', @@ -203,7 +197,6 @@ export function IAAsignaturaTab() { } }, [rawMessages, isSending]) - // --- FILTRADO DE CHATS --- const { activeChats, archivedChats } = useMemo(() => { const chats = todasConversaciones || [] return { @@ -213,7 +206,6 @@ export function IAAsignaturaTab() { }, [todasConversaciones]) const availableFields = useMemo(() => { - // 1. Obtenemos los campos dinámicos de la DB const dynamicFields = datosGenerales?.datos ? Object.keys(datosGenerales.datos).map((key) => { const estructuraProps = @@ -228,13 +220,8 @@ export function IAAsignaturaTab() { }) : [] - // 2. Definimos tus campos manuales (hardcoded) const hardcodedFields = [ - { - key: 'contenido_tematico', - label: 'Contenido temático', - value: '', // Puedes dejarlo vacío o buscarlo en datosGenerales si existiera - }, + { key: 'contenido_tematico', label: 'Contenido temático', value: '' }, { key: 'criterios_de_evaluacion', label: 'Criterios de evaluación', @@ -242,7 +229,6 @@ export function IAAsignaturaTab() { }, ] - // 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía const combined = [...dynamicFields] hardcodedFields.forEach((hf) => { @@ -254,18 +240,13 @@ export function IAAsignaturaTab() { return combined }, [datosGenerales]) - // --- PROCESAMIENTO DE MENSAJES --- - // --- PROCESAMIENTO DE MENSAJES --- const messages = useMemo(() => { const msgs: Array = [] - // 1. Mensajes existentes de la DB if (rawMessages) { rawMessages.forEach((m) => { - // Mensaje del usuario msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje }) - // Respuesta de la IA (si existe) if (m.respuesta) { const sugerencias = m.propuesta?.recommendations?.map((rec: any, index: number) => ({ @@ -287,21 +268,14 @@ export function IAAsignaturaTab() { }) } - // 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario if (isSending && input.trim()) { - msgs.push({ - id: 'optimistic-user-msg', - role: 'user', - content: input, - }) + msgs.push({ id: 'optimistic-user-msg', role: 'user', content: input }) } return msgs }, [rawMessages, isSending, input]) - // Auto-selección inicial useEffect(() => { - // Si ya hay un chat, o si el usuario ya interactuó (hasInitialSelected), abortamos. if (activeChatId || hasInitialSelected.current) return if (activeChats.length > 0 && !loadingConv) { @@ -312,16 +286,14 @@ export function IAAsignaturaTab() { const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value - const selectionStart = e.target.selectionStart // Posición del cursor + const selectionStart = e.target.selectionStart setInput(value) - // Buscamos si el carácter anterior al cursor es ':' const lastChar = value.slice(selectionStart - 1, selectionStart) if (lastChar === ':') { setShowSuggestions(true) } else if (!value.includes(':')) { - // Si borran todos los dos puntos, cerramos setShowSuggestions(false) } } @@ -329,17 +301,11 @@ export function IAAsignaturaTab() { const filteredFields = useMemo(() => { if (!showSuggestions || !input) return availableFields - // 1. Encontrar el ":" más cercano a la IZQUIERDA del cursor - // Usamos una posición de referencia (si no tienes ref, usaremos el final del string, - // pero para mayor precisión lo ideal es usar e.target.selectionStart en el onChange) - const lastColonIndex = input.lastIndexOf(':') if (lastColonIndex === -1) return availableFields - // 2. Extraer solo el fragmento de "búsqueda" - // Cortamos desde el ":" hasta el final, y luego tomamos solo la primera palabra const textAfterColon = input.slice(lastColonIndex + 1) - const query = textAfterColon.split(/\s/)[0].toLowerCase() // Se detiene al encontrar un espacio + const query = textAfterColon.split(/\s/)[0].toLowerCase() if (!query) return availableFields @@ -353,28 +319,21 @@ export function IAAsignaturaTab() { useEffect(() => { const state = location.state as any - // Verificamos si existe el timestamp (_ts) para saber que es una acción nueva if (state?.activeTab === 'ia' && state?._ts) { - // Si el campo no está ya seleccionado, lo agregamos if (state.prefillCampo) { const fieldToSelect = availableFields.find( (f) => f.key === state.prefillCampo, ) if (fieldToSelect) { - setSelectedFields([fieldToSelect]) // Reemplaza o añade según prefieras - - // Generamos un prompt inicial automático + setSelectedFields([fieldToSelect]) const autoPrompt = `Mejora el contenido de: ${fieldToSelect.label}` setInput(autoPrompt) - - // Opcional: Si quieres que dispare la IA inmediatamente al llegar: - // handleSend(autoPrompt) } } } }, [location.state, availableFields]) - // 2. Efecto para cerrar con ESC + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setShowSuggestions(false) @@ -383,7 +342,6 @@ export function IAAsignaturaTab() { return () => window.removeEventListener('keydown', handleKeyDown) }, []) - // 3. Función para insertar el campo y limpiar el prompt const handleSelectField = (field: SelectedField) => { if (!selectedFields.find((f) => f.key === field.key)) { setSelectedFields((prev) => [...prev, field]) @@ -392,16 +350,11 @@ export function IAAsignaturaTab() { const lastColonIndex = input.lastIndexOf(':') if (lastColonIndex !== -1) { const parteAntesDelColon = input.slice(0, lastColonIndex) - - // Buscamos si hay texto después de la palabra que estamos escribiendo const textoDespuesDelColon = input.slice(lastColonIndex + 1) const espacioIndex = textoDespuesDelColon.indexOf(' ') - - // Si hay un espacio, guardamos lo que sigue. Si no, es el final del texto. const parteRestante = espacioIndex !== -1 ? textoDespuesDelColon.slice(espacioIndex) : '' - // Reconstruimos: [Antes] + [Label] + [Lo que ya estaba después] const nuevoTexto = `${parteAntesDelColon}${field.label}${parteRestante}` setInput(nuevoTexto) } @@ -423,21 +376,17 @@ export function IAAsignaturaTab() { setIsSending(true) try { const response = await sendMessage({ - subjectId: asignaturaId as any, // Importante: se usa para crear la conv si activeChatId es undefined + subjectId: asignaturaId as any, content: text, campos: selectedFields.map((f) => f.key), - conversacionId: activeChatId, // Si es undefined, la mutación crea el chat automáticamente + conversacionId: activeChatId, }) - // IMPORTANTE: Después de la respuesta, actualizamos el ID activo con el que creó el backend if (response.conversacionId) { setActiveChatId(response.conversacionId) } setInput('') - // setSelectedFields([]) - - // Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo) queryClient.invalidateQueries({ queryKey: ['conversation-by-subject', asignaturaId], }) @@ -457,10 +406,9 @@ export function IAAsignaturaTab() { } const createNewChat = () => { - setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend + setActiveChatId(undefined) setInput('') setSelectedFields([]) - // Opcional: podrías forzar el foco al textarea aquí con una ref } const PRESETS = [ @@ -485,19 +433,19 @@ export function IAAsignaturaTab() { ] return ( -
-
+
+
- + Asistente
@@ -505,16 +453,17 @@ export function IAAsignaturaTab() {
+ {/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */} -