Persistencia en aplicar mejora #124

Merged
roberto.silva merged 2 commits from issue/113-persistencia-en-columnas-de-plan into main 2026-02-18 21:44:52 +00:00
5 changed files with 157 additions and 86 deletions

View File

@@ -1,46 +1,92 @@
import { Check } from 'lucide-react' import { Check, Loader2 } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useUpdatePlanFields } from '@/data' // Tu hook existente
export const ImprovementCard = ({ export const ImprovementCard = ({
suggestions, suggestions,
onApply, onApply,
planId, // Necesitamos el ID
currentDatos, // Necesitamos los datos actuales para no sobrescribir todo el JSON
}: { }: {
suggestions: Array<any> suggestions: Array<any>
onApply: (key: string, value: string) => void onApply?: (key: string, value: string) => void
planId: string
currentDatos: any
}) => { }) => {
// Estado para rastrear qué campos han sido aplicados
const [appliedFields, setAppliedFields] = useState<Array<string>>([]) const [appliedFields, setAppliedFields] = useState<Array<string>>([])
const updatePlan = useUpdatePlanFields()
const handleApply = (key: string, value: string) => { const handleApply = (key: string, newValue: string) => {
onApply(key, value) if (!currentDatos) return
setAppliedFields((prev) => [...prev, key])
// 1. Lógica para preparar el valor (idéntica a tu handleSave original)
const currentValue = currentDatos[key]
let finalValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
finalValue = { ...currentValue, description: newValue }
} else {
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,
patch: { datos: datosActualizados },
},
{
onSuccess: () => {
setAppliedFields((prev) => [...prev, key])
if (onApply) onApply(key, newValue)
console.log(`Campo ${key} guardado exitosamente`)
},
},
)
} }
return ( return (
<div className="mt-2 flex w-full flex-col gap-4"> <div className="mt-2 flex w-full flex-col gap-4">
{suggestions.map((sug) => { {suggestions.map((sug) => {
const isApplied = appliedFields.includes(sug.key) const isApplied = appliedFields.includes(sug.key)
const isUpdating =
updatePlan.isPending &&
updatePlan.variables.patch.datos?.[sug.key] !== undefined
return ( return (
<div <div
key={sug.key} key={sug.key}
className="rounded-2xl border border-slate-100 bg-white p-5 shadow-sm" className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
}`}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3> <h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<Button <Button
size="sm" size="sm"
onClick={() => handleApply(sug.key, sug.newValue)} onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied} disabled={isApplied || !!isUpdating}
className={`h-8 rounded-full px-4 text-xs transition-all ${ className={`h-8 rounded-full px-4 text-xs transition-all ${
isApplied isApplied
? 'cursor-not-allowed bg-slate-100 text-slate-400' ? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]' : 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`} }`}
> >
{isApplied ? ( {isUpdating ? (
<Loader2 size={12} className="animate-spin" />
) : isApplied ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Check size={12} /> Aplicado <Check size={12} /> Aplicado
</span> </span>
@@ -53,7 +99,7 @@ export const ImprovementCard = ({
<div <div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${ className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied isApplied
? 'border-[#ccfbf1] bg-[#f0fdfa] text-slate-700' ? 'border-teal-100 bg-teal-50/50 text-slate-700'
: 'border-slate-200 bg-slate-50 text-slate-500' : 'border-slate-200 bg-slate-50 text-slate-500'
}`} }`}
> >

View File

@@ -130,12 +130,19 @@ export async function get_chat_history(conversacionId: string) {
return data // Retorna Array de mensajes return data // Retorna Array de mensajes
} }
export async function archive_conversation(conversacionId: string) { export async function update_conversation_status(
conversacionId: string,
nuevoEstado: 'ARCHIVADA' | 'ACTIVA',
) {
const supabase = supabaseBrowser() const supabase = supabaseBrowser()
const { data, error } = await supabase.functions.invoke(
`create-chat-conversation/conversations/${conversacionId}/archive`, const { data, error } = await supabase
{ method: 'DELETE' }, .from('conversaciones_plan') // Asegúrate que el nombre de la tabla sea exacto
) .update({ estado: nuevoEstado })
.eq('id', conversacionId)
.select()
.single()
if (error) throw error if (error) throw error
return data return data
} }
@@ -168,8 +175,9 @@ export async function getConversationByPlan(planId: string) {
.from('conversaciones_plan') .from('conversaciones_plan')
.select('*') .select('*')
.eq('plan_estudio_id', planId) .eq('plan_estudio_id', planId)
.order('creado_en', { ascending: true }) .order('creado_en', { ascending: false })
if (error) throw error if (error) throw error
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return data ?? [] return data ?? []
} }

View File

@@ -5,13 +5,16 @@ import {
ai_plan_improve, ai_plan_improve,
ai_subject_chat, ai_subject_chat,
ai_subject_improve, ai_subject_improve,
archive_conversation,
create_conversation, create_conversation,
get_chat_history, get_chat_history,
getConversationByPlan, getConversationByPlan,
library_search, library_search,
update_conversation_status,
} from '../api/ai.api' } from '../api/ai.api'
// eslint-disable-next-line node/prefer-node-protocol
import type { UUID } from 'crypto'
export function useAIPlanImprove() { export function useAIPlanImprove() {
return useMutation({ mutationFn: ai_plan_improve }) return useMutation({ mutationFn: ai_plan_improve })
} }
@@ -62,13 +65,20 @@ export function useChatHistory(conversacionId?: string) {
}) })
} }
export function useArchiveConversation() { export function useUpdateConversationStatus() {
const queryClient = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => archive_conversation(id), mutationFn: ({
id,
estado,
}: {
id: string
estado: 'ARCHIVADA' | 'ACTIVA'
}) => update_conversation_status(id, estado),
onSuccess: () => { onSuccess: () => {
// Opcional: limpiar datos viejos de la caché // Esto refresca las listas automáticamente
queryClient.invalidateQueries({ queryKey: ['chat-history'] }) qc.invalidateQueries({ queryKey: ['conversation-by-plan'] })
}, },
}) })
} }

View File

@@ -27,9 +27,9 @@ import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { import {
useAIPlanChat, useAIPlanChat,
useArchiveConversation,
useChatHistory, useChatHistory,
useConversationByPlan, useConversationByPlan,
useUpdateConversationStatus,
} from '@/data' } from '@/data'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan } from '@/data/hooks/usePlans'
@@ -73,13 +73,12 @@ export const Route = createFileRoute('/planes/$planId/_detalle/iaplan')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data } = usePlan(planId) const { data } = usePlan(planId)
const routerState = useRouterState() const routerState = useRouterState()
const [openIA, setOpenIA] = useState(false) const [openIA, setOpenIA] = useState(false)
const [conversacionId, setConversacionId] = useState<string | null>(null) const [conversacionId, setConversacionId] = useState<string | null>(null)
const { mutateAsync: sendChat, isLoading } = useAIPlanChat() const { mutateAsync: sendChat, isLoading } = useAIPlanChat()
const { mutate: archiveChatMutation } = useArchiveConversation() const { mutate: updateStatusMutation } = useUpdateConversationStatus()
const [activeChatId, setActiveChatId] = useState<string | undefined>( const [activeChatId, setActiveChatId] = useState<string | undefined>(
undefined, undefined,
@@ -102,27 +101,12 @@ function RouteComponent() {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([]) const [selectedFields, setSelectedFields] = useState<Array<SelectedField>>([])
const [showSuggestions, setShowSuggestions] = useState(false) const [showSuggestions, setShowSuggestions] = useState(false)
// const [isLoading, setIsLoading] = useState(false)
const [pendingSuggestion, setPendingSuggestion] = useState<any>(null) const [pendingSuggestion, setPendingSuggestion] = useState<any>(null)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const scrollRef = useRef<HTMLDivElement>(null) const scrollRef = useRef<HTMLDivElement>(null)
const chatHistory = useMemo(() => {
return lastConversation || []
}, [lastConversation])
const [showArchived, setShowArchived] = useState(false) const [showArchived, setShowArchived] = useState(false)
const [archivedHistory, setArchivedHistory] = useState<Array<any>>([])
const [allMessages, setAllMessages] = useState<{ [key: string]: Array<any> }>(
{
'1': [
{
id: 'm1',
role: 'assistant',
content: '¡Hola! Soy tu asistente de IA en este chat inicial.',
},
],
},
)
useEffect(() => { useEffect(() => {
// 1. Si no hay ID o está cargando el historial, no hacemos nada // 1. Si no hay ID o está cargando el historial, no hacemos nada
if (!activeChatId || isLoadingHistory) return if (!activeChatId || isLoadingHistory) return
@@ -205,30 +189,39 @@ function RouteComponent() {
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation() // Evita que se seleccione el chat al intentar archivarlo e.stopPropagation()
archiveChatMutation(id, { updateStatusMutation(
onSuccess: () => { { id, estado: 'ARCHIVADA' },
// 1. Invalidamos las listas para que desaparezca de activos y aparezca en archivados {
queryClient.invalidateQueries({ onSuccess: () => {
queryKey: ['conversation-by-plan', planId], queryClient.invalidateQueries({
}) queryKey: ['conversation-by-plan', planId],
})
// 2. Si el chat archivado era el que tenías abierto, limpia la pantalla if (activeChatId === id) {
if (activeChatId === id) { setActiveChatId(undefined)
setActiveChatId(undefined) setMessages([])
setMessages([]) }
} },
}, },
}) )
} }
const unarchiveChat = (e: React.MouseEvent, id: string) => { const unarchiveChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation() e.stopPropagation()
const chatToRestore = archivedHistory.find((chat) => chat.id === id)
if (chatToRestore) { updateStatusMutation(
setChatHistory([chatToRestore, ...chatHistory]) { id, estado: 'ACTIVA' },
setArchivedHistory(archivedHistory.filter((chat) => chat.id !== id)) {
} 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],
})
},
},
)
} }
// 1. Transformar datos de la API para el menú de selección // 1. Transformar datos de la API para el menú de selección
@@ -296,22 +289,20 @@ function RouteComponent() {
} }
}) })
if (isAdding) { setInput((prev) => {
setInput((prev) => { // 1. Eliminamos TODOS los ":" que existan en el texto actual
// 1. Eliminamos TODOS los ":" que existan en el texto actual // 2. Quitamos espacios en blanco extra al final
// 2. Quitamos espacios en blanco extra al final const cleanPrev = prev.replace(/:/g, '').trim()
const cleanPrev = prev.replace(/:/g, '').trim()
// 3. Si el input resultante está vacío, solo ponemos la frase // 3. Si el input resultante está vacío, solo ponemos la frase
if (cleanPrev === '') { if (cleanPrev === '') {
return `${field.label} ` return `${field.label} `
} }
// 4. Si ya había algo, lo concatenamos con un espacio // 4. Si ya había algo, lo concatenamos con un espacio
// Usamos un espacio simple al final para que el usuario pueda seguir escribiendo // Usamos un espacio simple al final para que el usuario pueda seguir escribiendo
return `${cleanPrev} ${field.label} ` return `${cleanPrev} ${field.label} `
}) })
}
setShowSuggestions(false) setShowSuggestions(false)
} }
@@ -368,7 +359,6 @@ function RouteComponent() {
if (response.raw) { if (response.raw) {
try { try {
// Parseamos el string JSON que viene en 'raw'
const rawData = JSON.parse(response.raw) const rawData = JSON.parse(response.raw)
// Extraemos el mensaje conversacional // Extraemos el mensaje conversacional
@@ -419,6 +409,16 @@ function RouteComponent() {
) )
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles]) }, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const { activeChats, archivedChats } = useMemo(() => {
const allChats = lastConversation || []
return {
activeChats: allChats.filter((chat: any) => chat.estado === 'ACTIVA'),
archivedChats: allChats.filter(
(chat: any) => chat.estado === 'ARCHIVADA',
),
}
}, [lastConversation])
return ( return (
<div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4"> <div className="flex h-[calc(100vh-160px)] max-h-[calc(100vh-160px)] w-full gap-6 overflow-hidden p-4">
{/* --- PANEL IZQUIERDO: HISTORIAL --- */} {/* --- PANEL IZQUIERDO: HISTORIAL --- */}
@@ -453,10 +453,9 @@ function RouteComponent() {
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1"> <div className="space-y-1">
{/* Lógica de renderizado condicional */}
{!showArchived ? ( {!showArchived ? (
// LISTA DE CHATS ACTIVOS // --- LISTA DE CHATS ACTIVOS ---
chatHistory.map((chat) => ( activeChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
@@ -467,7 +466,10 @@ function RouteComponent() {
}`} }`}
> >
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<span className="truncate pr-8">{chat.title}</span> {/* Usamos el primer mensaje o un título por defecto */}
<span className="truncate pr-8">
{chat.title || `Chat ${chat.creado_en.split('T')[0]}`}
</span>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600" className="absolute right-2 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-amber-600"
@@ -478,18 +480,21 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
// LISTA DE CHATS ARCHIVADOS // --- LISTA DE CHATS ARCHIVADOS ---
<div className="animate-in fade-in slide-in-from-left-2"> <div className="animate-in fade-in slide-in-from-left-2">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedHistory.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">{chat.title}</span> <span className="truncate pr-8">
{chat.title ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600" className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600"
@@ -499,7 +504,7 @@ function RouteComponent() {
</button> </button>
</div> </div>
))} ))}
{archivedHistory.length === 0 && ( {archivedChats.length === 0 && (
<div className="px-2 py-4 text-center"> <div className="px-2 py-4 text-center">
<p className="text-xs text-slate-400 italic"> <p className="text-xs text-slate-400 italic">
No hay archivados No hay archivados
@@ -558,12 +563,13 @@ function RouteComponent() {
<div className="mt-4"> <div className="mt-4">
<ImprovementCard <ImprovementCard
suggestions={msg.suggestions} suggestions={msg.suggestions}
planId={planId} // Del useParams()
currentDatos={data?.datos} // De tu query usePlan(planId)
onApply={(key, val) => { onApply={(key, val) => {
console.log(`Aplicando ${val} al campo ${key}`) // Esto es opcional, si quieres hacer algo más en la UI del chat
setSelectedFields((prev) => console.log(
prev.filter((f) => f.key !== key), 'Evento onApply disparado desde el chat',
) )
// Aquí llamarías a tu mutación de actualizar el plan
}} }}
/> />
</div> </div>

View File

@@ -160,6 +160,7 @@ function DatosGeneralesPage() {
if (!data?.datos) return if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor) const datosActualizados = prepararDatosActualizados(data, campo, valor)
console.log(datosActualizados)
updatePlan.mutate({ updatePlan.mutate({
planId, planId,