From 3c37b5f313b0a95c2c6bef36da74d8fd7a80269e Mon Sep 17 00:00:00 2001 From: "Roberto.silva" Date: Thu, 26 Feb 2026 10:51:23 -0600 Subject: [PATCH] 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() {