From a51fa6b1fc28e7ea4a9796ce7ab8bd08efc165c4 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Tue, 24 Feb 2026 12:33:27 -0600 Subject: [PATCH 1/7] =?UTF-8?q?A=C3=B1adir=20Bibliograf=C3=ADa=20fix=20#12?= =?UTF-8?q?8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../asignaturas/detalle/BibliographyItem.tsx | 122 +++++++++++------- src/data/api/subjects.api.ts | 48 +++++++ src/data/hooks/useSubjects.ts | 41 ++++++ 3 files changed, 166 insertions(+), 45 deletions(-) diff --git a/src/components/asignaturas/detalle/BibliographyItem.tsx b/src/components/asignaturas/detalle/BibliographyItem.tsx index acec679..2067498 100644 --- a/src/components/asignaturas/detalle/BibliographyItem.tsx +++ b/src/components/asignaturas/detalle/BibliographyItem.tsx @@ -1,6 +1,9 @@ +/* eslint-disable jsx-a11y/click-events-have-key-events */ +/* eslint-disable jsx-a11y/label-has-associated-control */ +/* eslint-disable jsx-a11y/no-static-element-interactions */ import { useParams } from '@tanstack/react-router' import { Plus, Search, BookOpen, Trash2, Library, Edit3 } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useState } from 'react' import { AlertDialog, @@ -31,7 +34,12 @@ import { SelectValue, } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { useSubjectBibliografia } from '@/data/hooks/useSubjects' +import { + useCreateBibliografia, + useDeleteBibliografia, + useSubjectBibliografia, + useUpdateBibliografia, +} from '@/data/hooks/useSubjects' import { cn } from '@/lib/utils' // --- Interfaces --- @@ -50,9 +58,16 @@ export function BibliographyItem() { from: '/planes/$planId/asignaturas/$asignaturaId', }) - const { data: bibliografia2, isLoading: loadinasignatura } = + // --- 1. Única fuente de verdad: La Query --- + const { data: bibliografia = [], isLoading } = useSubjectBibliografia(asignaturaId) - const [entries, setEntries] = useState>([]) + + // --- 2. Mutaciones --- + const { mutate: crearBibliografia } = useCreateBibliografia() + const { mutate: actualizarBibliografia } = useUpdateBibliografia(asignaturaId) + const { mutate: eliminarBibliografia } = useDeleteBibliografia(asignaturaId) + + // --- 3. Estados de UI (Solo para diálogos y edición) --- const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isLibraryDialogOpen, setIsLibraryDialogOpen] = useState(false) const [deleteId, setDeleteId] = useState(null) @@ -61,29 +76,27 @@ export function BibliographyItem() { 'BASICA', ) - useEffect(() => { - console.log(entries) - - if (bibliografia2 && Array.isArray(bibliografia2)) { - setEntries(bibliografia2) - } - }, [bibliografia2]) - - const basicaEntries = entries.filter((e) => e.tipo === 'BASICA') - const complementariaEntries = entries.filter( + console.log('Datos actuales en el front:', bibliografia) + // --- 4. Derivación de datos (Se calculan en cada render) --- + const basicaEntries = bibliografia.filter((e) => e.tipo === 'BASICA') + const complementariaEntries = bibliografia.filter( (e) => e.tipo === 'COMPLEMENTARIA', ) - console.log(bibliografia2) + + // --- Handlers Conectados a la Base de Datos --- const handleAddManual = (cita: string) => { - const newEntry: BibliografiaEntry = { - id: `manual-${Date.now()}`, - tipo: newEntryType, - cita, - } - setEntries([...entries, newEntry]) - setIsAddDialogOpen(false) - // toast.success('Referencia manual añadida'); + crearBibliografia( + { + asignatura_id: asignaturaId, + tipo: newEntryType, + cita, + tipo_fuente: 'MANUAL', + }, + { + onSuccess: () => setIsAddDialogOpen(false), + }, + ) } const handleAddFromLibrary = ( @@ -91,22 +104,43 @@ export function BibliographyItem() { tipo: 'BASICA' | 'COMPLEMENTARIA', ) => { const cita = `${resource.autor} (${resource.anio}). ${resource.titulo}. ${resource.editorial}.` - const newEntry: BibliografiaEntry = { - id: `lib-ref-${Date.now()}`, - tipo, - cita, - fuenteBibliotecaId: resource.id, - fuenteBiblioteca: resource, - } - setEntries([...entries, newEntry]) - setIsLibraryDialogOpen(false) - // toast.success('Añadido desde biblioteca'); + crearBibliografia( + { + asignatura_id: asignaturaId, + tipo, + cita, + tipo_fuente: 'BIBLIOTECA', + biblioteca_item_id: resource.id, + }, + { + onSuccess: () => setIsLibraryDialogOpen(false), + }, + ) } - const handleUpdateCita = (id: string, cita: string) => { - setEntries(entries.map((e) => (e.id === id ? { ...e, cita } : e))) + const handleUpdateCita = (id: string, nuevaCita: string) => { + actualizarBibliografia( + { + id, + updates: { cita: nuevaCita }, + }, + { + onSuccess: () => setEditingId(null), + }, + ) } + const onConfirmDelete = () => { + if (deleteId) { + eliminarBibliografia(deleteId, { + onSuccess: () => setDeleteId(null), + }) + } + } + + if (isLoading) + return
Cargando bibliografía...
+ return (
@@ -134,9 +168,13 @@ export function BibliographyItem() { e.fuenteBibliotecaId || '')} + // CORRECCIÓN: Usamos 'bibliografia' en lugar de 'entries' + existingIds={bibliografia.map( + (e) => e.biblioteca_item_id || '', + )} /> @@ -216,13 +254,7 @@ export function BibliographyItem() { Cancelar - { - setEntries(entries.filter((e) => e.id !== deleteId)) - setDeleteId(null) - }} - className="bg-red-600" - > + Eliminar @@ -412,7 +444,7 @@ function LibrarySearchDialog({ resources, onSelect, existingIds }: any) {
- {filtered.map((res) => ( + {filtered.map((res: any) => (
onSelect(res, tipo)} diff --git a/src/data/api/subjects.api.ts b/src/data/api/subjects.api.ts index 56a8e11..dd6bcb0 100644 --- a/src/data/api/subjects.api.ts +++ b/src/data/api/subjects.api.ts @@ -490,3 +490,51 @@ export async function lineas_delete(lineaId: string) { if (error) throw error return lineaId } + +export async function bibliografia_insert(entry: { + asignatura_id: string + tipo: 'BASICA' | 'COMPLEMENTARIA' + cita: string + tipo_fuente: 'MANUAL' | 'BIBLIOTECA' + biblioteca_item_id?: string | null +}) { + const supabase = supabaseBrowser() + const { data, error } = await supabase + .from('bibliografia_asignatura') + .insert([entry]) + .select() + .single() + + if (error) throw error + return data +} + +export async function bibliografia_update( + id: string, + updates: { + cita?: string + tipo?: 'BASICA' | 'COMPLEMENTARIA' + }, +) { + const supabase = supabaseBrowser() + const { data, error } = await supabase + .from('bibliografia_asignatura') + .update(updates) // Ahora 'updates' es compatible con lo que espera Supabase + .eq('id', id) + .select() + .single() + + if (error) throw error + return data +} + +export async function bibliografia_delete(id: string) { + const supabase = supabaseBrowser() + const { error } = await supabase + .from('bibliografia_asignatura') + .delete() + .eq('id', id) + + if (error) throw error + return id +} diff --git a/src/data/hooks/useSubjects.ts b/src/data/hooks/useSubjects.ts index 9975763..29dba1e 100644 --- a/src/data/hooks/useSubjects.ts +++ b/src/data/hooks/useSubjects.ts @@ -3,6 +3,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ai_generate_subject, asignaturas_update, + bibliografia_delete, + bibliografia_insert, + bibliografia_update, lineas_insert, lineas_update, subjects_bibliografia_list, @@ -276,3 +279,41 @@ export function useUpdateLinea() { }, }) } + +export function useCreateBibliografia() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: bibliografia_insert, + onSuccess: (data) => { + // USAR LA MISMA LLAVE QUE EL HOOK DE LECTURA + queryClient.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(data.asignatura_id), + }) + }, + }) +} + +export function useUpdateBibliografia(asignaturaId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ id, updates }: { id: string; updates: any }) => + bibliografia_update(id, updates), + onSuccess: () => { + qc.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(asignaturaId), + }) + }, + }) +} + +export function useDeleteBibliografia(asignaturaId: string) { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (id: string) => bibliografia_delete(id), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: qk.asignaturaBibliografia(asignaturaId), + }) + }, + }) +} From 6db7c1c0232369159afcfe9072341fe1d7f6b7f5 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Tue, 24 Feb 2026 16:09:35 -0600 Subject: [PATCH 2/7] Que te permita renombrar los chats fix #96 --- src/data/api/ai.api.ts | 23 ++- src/data/hooks/useAI.ts | 19 ++- src/routes/planes/$planId/_detalle/iaplan.tsx | 161 +++++++++++------- 3 files changed, 128 insertions(+), 75 deletions(-) diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index ca0c080..16632f7 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -111,12 +111,6 @@ export async function create_conversation(planId: string) { ) if (error) throw error - - // LOG de depuración: Mira qué estructura trae 'data' - console.log('Respuesta creación conv:', data) - - // Si data es { id: "..." }, devolvemos data. - // Si data viene envuelto, asegúrate de retornar el objeto con el id. return data } @@ -181,3 +175,20 @@ export async function getConversationByPlan(planId: string) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return data ?? [] } + +export async function update_conversation_title( + conversacionId: string, + nuevoTitulo: string, +) { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('conversaciones_plan') + .update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o '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 5338a52..24231a1 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -10,6 +10,7 @@ import { getConversationByPlan, library_search, update_conversation_status, + update_conversation_title, } from '../api/ai.api' // eslint-disable-next-line node/prefer-node-protocol @@ -35,8 +36,6 @@ export function useAIPlanChat() { // CAMBIO AQUÍ: Accedemos a la estructura correcta según tu consola currentId = response.conversation_plan.id - - console.log('Nuevo ID extraído:', currentId) } // 2. Ahora enviamos el mensaje con el ID garantizado @@ -56,11 +55,8 @@ export function useChatHistory(conversacionId?: string) { return useQuery({ queryKey: ['chat-history', conversacionId], queryFn: async () => { - console.log('--- EJECUTANDO QUERY FN ---') - console.log('ID RECIBIDO:', conversacionId) return get_chat_history(conversacionId!) }, - // Simplificamos el enabled para probar enabled: Boolean(conversacionId), }) } @@ -102,3 +98,16 @@ export function useAISubjectChat() { export function useLibrarySearch() { return useMutation({ mutationFn: library_search }) } + +export function useUpdateConversationTitle() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ id, nombre }: { id: string; nombre: string }) => + update_conversation_title(id, nombre), + onSuccess: (_, variables) => { + // Invalidamos para que la lista de chats se refresque + qc.invalidateQueries({ queryKey: ['conversation-by-plan'] }) + }, + }) +} diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index a8d6049..8100208 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -30,6 +30,7 @@ import { useChatHistory, useConversationByPlan, useUpdateConversationStatus, + useUpdateConversationTitle, } from '@/data' import { usePlan } from '@/data/hooks/usePlans' @@ -104,8 +105,21 @@ function RouteComponent() { const [pendingSuggestion, setPendingSuggestion] = useState(null) const queryClient = useQueryClient() const scrollRef = useRef(null) - const [showArchived, setShowArchived] = useState(false) + const [editingChatId, setEditingChatId] = useState(null) + const editableRef = useRef(null) + const { mutate: updateTitleMutation } = useUpdateConversationTitle() + + const availableFields = useMemo(() => { + if (!data?.estructuras_plan?.definicion?.properties) return [] + return Object.entries(data.estructuras_plan.definicion.properties).map( + ([key, value]) => ({ + key, + label: value.title, + value: String(value.description || ''), + }), + ) + }, [data]) useEffect(() => { // 1. Si no hay ID o está cargando el historial, no hacemos nada @@ -153,9 +167,6 @@ function RouteComponent() { type: suggestions.length > 0 ? 'improvement-card' : 'text', } }) - - // Solo actualizamos si no estamos esperando la respuesta de un POST - // para evitar saltos visuales if (!isLoading) { setMessages(flattened.reverse()) } @@ -175,6 +186,20 @@ function RouteComponent() { } }, [lastConversation, activeChatId]) + useEffect(() => { + const state = routerState.location.state as any + if (!state?.campo_edit || availableFields.length === 0) return + const field = availableFields.find( + (f) => + f.value === state.campo_edit.label || f.key === state.campo_edit.clave, + ) + if (!field) return + setSelectedFields([field]) + setInput((prev) => + injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), + ) + }, [availableFields]) + const createNewChat = () => { setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo setMessages([ @@ -224,37 +249,6 @@ function RouteComponent() { ) } - // 1. Transformar datos de la API para el menú de selección - const availableFields = useMemo(() => { - if (!data?.estructuras_plan?.definicion?.properties) return [] - return Object.entries(data.estructuras_plan.definicion.properties).map( - ([key, value]) => ({ - key, - label: value.title, - value: String(value.description || ''), - }), - ) - }, [data]) - - // 2. Manejar el estado inicial si viene de "Datos Generales" - useEffect(() => { - const state = routerState.location.state as any - if (!state?.campo_edit || availableFields.length === 0) return - - const field = availableFields.find( - (f) => - f.value === state.campo_edit.label || f.key === state.campo_edit.clave, - ) - - if (!field) return - - setSelectedFields([field]) - setInput((prev) => - injectFieldsIntoInput(prev || 'Mejora este campo:', [field]), - ) - }, [availableFields]) - - // 3. Lógica para el disparador ":" const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value setInput(val) @@ -290,17 +284,11 @@ function RouteComponent() { }) setInput((prev) => { - // 1. Eliminamos TODOS los ":" que existan en el texto actual - // 2. Quitamos espacios en blanco extra al final const cleanPrev = prev.replace(/:/g, '').trim() - // 3. Si el input resultante está vacío, solo ponemos la frase if (cleanPrev === '') { return `${field.label} ` } - - // 4. Si ya había algo, lo concatenamos con un espacio - // Usamos un espacio simple al final para que el usuario pueda seguir escribiendo return `${cleanPrev} ${field.label} ` }) @@ -329,8 +317,6 @@ function RouteComponent() { setMessages((prev) => [...prev, userMsg]) setInput('') - // setSelectedFields([]) - try { const payload: any = { planId: planId, @@ -454,7 +440,6 @@ function RouteComponent() {
{!showArchived ? ( - // --- LISTA DE CHATS ACTIVOS --- activeChats.map((chat) => (
- {/* Usamos el primer mensaje o un título por defecto */} - - {chat.title || `Chat ${chat.creado_en.split('T')[0]}`} - - + {chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} + + + {/* ACCIONES */} +
+ + +
)) ) : ( - // --- LISTA DE CHATS ARCHIVADOS --- + /* ... Resto del código de archivados (sin cambios) ... */

Archivados @@ -492,25 +533,17 @@ function RouteComponent() { > - {chat.title || + {chat.nombre || `Archivado ${chat.creado_en.split('T')[0]}`}

))} - {archivedChats.length === 0 && ( -
-

- No hay archivados -

-
- )}
)}
From 4a8a9e18572c17080599a02a29f38162d3425a3d Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Wed, 25 Feb 2026 13:58:12 -0600 Subject: [PATCH 3/7] =?UTF-8?q?Que=20la=20conversaci=C3=B3n=20se=20obtenga?= =?UTF-8?q?=20de=20conversation=5Fjson=20de=20supabase=20#136?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../planes/detalle/Ia/ImprovementCard.tsx | 16 +- src/data/api/ai.api.ts | 62 +++++++ src/data/hooks/useAI.ts | 26 +++ src/routes/planes/$planId/_detalle/iaplan.tsx | 165 ++++++------------ 4 files changed, 151 insertions(+), 118 deletions(-) diff --git a/src/components/planes/detalle/Ia/ImprovementCard.tsx b/src/components/planes/detalle/Ia/ImprovementCard.tsx index 67114b2..b27164c 100644 --- a/src/components/planes/detalle/Ia/ImprovementCard.tsx +++ b/src/components/planes/detalle/Ia/ImprovementCard.tsx @@ -2,21 +2,25 @@ import { Check, Loader2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' -import { useUpdatePlanFields } from '@/data' // Tu hook existente +import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data' // Tu hook existente export const ImprovementCard = ({ suggestions, onApply, planId, // Necesitamos el ID currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON + activeChatId, }: { suggestions: Array onApply?: (key: string, value: string) => void planId: string currentDatos: any + activeChatId: any }) => { const [appliedFields, setAppliedFields] = useState>([]) + const [localApplied, setLocalApplied] = useState>([]) const updatePlan = useUpdatePlanFields() + const updateAppliedStatus = useUpdateRecommendationApplied() const handleApply = (key: string, newValue: string) => { if (!currentDatos) return @@ -52,6 +56,14 @@ export const ImprovementCard = ({ setAppliedFields((prev) => [...prev, key]) if (onApply) onApply(key, newValue) console.log(`Campo ${key} guardado exitosamente`) + if (activeChatId) { + updateAppliedStatus.mutate({ + conversacionId: activeChatId, + campoAfectado: key, + }) + } + + if (onApply) onApply(key, newValue) }, }, ) @@ -60,7 +72,7 @@ export const ImprovementCard = ({ return (
{suggestions.map((sug) => { - const isApplied = appliedFields.includes(sug.key) + const isApplied = sug.applied === true || localApplied.includes(sug.key) const isUpdating = updatePlan.isPending && updatePlan.variables.patch.datos?.[sug.key] !== undefined diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index ca0c080..0977490 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -181,3 +181,65 @@ export async function getConversationByPlan(planId: string) { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition return data ?? [] } + +export async function update_conversation_title( + conversacionId: string, + nuevoTitulo: string, +) { + const supabase = supabaseBrowser() + + const { data, error } = await supabase + .from('conversaciones_plan') + .update({ nombre: nuevoTitulo }) // Asegúrate que la columna se llame 'title' o 'nombre' + .eq('id', conversacionId) + .select() + .single() + + if (error) throw error + return data +} + +export async function update_recommendation_applied_status( + conversacionId: string, + campoAfectado: string, +) { + const supabase = supabaseBrowser() + + // 1. Obtener el estado actual del JSON + const { data: conv, error: fetchError } = await supabase + .from('conversaciones_plan') + .select('conversacion_json') + .eq('id', conversacionId) + .single() + + if (fetchError) throw fetchError + if (!conv?.conversacion_json) + throw new Error('No se encontró la conversación') + + // 2. Transformar el JSON para marcar como aplicada la recomendación específica + // Usamos una transformación inmutable para evitar efectos secundarios + const nuevoJson = (conv.conversacion_json as Array).map((msg) => { + if (msg.user === 'assistant' && Array.isArray(msg.recommendations)) { + return { + ...msg, + recommendations: msg.recommendations.map((rec: any) => + rec.campo_afectado === campoAfectado + ? { ...rec, aplicada: true } + : rec, + ), + } + } + return msg + }) + + // 3. Actualizar la base de datos con el nuevo JSON + const { data, error: updateError } = await supabase + .from('conversaciones_plan') + .update({ conversacion_json: nuevoJson }) + .eq('id', conversacionId) + .select() + .single() + + if (updateError) throw updateError + return data +} diff --git a/src/data/hooks/useAI.ts b/src/data/hooks/useAI.ts index 5338a52..0aa655e 100644 --- a/src/data/hooks/useAI.ts +++ b/src/data/hooks/useAI.ts @@ -10,6 +10,7 @@ import { getConversationByPlan, library_search, update_conversation_status, + update_recommendation_applied_status, } from '../api/ai.api' // eslint-disable-next-line node/prefer-node-protocol @@ -91,6 +92,31 @@ export function useConversationByPlan(planId: string | null) { }) } +export function useUpdateRecommendationApplied() { + const qc = useQueryClient() + + return useMutation({ + mutationFn: ({ + conversacionId, + campoAfectado, + }: { + conversacionId: string + campoAfectado: string + }) => update_recommendation_applied_status(conversacionId, campoAfectado), + + onSuccess: (_, variables) => { + // Invalidamos la query para que useConversationByPlan refresque el JSON + qc.invalidateQueries({ queryKey: ['conversation-by-plan'] }) + console.log( + `Recomendación ${variables.campoAfectado} marcada como aplicada.`, + ) + }, + onError: (error) => { + console.error('Error al actualizar el estado de la recomendación:', error) + }, + }) +} + export function useAISubjectImprove() { return useMutation({ mutationFn: ai_subject_improve }) } diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index a8d6049..3e7c0f0 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -27,7 +27,6 @@ import { ScrollArea } from '@/components/ui/scroll-area' import { Textarea } from '@/components/ui/textarea' import { useAIPlanChat, - useChatHistory, useConversationByPlan, useUpdateConversationStatus, } from '@/data' @@ -84,8 +83,9 @@ function RouteComponent() { undefined, ) - const { data: historyMessages, isLoading: isLoadingHistory } = - useChatHistory(activeChatId) + /* const { data: historyMessages, isLoading: isLoadingHistory } = + useChatHistory(activeChatId) */ + const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) // archivos @@ -106,61 +106,48 @@ function RouteComponent() { const scrollRef = useRef(null) const [showArchived, setShowArchived] = useState(false) + const [editingChatId, setEditingChatId] = useState(null) + const editableRef = useRef(null) + const { mutate: updateTitleMutation } = useUpdateConversationTitle() - useEffect(() => { - // 1. Si no hay ID o está cargando el historial, no hacemos nada - if (!activeChatId || isLoadingHistory) return + const availableFields = useMemo(() => { + if (!data?.estructuras_plan?.definicion?.properties) return [] + return Object.entries(data.estructuras_plan.definicion.properties).map( + ([key, value]) => ({ + key, + label: value.title, + value: String(value.description || ''), + }), + ) + }, [data]) + const activeChatData = useMemo(() => { + return lastConversation?.find((chat: any) => chat.id === activeChatId) + }, [lastConversation, activeChatId]) - const messagesFromApi = historyMessages?.items || historyMessages + const conversacionJson = activeChatData?.conversacion_json || [] + const chatMessages = useMemo(() => { + const json = activeChatData?.conversacion_json + if (!Array.isArray(json)) return [] - if (Array.isArray(messagesFromApi)) { - const flattened = messagesFromApi.map((msg) => { - let content = msg.content - let suggestions: Array = [] + return json.map((msg: any, index: number) => { + const isAssistant = msg.user === 'assistant' - if (typeof content === 'object' && content !== null) { - suggestions = Object.entries(content) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - - content = content['ai-message'] || JSON.stringify(content) - } - // Si el content es un string que parece JSON (caso común en respuestas RAW) - else if (typeof content === 'string' && content.startsWith('{')) { - try { - const parsed = JSON.parse(content) - suggestions = Object.entries(parsed) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, + return { + id: `${activeChatId}-${index}-${msg.timestamp}`, // ID estable + role: isAssistant ? 'assistant' : 'user', + content: isAssistant ? msg.message : msg.prompt, + suggestions: + isAssistant && msg.recommendations + ? msg.recommendations.map((rec: any) => ({ + key: rec.campo_afectado, + label: rec.campo_afectado.replace(/_/g, ' '), + newValue: rec.texto_mejora, + applied: rec.aplicada, })) - content = parsed['ai-message'] || content - } catch (e) { - /* no es json */ - } - } - - return { - ...msg, - content, - suggestions, - type: suggestions.length > 0 ? 'improvement-card' : 'text', - } - }) - - // Solo actualizamos si no estamos esperando la respuesta de un POST - // para evitar saltos visuales - if (!isLoading) { - setMessages(flattened.reverse()) + : [], } - } - }, [historyMessages, activeChatId, isLoadingHistory, isLoading]) + }) + }, [activeChatData, activeChatId]) useEffect(() => { // Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes @@ -321,13 +308,6 @@ function RouteComponent() { const currentFields = [...selectedFields] const finalPrompt = buildPrompt(rawText, currentFields) - const userMsg = { - id: Date.now().toString(), - role: 'user', - content: rawText, - } - - setMessages((prev) => [...prev, userMsg]) setInput('') // setSelectedFields([]) @@ -346,58 +326,14 @@ function RouteComponent() { if (response.conversacionId && response.conversacionId !== activeChatId) { setActiveChatId(response.conversacionId) - - // Esto obliga a 'useConversationByPlan' a buscar en la DB el nuevo chat creado - queryClient.invalidateQueries({ - queryKey: ['conversation-by-plan', planId], - }) } - // --- NUEVA LÓGICA DE PARSEO --- - let aiText = 'Sin respuesta del asistente' - let suggestions: Array = [] - - if (response.raw) { - try { - const rawData = JSON.parse(response.raw) - - // Extraemos el mensaje conversacional - aiText = rawData['ai-message'] || 'Cambios aplicados con éxito.' - - // Filtramos todo lo que no sea el mensaje para crear las sugerencias - suggestions = Object.entries(rawData) - .filter(([key]) => key !== 'ai-message') - .map(([key, value]) => ({ - key, - label: key.replace(/_/g, ' '), - newValue: value as string, - })) - } catch (e) { - console.error('Error parseando el campo raw:', e) - aiText = response.raw // Fallback si no es JSON - } - } - - setMessages((prev) => [ - ...prev, - { - id: Date.now().toString(), - role: 'assistant', - content: aiText, - type: suggestions.length > 0 ? 'improvement-card' : 'text', - suggestions: suggestions, - }, - ]) + await queryClient.invalidateQueries({ + queryKey: ['conversation-by-plan', planId], + }) } catch (error) { console.error('Error en el chat:', error) - setMessages((prev) => [ - ...prev, - { - id: 'error', - role: 'assistant', - content: 'Lo siento, hubo un error al procesar tu solicitud.', - }, - ]) + // Aquí sí podrías usar un toast o un mensaje de error temporal } } @@ -543,7 +479,7 @@ function RouteComponent() {
- {messages.map((msg) => ( + {chatMessages.map((msg) => (
{ - // Esto es opcional, si quieres hacer algo más en la UI del chat - console.log( - 'Evento onApply disparado desde el chat', - ) - }} + planId={planId} + currentDatos={data?.datos} + activeChatId={activeChatId} + // Puedes pasar una prop nueva si tu ImprovementCard la soporta: + // isReadOnly={msg.suggestions.every(s => s.applied)} />
)} From b888bb22cde431cd528663390e53327444a2f3c2 Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Wed, 25 Feb 2026 16:04:30 -0600 Subject: [PATCH 4/7] Agregar estilo cuando la respuesta del asistente sea refusal fix #139 --- src/routes/planes/$planId/_detalle/iaplan.tsx | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index b745990..cc4b307 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -126,16 +126,16 @@ function RouteComponent() { const conversacionJson = activeChatData?.conversacion_json || [] const chatMessages = useMemo(() => { - const json = activeChatData?.conversacion_json - if (!Array.isArray(json)) return [] - + const json = activeChatData?.conversacion_json || [] return json.map((msg: any, index: number) => { const isAssistant = msg.user === 'assistant' return { - id: `${activeChatId}-${index}-${msg.timestamp}`, // ID estable + id: `${activeChatId}-${index}`, role: isAssistant ? 'assistant' : 'user', content: isAssistant ? msg.message : msg.prompt, + // EXTRAEMOS EL CAMPO REFUSAL + isRefusal: isAssistant && msg.refusal === true, suggestions: isAssistant && msg.recommendations ? msg.recommendations.map((rec: any) => ({ @@ -275,7 +275,7 @@ function RouteComponent() { // Si no hay campos, enviamos el texto tal cual if (fields.length === 0) return userInput - return `Instrucción del usuario: ${userInput}` + return ` ${userInput}` } const handleSend = async (promptOverride?: string) => { @@ -504,31 +504,44 @@ function RouteComponent() { {chatMessages.map((msg) => (
- {/* Contenido de texto normal */} - {msg.content} - - {/* Si el mensaje tiene sugerencias (ImprovementCard) */} - {msg.suggestions && msg.suggestions.length > 0 && ( -
- s.applied)} - /> + {/* Icono opcional de advertencia si es refusal */} + {msg.isRefusal && ( +
+ Aviso del Asistente
)} + + {msg.content} + + {/* Renderizado de sugerencias (ImprovementCard) */} + {!msg.isRefusal && + msg.suggestions && + msg.suggestions.length > 0 && ( +
+ +
+ )}
))} From 3c37b5f313b0a95c2c6bef36da74d8fd7a80269e Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Thu, 26 Feb 2026 10:51:23 -0600 Subject: [PATCH 5/7] Mejorar experiencia de usuario fix #141 --- .../planes/detalle/Ia/ImprovementCard.tsx | 19 +- src/data/api/ai.api.ts | 3 +- src/routes/planes/$planId/_detalle/iaplan.tsx | 171 +++++++++++++----- 3 files changed, 135 insertions(+), 58 deletions(-) diff --git a/src/components/planes/detalle/Ia/ImprovementCard.tsx b/src/components/planes/detalle/Ia/ImprovementCard.tsx index b27164c..64bda36 100644 --- a/src/components/planes/detalle/Ia/ImprovementCard.tsx +++ b/src/components/planes/detalle/Ia/ImprovementCard.tsx @@ -2,30 +2,29 @@ import { Check, Loader2 } from 'lucide-react' import { useState } from 'react' import { Button } from '@/components/ui/button' -import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data' // Tu hook existente +import { useUpdatePlanFields, useUpdateRecommendationApplied } from '@/data' export const ImprovementCard = ({ suggestions, onApply, - planId, // Necesitamos el ID - currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON + planId, + currentDatos, activeChatId, + onApplySuccess, }: { suggestions: Array onApply?: (key: string, value: string) => void planId: string currentDatos: any activeChatId: any + onApplySuccess?: (key: string) => void }) => { - const [appliedFields, setAppliedFields] = useState>([]) const [localApplied, setLocalApplied] = useState>([]) const updatePlan = useUpdatePlanFields() const updateAppliedStatus = useUpdateRecommendationApplied() const handleApply = (key: string, newValue: string) => { if (!currentDatos) return - - // 1. Lógica para preparar el valor (idéntica a tu handleSave original) const currentValue = currentDatos[key] let finalValue: any @@ -39,13 +38,11 @@ export const ImprovementCard = ({ finalValue = newValue } - // 2. Construir el nuevo objeto 'datos' manteniendo lo que ya existía const datosActualizados = { ...currentDatos, [key]: finalValue, } - // 3. Ejecutar la mutación directamente aquí updatePlan.mutate( { planId: planId as any, @@ -53,9 +50,9 @@ export const ImprovementCard = ({ }, { onSuccess: () => { - setAppliedFields((prev) => [...prev, key]) - if (onApply) onApply(key, newValue) - console.log(`Campo ${key} guardado exitosamente`) + setLocalApplied((prev) => [...prev, key]) + + if (onApplySuccess) onApplySuccess(key) if (activeChatId) { updateAppliedStatus.mutate({ conversacionId: activeChatId, diff --git a/src/data/api/ai.api.ts b/src/data/api/ai.api.ts index 8bbf16c..09602d6 100644 --- a/src/data/api/ai.api.ts +++ b/src/data/api/ai.api.ts @@ -207,8 +207,7 @@ export async function update_recommendation_applied_status( .single() if (fetchError) throw fetchError - if (!conv?.conversacion_json) - throw new Error('No se encontró la conversación') + if (!conv.conversacion_json) throw new Error('No se encontró la conversación') // 2. Transformar el JSON para marcar como aplicada la recomendación específica // Usamos una transformación inmutable para evitar efectos secundarios diff --git a/src/routes/planes/$planId/_detalle/iaplan.tsx b/src/routes/planes/$planId/_detalle/iaplan.tsx index cc4b307..07087ab 100644 --- a/src/routes/planes/$planId/_detalle/iaplan.tsx +++ b/src/routes/planes/$planId/_detalle/iaplan.tsx @@ -14,6 +14,7 @@ import { MessageSquarePlus, Archive, RotateCcw, + Loader2, } from 'lucide-react' import { useState, useEffect, useRef, useMemo } from 'react' @@ -66,7 +67,25 @@ interface SelectedField { label: string value: string } - +interface EstructuraDefinicion { + properties?: { + [key: string]: { + title: string + description?: string + } + } +} +interface ChatMessageJSON { + user: 'user' | 'assistant' + message?: string + prompt?: string + refusal?: boolean + recommendations?: Array<{ + campo_afectado: string + texto_mejora: string + aplicada: boolean + }> +} export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({ component: RouteComponent, }) @@ -76,20 +95,14 @@ function RouteComponent() { const { data } = usePlan(planId) const routerState = useRouterState() const [openIA, setOpenIA] = useState(false) - const [conversacionId, setConversacionId] = useState(null) - const { mutateAsync: sendChat, isLoading } = useAIPlanChat() + const { mutateAsync: sendChat, isPending: isLoading } = useAIPlanChat() const { mutate: updateStatusMutation } = useUpdateConversationStatus() const [activeChatId, setActiveChatId] = useState( undefined, ) - - /* const { data: historyMessages, isLoading: isLoadingHistory } = - useChatHistory(activeChatId) */ - const { data: lastConversation, isLoading: isLoadingConv } = useConversationByPlan(planId) - // archivos const [selectedArchivoIds, setSelectedArchivoIds] = useState>( [], ) @@ -109,45 +122,81 @@ function RouteComponent() { const [editingChatId, setEditingChatId] = useState(null) const editableRef = useRef(null) const { mutate: updateTitleMutation } = useUpdateConversationTitle() + const [isSending, setIsSending] = useState(false) + const [optimisticMessage, setOptimisticMessage] = useState( + null, + ) const availableFields = useMemo(() => { - if (!data?.estructuras_plan?.definicion?.properties) return [] - return Object.entries(data.estructuras_plan.definicion.properties).map( - ([key, value]) => ({ - key, - label: value.title, - value: String(value.description || ''), - }), - ) + // 1. Hacemos un cast de la definición a nuestra interfaz + const definicion = data?.estructuras_plan + ?.definicion as EstructuraDefinicion + + if (!definicion.properties) return [] + + return Object.entries(definicion.properties).map(([key, value]) => ({ + key, + label: value.title, + // 2. Aquí value ya no es unknown, es parte de nuestra interfaz + value: String(value.description || ''), + })) }, [data]) const activeChatData = useMemo(() => { return lastConversation?.find((chat: any) => chat.id === activeChatId) }, [lastConversation, activeChatId]) - const conversacionJson = activeChatData?.conversacion_json || [] const chatMessages = useMemo(() => { - const json = activeChatData?.conversacion_json || [] - return json.map((msg: any, index: number) => { + // Forzamos el cast a Array de nuestra interfaz + const json = (activeChatData?.conversacion_json || + []) as unknown as Array + + // Ahora .map() funcionará sin errores + return json.map((msg, index: number) => { const isAssistant = msg.user === 'assistant' return { id: `${activeChatId}-${index}`, role: isAssistant ? 'assistant' : 'user', content: isAssistant ? msg.message : msg.prompt, - // EXTRAEMOS EL CAMPO REFUSAL isRefusal: isAssistant && msg.refusal === true, suggestions: isAssistant && msg.recommendations - ? msg.recommendations.map((rec: any) => ({ - key: rec.campo_afectado, - label: rec.campo_afectado.replace(/_/g, ' '), - newValue: rec.texto_mejora, - applied: rec.aplicada, - })) + ? msg.recommendations.map((rec) => { + const fieldConfig = availableFields.find( + (f) => f.key === rec.campo_afectado, + ) + return { + key: rec.campo_afectado, + label: fieldConfig + ? fieldConfig.label + : rec.campo_afectado.replace(/_/g, ' '), + newValue: rec.texto_mejora, + applied: rec.aplicada, + } + }) : [], } }) - }, [activeChatData, activeChatId]) + }, [activeChatData, activeChatId, availableFields]) + const scrollToBottom = () => { + if (scrollRef.current) { + // Buscamos el viewport interno del ScrollArea de Radix + const scrollContainer = scrollRef.current.querySelector( + '[data-radix-scroll-area-viewport]', + ) + if (scrollContainer) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: 'smooth', + }) + } + } + } + + // Auto-scroll cuando cambian los mensajes o cuando la IA está cargando + useEffect(() => { + scrollToBottom() + }, [chatMessages, isLoading]) useEffect(() => { // Si no hay un chat seleccionado manualmente y la API nos devuelve chats existentes @@ -215,8 +264,6 @@ function RouteComponent() { { id, estado: 'ACTIVA' }, { onSuccess: () => { - // Al invalidar la query, React Query traerá la lista fresca - // y el chat se moverá solo de "archivados" a "activos" queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }) @@ -228,7 +275,6 @@ function RouteComponent() { const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value setInput(val) - // Solo abrir si termina en ":" setShowSuggestions(val.endsWith(':')) } @@ -236,7 +282,6 @@ function RouteComponent() { input: string, fields: Array, ) => { - // Quita cualquier bloque previo de campos const cleaned = input.replace(/\n?\[Campos:[^\]]*]/g, '').trim() if (fields.length === 0) return cleaned @@ -272,7 +317,6 @@ function RouteComponent() { } const buildPrompt = (userInput: string, fields: Array) => { - // Si no hay campos, enviamos el texto tal cual if (fields.length === 0) return userInput return ` ${userInput}` @@ -281,10 +325,11 @@ function RouteComponent() { const handleSend = async (promptOverride?: string) => { const rawText = promptOverride || input if (!rawText.trim() && selectedFields.length === 0) return - + if (isSending || (!rawText.trim() && selectedFields.length === 0)) return const currentFields = [...selectedFields] const finalPrompt = buildPrompt(rawText, currentFields) - + setIsSending(true) + setOptimisticMessage(rawText) setInput('') try { const payload: any = { @@ -306,9 +351,14 @@ function RouteComponent() { await queryClient.invalidateQueries({ queryKey: ['conversation-by-plan', planId], }) + setOptimisticMessage(null) } catch (error) { console.error('Error en el chat:', error) // Aquí sí podrías usar un toast o un mensaje de error temporal + } finally { + // 5. CRÍTICO: Detener el estado de carga SIEMPRE + setIsSending(false) + setOptimisticMessage(null) } } @@ -330,6 +380,10 @@ function RouteComponent() { } }, [lastConversation]) + const removeSelectedField = (fieldKey: string) => { + setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey)) + } + return (
{/* --- PANEL IZQUIERDO: HISTORIAL --- */} @@ -395,7 +449,7 @@ function RouteComponent() { e.preventDefault() const newTitle = e.currentTarget.textContent || '' updateTitleMutation( - { id: chat.id, titulo: newTitle }, + { id: chat.id, nombre: newTitle }, { onSuccess: () => setEditingChatId(null), }, @@ -501,7 +555,7 @@ function RouteComponent() {
- {chatMessages.map((msg) => ( + {chatMessages.map((msg: any) => (
0 && ( @@ -538,18 +591,35 @@ function RouteComponent() { suggestions={msg.suggestions} planId={planId} currentDatos={data?.datos} - conversacionId={activeChatId} + activeChatId={activeChatId} + onApplySuccess={(key) => removeSelectedField(key)} />
)}
))} - {isLoading && ( -
-
-
-
+ {optimisticMessage && ( +
+
+ {optimisticMessage} +
+
+ )} + {isSending && ( +
+
+
+
+ + + +
+ + Esperando respuesta... + +
+
)}
@@ -631,6 +701,13 @@ function RouteComponent() {