From 56ac8c015532c3724afa3287d3b23ee772b1cb06 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Wed, 11 Mar 2026 16:06:26 -0600 Subject: [PATCH] Se homologa vista y funcionalidades de chat de asignatura ( Guardar cambios o mejora es decir aplicar mejora, crear conversaciones, renombrar conversaciones, archivar conversaciones visualizar modal de referencias) --- .../asignaturas/detalle/IAAsignaturaTab.tsx | 448 ++++++++++++------ .../SaveAsignatura/ImprovementCardProps.tsx | 109 +++++ src/data/api/ai.api.ts | 16 + src/data/hooks/useAI.ts | 15 + 4 files changed, 448 insertions(+), 140 deletions(-) create mode 100644 src/components/asignaturas/detalle/SaveAsignatura/ImprovementCardProps.tsx diff --git a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx index c584495..4e087b8 100644 --- a/src/components/asignaturas/detalle/IAAsignaturaTab.tsx +++ b/src/components/asignaturas/detalle/IAAsignaturaTab.tsx @@ -13,14 +13,19 @@ import { X, MessageSquarePlus, Archive, - History, // Agregado + History, + Edit2, // Agregado } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' +import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps' + import type { IASugerencia } from '@/types/asignatura' +import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' +import { Drawer, DrawerContent } from '@/components/ui/drawer' import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { @@ -28,6 +33,7 @@ import { useConversationBySubject, useMessagesBySubjectChat, useSubject, + useUpdateSubjectConversationName, useUpdateSubjectConversationStatus, } from '@/data' import { cn } from '@/lib/utils' @@ -75,6 +81,24 @@ export function IAAsignaturaTab({ const { mutate: updateStatus } = useUpdateSubjectConversationStatus() const [isCreatingNewChat, setIsCreatingNewChat] = useState(false) const hasInitialSelected = useRef(false) + const { mutate: updateName } = useUpdateSubjectConversationName() + const [editingId, setEditingId] = useState(null) + const [tempName, setTempName] = useState('') + const [openIA, setOpenIA] = useState(false) + + const [selectedArchivoIds, setSelectedArchivoIds] = useState>( + [], + ) + const [selectedRepositorioIds, setSelectedRepositorioIds] = useState< + Array + >([]) + const [uploadedFiles, setUploadedFiles] = useState>([]) + + // Cálculo del total para el Badge del botón + const totalReferencias = + selectedArchivoIds.length + + selectedRepositorioIds.length + + uploadedFiles.length const isAiThinking = useMemo(() => { if (isSending) return true @@ -106,38 +130,62 @@ export function IAAsignaturaTab({ } }, [todasConversaciones]) + const availableFields = useMemo(() => { + if (!datosGenerales?.datos) return [] + const estructuraProps = + datosGenerales?.estructuras_asignatura?.definicion?.properties || {} + return Object.keys(datosGenerales.datos).map((key) => ({ + key, + label: + estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(), + value: String(datosGenerales.datos[key] || ''), + })) + }, [datosGenerales]) + + // --- PROCESAMIENTO DE MENSAJES --- // --- PROCESAMIENTO DE MENSAJES --- const messages = useMemo(() => { - if (!rawMessages) return [] - return rawMessages.flatMap((m) => { - const msgs = [] + const msgs: Array = [] - // 1. Mensaje del usuario - msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje }) + // 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 }) - // 2. Respuesta de la IA - if (m.respuesta) { - // Mapeamos TODAS las recomendaciones del array - const sugerencias = - m.propuesta?.recommendations?.map((rec: any, index: number) => ({ - id: `${m.id}-sug-${index}`, // ID único por sugerencia - messageId: m.id, - campoKey: rec.campo_afectado, - campoNombre: rec.campo_afectado.replace(/_/g, ' '), - valorSugerido: rec.texto_mejora, - aceptada: rec.aplicada, - })) || [] + // Respuesta de la IA (si existe) + if (m.respuesta) { + const sugerencias = + m.propuesta?.recommendations?.map((rec: any, index: number) => ({ + id: `${m.id}-sug-${index}`, + messageId: m.id, + campoKey: rec.campo_afectado, + campoNombre: rec.campo_afectado.replace(/_/g, ' '), + valorSugerido: rec.texto_mejora, + aceptada: rec.aplicada, + })) || [] - msgs.push({ - id: `${m.id}-ai`, - role: 'assistant', - content: m.respuesta, - sugerencias: sugerencias, // Ahora es un plural (array) - }) - } - return msgs - }) - }, [rawMessages]) + msgs.push({ + id: `${m.id}-ai`, + role: 'assistant', + content: m.respuesta, + sugerencias: sugerencias, + }) + } + }) + } + + // 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, + }) + } + + return msgs + }, [rawMessages, isSending, input]) // Auto-selección inicial useEffect(() => { @@ -150,6 +198,58 @@ export function IAAsignaturaTab({ } }, [activeChats, loadingConv]) + const filteredFields = useMemo(() => { + if (!showSuggestions) return availableFields + + // Extraemos lo que hay después del último ':' para filtrar + const lastColonIndex = input.lastIndexOf(':') + const query = input.slice(lastColonIndex + 1).toLowerCase() + + return availableFields.filter( + (f) => + f.label.toLowerCase().includes(query) || + f.key.toLowerCase().includes(query), + ) + }, [availableFields, input, showSuggestions]) + + // 2. Efecto para cerrar con ESC + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowSuggestions(false) + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, []) + + // 3. Función para insertar el campo y limpiar el prompt + const handleSelectField = (field: SelectedField) => { + // 1. Agregamos al array de objetos (para tu lógica de API) + if (!selectedFields.find((f) => f.key === field.key)) { + setSelectedFields((prev) => [...prev, field]) + } + + // 2. Lógica de autocompletado en el texto + const lastColonIndex = input.lastIndexOf(':') + if (lastColonIndex !== -1) { + // Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio + const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} ` + setInput(nuevoTexto) + } + + // 3. Cerramos el buscador y devolvemos el foco al textarea + setShowSuggestions(false) + + // Opcional: Si tienes una ref del textarea, puedes hacer: + // textareaRef.current?.focus() + } + + const handleSaveName = (id: string) => { + if (tempName.trim()) { + updateName({ id, nombre: tempName }) + } + setEditingId(null) + } + const handleSend = async (promptOverride?: string) => { const text = promptOverride || input if (!text.trim() && selectedFields.length === 0) return @@ -190,18 +290,6 @@ export function IAAsignaturaTab({ ) } - const availableFields = useMemo(() => { - if (!datosGenerales?.datos) return [] - const estructuraProps = - datosGenerales?.estructuras_asignatura?.definicion?.properties || {} - return Object.keys(datosGenerales.datos).map((key) => ({ - key, - label: - estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(), - value: String(datosGenerales.datos[key] || ''), - })) - }, [datosGenerales]) - const createNewChat = () => { setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend setInput('') @@ -273,45 +361,86 @@ export function IAAsignaturaTab({
+ {/* CORRECCIÓN: Mapear ambos casos */} {(showArchived ? archivedChats : activeChats).map((chat: any) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
{ - setActiveChatId(chat.id) - setIsCreatingNewChat(false) // <--- Volvemos al modo normal - }} className={cn( - 'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all', + 'group relative flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-all', activeChatId === chat.id - ? 'bg-teal-50 font-medium text-teal-900' + ? 'bg-teal-50 text-teal-900' : 'text-slate-600 hover:bg-slate-100', )} > - - {chat.titulo || 'Conversación'} - - + + {editingId === chat.id ? ( +
+ setTempName(e.target.value)} + onBlur={() => handleSaveName(chat.id)} // Guardar al hacer clic fuera + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveName(chat.id) + if (e.key === 'Escape') setEditingId(null) + }} + /> +
+ ) : ( + <> + setActiveChatId(chat.id)} + className="flex-1 cursor-pointer truncate" + > + {/* CORRECCIÓN: Usar 'nombre' si así se llama en tu DB */} + {chat.nombre || chat.titulo || 'Conversación'} + + +
+ + + {/* Botón para Archivar/Desarchivar dinámico */} + +
+ + )}
))}
@@ -320,10 +449,22 @@ export function IAAsignaturaTab({ {/* PANEL CENTRAL */}
-
+
Asistente IA +
@@ -386,62 +527,40 @@ export function IAAsignaturaTab({

Mejoras disponibles:

- {msg.sugerencias.map((sug: any) => - sug.aceptada ? ( - /* --- ESTADO: YA APLICADO (Basado en tu última imagen) --- */ -
-
- - {sug.campoNombre} - - - {/* Badge de Aplicado */} -
- - Aplicado -
-
- -
- "{sug.valorSugerido}" -
-
- ) : ( - /* --- ESTADO: PENDIENTE POR APLICAR --- */ -
-
- - {sug.campoNombre} - - - -
- -
- "{sug.valorSugerido}" -
-
- ), - )} + {msg.sugerencias.map((sug: any) => ( + + ))}
)}
))} + {isAiThinking && ( +
+ + + + + +
+
+
+ + + +
+
+ + La IA está analizando tu solicitud... + +
+
+ )} {/* Espacio extra al final para que el scroll no tape el último mensaje */}
@@ -452,39 +571,53 @@ export function IAAsignaturaTab({
{showSuggestions && ( -
-
- Campos de Asignatura +
+
+ Filtrando campos... + + ESC para cerrar +
-
- {availableFields.map((field) => ( - - ))} +
+ {filteredFields.length > 0 ? ( + filteredFields.map((field) => ( + + )) + ) : ( +
+ No se encontraron coincidencias +
+ )}
)}
{selectedFields.length > 0 && ( -
+
{selectedFields.map((field) => (
+ {field.label} @@ -547,6 +680,41 @@ export function IAAsignaturaTab({ ))}
+ {/* --- DRAWER DE REFERENCIAS --- */} + + +
+

+ Referencias para la IA +

+ +
+ +
+ { + setSelectedArchivoIds((prev) => + checked ? [...prev, id] : prev.filter((a) => a !== id), + ) + }} + onToggleRepositorio={(id, checked) => { + setSelectedRepositorioIds((prev) => + checked ? [...prev, id] : prev.filter((r) => r !== id), + ) + }} + onFilesChange={(files) => setUploadedFiles(files)} + /> +
+
+
) } diff --git a/src/components/asignaturas/detalle/SaveAsignatura/ImprovementCardProps.tsx b/src/components/asignaturas/detalle/SaveAsignatura/ImprovementCardProps.tsx new file mode 100644 index 0000000..83dac0d --- /dev/null +++ b/src/components/asignaturas/detalle/SaveAsignatura/ImprovementCardProps.tsx @@ -0,0 +1,109 @@ +import { Check, Loader2 } from 'lucide-react' +import { useState } from 'react' + +import type { IASugerencia } from '@/types/asignatura' + +import { Button } from '@/components/ui/button' +import { + useUpdateAsignatura, + useSubject, + useUpdateSubjectRecommendation, // Importamos tu nuevo hook +} from '@/data' + +interface ImprovementCardProps { + sug: IASugerencia + asignaturaId: string +} + +export function ImprovementCard({ sug, asignaturaId }: ImprovementCardProps) { + const { data: asignatura } = useSubject(asignaturaId) + const updateAsignatura = useUpdateAsignatura() + + // Hook para marcar en la base de datos que la sugerencia fue aceptada + const updateRecommendation = useUpdateSubjectRecommendation() + + const [isApplying, setIsApplying] = useState(false) + + const handleApply = async () => { + if (!asignatura?.datos) return + + setIsApplying(true) + try { + // 1. Actualizar el contenido real de la asignatura (JSON datos) + const nuevosDatos = { + ...asignatura.datos, + [sug.campoKey]: sug.valorSugerido, + } + + await updateAsignatura.mutateAsync({ + asignaturaId: asignaturaId as any, + patch: { + datos: nuevosDatos, + } as any, + }) + + // 2. Marcar la sugerencia como "aplicada: true" en la tabla de mensajes + // Usamos los datos que vienen en el objeto 'sug' + await updateRecommendation.mutateAsync({ + mensajeId: sug.messageId, + campoAfectado: sug.campoKey, + }) + + // Al terminar, React Query invalidará 'subject-messages' + // y la card pasará automáticamente al estado "Aplicado" (gris) + } catch (error) { + console.error('Error al aplicar mejora:', error) + } finally { + setIsApplying(false) + } + } + + // --- ESTADO APLICADO --- + if (sug.aceptada) { + return ( +
+
+ + {sug.campoNombre} + +
+ + Aplicado +
+
+
+ "{sug.valorSugerido}" +
+
+ ) + } + + // --- ESTADO PENDIENTE --- + return ( +
+
+ + {sug.campoNombre} + + + +
+ +
+ "{sug.valorSugerido}" +
+
+ ) +} diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index 08f66cc..1464504 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -359,3 +359,19 @@ export async function update_subject_conversation_status( if (error) throw error return data } + +export async function update_subject_conversation_name( + conversacionId: string, + nuevoNombre: string, +) { + const supabase = supabaseBrowser() + const { data, error } = await supabase + .from('conversaciones_asignatura') + .update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre' + .eq('id', conversacionId) + .select() + .single() + + if (error) throw error + return data +} diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 464f423..d839149 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -19,6 +19,7 @@ import { getConversationBySubject, ai_subject_chat_v2, create_subject_conversation, + update_subject_conversation_name, } from '../api/ai.api' import { supabaseBrowser } from '../supabase/client' @@ -320,3 +321,17 @@ export function useUpdateSubjectConversationStatus() { }, }) } + +export function useUpdateSubjectConversationName() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: (payload: { id: string; nombre: string }) => + update_subject_conversation_name(payload.id, payload.nombre), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['conversation-by-subject'] }) + // También invalidamos los mensajes si el título se muestra en la cabecera + qc.invalidateQueries({ queryKey: ['subject-messages'] }) + }, + }) +}