Merge branch 'main' into issue/225-mejoras-en-el-diseo #232

Merged
Guillermo.Arrieta merged 7 commits from issue/225-mejoras-en-el-diseo into main 2026-04-14 19:04:16 +00:00
14 changed files with 378 additions and 538 deletions
Showing only changes of commit 434b50cfc3 - Show all commits
@@ -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"
>
<Plus className="mr-2 h-4 w-4" /> Agregar Bibliografía
</Button>
@@ -94,11 +94,7 @@ export function DocumentoSEPTab({
{pdfUrl && !isLoading && (
<>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={onDownloadWord}
>
<Button size="sm" className="gap-2" onClick={onDownloadWord}>
<Download className="h-4 w-4" /> Descargar Word
</Button>
<Button
@@ -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<string | undefined>(
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<any> = []
// 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<HTMLTextAreaElement>) => {
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 (
<div className="flex h-full w-full overflow-hidden bg-white">
<div className="fixed top-0 z-40 flex w-full items-center justify-between border-b bg-white/80 p-2 backdrop-blur-md">
<div className="bg-background flex h-full w-full overflow-hidden">
<div className="bg-background/80 fixed top-0 z-40 flex w-full items-center justify-between border-b p-2 backdrop-blur-md">
<Button
variant="ghost"
size="sm"
className="text-slate-600"
className="text-muted-foreground"
onClick={() => setIsSidebarOpen(true)}
>
<History size={18} className="mr-2" /> Historial
</Button>
<div className="flex flex-col items-center">
<span className="text-[9px] font-bold tracking-wider text-teal-600 uppercase">
<span className="text-primary text-[9px] font-bold tracking-wider uppercase">
Asistente
</span>
</div>
@@ -505,16 +453,17 @@ export function IAAsignaturaTab() {
<Button
variant="ghost"
size="sm"
className="text-slate-600"
onClick={() => setOpenIA(true)} // O el drawer de acciones/referencias
className="text-muted-foreground"
onClick={() => setOpenIA(true)}
>
<FileText size={18} className="mr-2 text-teal-600" /> Referencias
<FileText size={18} className="text-primary mr-2" /> Referencias
</Button>
</div>
{/* 1. PANEL IZQUIERDO (HISTORIAL) - Desktop */}
<aside className="hidden h-full w-64 shrink-0 flex-col border-r bg-white pr-4 md:flex">
<aside className="bg-background hidden h-full w-64 shrink-0 flex-col border-r pr-4 md:flex">
<div className="mb-4 flex items-center justify-between px-2 pt-4">
<h2 className="flex items-center gap-2 text-xs font-bold text-slate-500 uppercase">
<h2 className="text-muted-foreground flex items-center gap-2 text-xs font-bold uppercase">
<History size={14} /> Historial
</h2>
<Button
@@ -522,7 +471,7 @@ export function IAAsignaturaTab() {
size="icon"
className={cn(
'h-8 w-8',
showArchived && 'bg-teal-50 text-teal-600',
showArchived && 'bg-accent text-accent-foreground',
)}
onClick={() => setShowArchived(!showArchived)}
>
@@ -539,7 +488,7 @@ export function IAAsignaturaTab() {
queryClient.setQueryData(['subject-messages', undefined], [])
}}
variant="outline"
className="mb-4 w-full justify-start gap-2 border-dashed border-slate-300 text-slate-600 hover:border-teal-500 hover:bg-teal-50/50"
className="border-border text-muted-foreground hover:border-primary hover:bg-primary/10 mb-4 w-full justify-start gap-2 border-dashed"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
@@ -550,11 +499,10 @@ export function IAAsignaturaTab() {
<div
key={chat.id}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id
? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
? 'bg-accent text-foreground font-medium'
: 'text-muted-foreground hover:bg-muted',
)}
onDoubleClick={() => {
setEditingId(chat.id)
@@ -565,7 +513,7 @@ export function IAAsignaturaTab() {
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
className="bg-background ring-primary w-full rounded border-none px-1 text-xs ring-1 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
@@ -577,7 +525,6 @@ export function IAAsignaturaTab() {
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
@@ -586,13 +533,12 @@ export function IAAsignaturaTab() {
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
? 'bg-accent'
: 'bg-transparent',
)}
>
<TooltipProvider delayDuration={300}>
@@ -604,7 +550,7 @@ export function IAAsignaturaTab() {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
className="hover:bg-muted hover:text-primary rounded-md p-1 transition-colors"
>
<Edit2 size={14} />
</button>
@@ -629,10 +575,10 @@ export function IAAsignaturaTab() {
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
'hover:bg-muted rounded-md p-1 transition-colors',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
? 'hover:text-destructive'
: 'hover:text-primary',
)}
>
{chat.estado === 'ACTIVA' ? (
@@ -658,16 +604,16 @@ export function IAAsignaturaTab() {
</ScrollArea>
</aside>
{/* 2. PANEL CENTRAL (CHAT) - EL RECUADRO ESTILIZADO */}
{/* 2. PANEL CENTRAL (CHAT) */}
<main
className={cn(
'relative flex min-w-0 flex-1 flex-col overflow-hidden bg-slate-50/50 shadow-sm',
'mt-[50px] h-[calc(100svh-50px)]', // 50px es la altura aproximada de tu header fixed
'md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border md:border-slate-200',
'bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden shadow-sm',
'mt-[50px] h-[calc(100svh-50px)]',
'md:border-border md:m-2 md:mt-0 md:h-[calc(100vh-160px)] md:rounded-xl md:border',
)}
>
{/* Header Interno del Recuadro */}
<div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
{/* Header Interno */}
<div className="bg-background flex shrink-0 items-center justify-between border-b p-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
@@ -675,13 +621,13 @@ export function IAAsignaturaTab() {
className="md:hidden"
onClick={() => setIsSidebarOpen(true)}
>
<History size={20} className="text-slate-500" />
<History size={20} className="text-muted-foreground" />
</Button>
<div className="flex flex-col">
<span className="text-[10px] font-bold tracking-wider text-teal-600 uppercase">
<span className="text-primary text-[10px] font-bold tracking-wider uppercase">
Asistente Académico
</span>
<span className="text-[11px] text-slate-400">
<span className="text-muted-foreground text-[11px]">
Personalizado para tu asignatura
</span>
</div>
@@ -690,12 +636,12 @@ export function IAAsignaturaTab() {
<div className="flex gap-2">
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-200"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors"
>
<FileText size={14} />
<span className="xs:inline hidden">Referencias</span>
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]">
{totalReferencias}
</span>
)}
@@ -719,8 +665,8 @@ export function IAAsignaturaTab() {
className={cn(
'h-9 w-9 shrink-0 border shadow-sm',
msg.role === 'assistant'
? 'bg-teal-600 text-white'
: 'bg-slate-100',
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
>
<AvatarFallback>
@@ -742,11 +688,10 @@ export function IAAsignaturaTab() {
className={cn(
'relative overflow-hidden rounded-2xl border shadow-sm',
msg.role === 'user'
? 'rounded-tr-none border-teal-700 bg-teal-600 px-4 py-3 text-white'
: 'w-full rounded-tl-none border-slate-200 bg-white text-slate-800',
? 'border-primary bg-primary text-primary-foreground rounded-tr-none px-4 py-3'
: 'bg-card text-card-foreground border-border w-full rounded-tl-none',
)}
>
{/* Texto del mensaje principal */}
<div
style={{ whiteSpace: 'pre-line' }}
className={cn(
@@ -757,27 +702,27 @@ export function IAAsignaturaTab() {
{msg.content}
</div>
{/* CONTENEDOR DE SUGERENCIAS INTEGRADO */}
{msg.role === 'assistant' &&
msg.sugerencias?.length > 0 && (
<div className="space-y-3 border-t bg-slate-50/50 p-3">
<div className="border-border bg-muted/50 space-y-3 border-t p-3">
<div className="flex items-center justify-between">
<p className="text-[10px] font-bold text-slate-400 uppercase">
<p className="text-muted-foreground text-[10px] font-bold uppercase">
Mejoras disponibles
</p>
{/* Solo mostramos "Seleccionar todo" si hay sugerencias pendientes en ESTE mensaje */}
{msg.sugerencias.some((s) => !s.aceptada) && (
{msg.sugerencias.some(
(s: any) => !s.aceptada,
) && (
<Button
variant="ghost"
className="h-6 text-[10px] text-teal-600 hover:bg-teal-100"
className="text-primary hover:bg-primary/10 h-6 text-[10px]"
onClick={() =>
toggleAllFromMessage(msg.sugerencias)
}
>
{msg.sugerencias
.filter((s) => !s.aceptada)
.every((s) =>
.filter((s: any) => !s.aceptada)
.every((s: any) =>
selectedImprovements.includes(s.id),
)
? 'Desmarcar todas'
@@ -791,7 +736,7 @@ export function IAAsignaturaTab() {
{!sug.aceptada && (
<input
type="checkbox"
className="mt-4 h-4 w-4 rounded border-slate-300 accent-teal-600"
className="border-input accent-primary mt-4 h-4 w-4 rounded"
checked={selectedImprovements.includes(
sug.id,
)}
@@ -814,17 +759,16 @@ export function IAAsignaturaTab() {
</div>
))}
{/* EL BOTÓN DE APLICAR: Lo movemos para que solo aparezca si hay algo seleccionado de ESTE mensaje */}
{msg.sugerencias.some((s) =>
{msg.sugerencias.some((s: any) =>
selectedImprovements.includes(s.id),
) && (
<Button
size="sm"
disabled={isSending}
className="w-full bg-teal-600 text-xs font-bold shadow-md hover:bg-teal-700"
className="w-full text-xs font-bold shadow-md"
onClick={() => {
const seleccionadasDeEsteMensaje =
msg.sugerencias.filter((s) =>
msg.sugerencias.filter((s: any) =>
selectedImprovements.includes(s.id),
)
handleApplyMultiple(
@@ -851,139 +795,137 @@ export function IAAsignaturaTab() {
))}
{isAiThinking && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span>
</div>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
<span className="text-muted-foreground text-[10px] font-medium italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
{/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" />
</div>
</ScrollArea>
</div>
{/* Input de Chat */}
<footer className="shrink-0 border-t bg-white p-3 md:p-4">
<div className="shrink-0 border-t bg-white p-2 md:p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl">
<div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
>
<div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" />
)}
</button>
))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div>
<footer className="bg-background border-t p-3 md:p-4">
<div className="relative mx-auto max-w-4xl">
{showSuggestions && (
<div className="animate-in fade-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full left-0 z-50 mb-2 w-72 overflow-hidden rounded-xl border shadow-2xl">
<div className="bg-muted text-muted-foreground flex justify-between border-b px-3 py-2 text-[10px] font-bold uppercase">
<span>Filtrando campos...</span>
<span className="bg-muted-foreground/20 text-muted-foreground rounded px-1 text-[9px]">
ESC para cerrar
</span>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button
key={field.key}
onClick={() => handleSelectField(field)}
className="hover:bg-accent flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors"
>
<div className="flex flex-col">
<span className="text-foreground font-medium">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-primary" />
)}
</button>
))
) : (
<div className="text-muted-foreground p-4 text-center text-xs italic">
No se encontraron coincidencias
</div>
)}
</div>
</div>
)}
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-bold shadow-sm"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-50 px-2 py-0.5 text-[11px] font-bold text-teal-700 shadow-sm"
>
<Target size={10} />
{field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
>
<X size={10} />
</button>
</div>
))}
</div>
)}
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
const val = e.target.value
const cursor = e.target.selectionStart
setInput(val)
<div className="flex items-end gap-2">
<Textarea
value={input}
onChange={(e) => {
const val = e.target.value
const cursor = e.target.selectionStart
setInput(val)
const textBeforeCursor = val.slice(0, cursor)
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
const textBeforeCursor = val.slice(0, cursor)
const lastColonIndex = textBeforeCursor.lastIndexOf(':')
if (lastColonIndex !== -1) {
const textSinceColon = textBeforeCursor.slice(
lastColonIndex + 1,
)
if (lastColonIndex !== -1) {
const textSinceColon = textBeforeCursor.slice(
lastColonIndex + 1,
)
// Si hay un espacio después del ":", cerramos sugerencias (ya no es un comando)
// Si no hay espacio, activamos
if (!textSinceColon.includes(' ')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
if (!textSinceColon.includes(' ')) {
setShowSuggestions(true)
} else {
setShowSuggestions(false)
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder='Escribe ":" para referenciar un campo...'
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) ||
isSending
} else {
setShowSuggestions(false)
}
size="icon"
className="h-9 w-9 bg-teal-600 hover:bg-teal-700"
>
<Send size={16} className="text-white" />
</Button>
</div>
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}}
placeholder='Escribe ":" para referenciar un campo...'
className="max-h-[120px] min-h-[40px] flex-1 resize-none border-none bg-transparent text-sm shadow-none focus-visible:ring-0"
/>
<Button
onClick={() => handleSend()}
disabled={
(!input.trim() && selectedFields.length === 0) || isSending
}
size="icon"
className="mb-1 h-9 w-9 shrink-0"
>
{isSending ? (
<Loader2 className="animate-spin" size={16} />
) : (
<Send size={16} />
)}
</Button>
</div>
</div>
</div>
@@ -992,20 +934,20 @@ export function IAAsignaturaTab() {
{/* 3. PANEL DERECHO (ATAJOS) */}
<aside className="hidden w-64 shrink-0 flex-col gap-4 overflow-y-auto p-4 lg:flex">
<h4 className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Atajos Rápidos
<h4 className="text-foreground flex items-center gap-2 text-sm font-bold">
<Lightbulb size={18} className="text-primary" /> Atajos Rápidos
</h4>
<div className="space-y-2">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border border-slate-100 bg-white p-3 text-left text-sm transition-all hover:border-teal-500 hover:shadow-sm"
className="bg-card border-border hover:border-primary group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm transition-all hover:shadow-sm"
>
<div className="rounded-lg bg-slate-50 p-2 group-hover:bg-teal-50 group-hover:text-teal-600">
<div className="bg-muted group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors">
<preset.icon size={16} />
</div>
<span className="font-medium text-slate-600 group-hover:text-slate-900">
<span className="text-muted-foreground group-hover:text-foreground font-medium">
{preset.label}
</span>
</button>
@@ -1015,14 +957,14 @@ export function IAAsignaturaTab() {
{/* DRAWERS (Referencias e Historial Móvil) */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl">
<div className="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3">
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 hover:text-slate-600"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
@@ -1051,10 +993,9 @@ export function IAAsignaturaTab() {
<Drawer open={isSidebarOpen} onOpenChange={setIsSidebarOpen}>
<DrawerPortal>
<DrawerOverlay className="fixed inset-0 bg-black/40" />
<DrawerOverlay />
<DrawerContent className="fixed right-0 bottom-0 left-0 h-[70vh] p-4 outline-none">
<div className="flex h-full flex-col overflow-hidden pt-8">
{/* Reutiliza aquí el componente de la lista de chats */}
<Button
onClick={() => {
setActiveChatId(undefined)
@@ -1062,10 +1003,10 @@ export function IAAsignaturaTab() {
setInput('')
setSelectedFields([])
queryClient.setQueryData(['subject-messages', undefined], [])
setIsSidebarOpen(false) // Cierra el drawer al crear nuevo
setIsSidebarOpen(false)
}}
variant="outline"
className="mb-6 w-full justify-center gap-2 border-dashed border-teal-500 bg-teal-50/50 text-teal-700 hover:bg-teal-100"
className="border-primary/50 bg-primary/10 text-primary hover:bg-primary/20 mb-6 w-full justify-center gap-2 border-dashed"
>
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
@@ -1074,11 +1015,10 @@ export function IAAsignaturaTab() {
<div
key={chat.id}
className={cn(
// Agregamos 'overflow-hidden' para que nada salga de este cuadro
'group relative flex w-full min-w-0 items-center justify-between gap-2 overflow-hidden rounded-lg px-3 py-2 text-sm transition-all',
activeChatId === chat.id
? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100',
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-muted',
)}
onDoubleClick={() => {
setEditingId(chat.id)
@@ -1089,7 +1029,7 @@ export function IAAsignaturaTab() {
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
className="bg-background ring-primary w-full rounded border-none px-1 text-xs ring-1 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
@@ -1101,7 +1041,6 @@ export function IAAsignaturaTab() {
</div>
) : (
<>
{/* CLAVE 2: 'truncate' y 'min-w-0' en el span para que ceda ante los botones */}
<span
onClick={() => setActiveChatId(chat.id)}
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
@@ -1110,13 +1049,12 @@ export function IAAsignaturaTab() {
{chat.nombre || chat.titulo || 'Conversación'}
</span>
{/* CLAVE 3: 'shrink-0' asegura que los botones NUNCA desaparezcan */}
<div
className={cn(
'z-10 flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100',
activeChatId === chat.id
? 'bg-teal-50'
: 'bg-slate-100',
? 'bg-accent'
: 'bg-transparent',
)}
>
<TooltipProvider delayDuration={300}>
@@ -1128,7 +1066,7 @@ export function IAAsignaturaTab() {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
className="hover:bg-muted hover:text-primary rounded-md p-1 transition-colors"
>
<Edit2 size={14} />
</button>
@@ -1153,10 +1091,10 @@ export function IAAsignaturaTab() {
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
'hover:bg-muted rounded-md p-1 transition-colors',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
? 'hover:text-destructive'
: 'hover:text-primary',
)}
>
{chat.estado === 'ACTIVA' ? (
@@ -88,19 +88,19 @@ export function ImprovementCard({
{valor.map((u: any, idx: number) => (
<div
key={idx}
className="rounded-md border border-teal-100 bg-white p-2 shadow-sm"
className="bg-card border-primary/20 rounded-md border p-2 shadow-sm"
>
<div className="mb-1 flex items-center gap-2 border-b border-slate-50 pb-1 text-[11px] font-bold text-teal-800">
<div className="border-border/50 text-primary mb-1 flex items-center gap-2 border-b pb-1 text-[11px] font-bold">
<BookOpen size={12} /> Unidad {u.unidad}: {u.titulo}
</div>
<ul className="space-y-1">
{u.temas?.map((t: any, tidx: number) => (
<li
key={tidx}
className="flex items-start justify-between gap-2 text-[10px] text-slate-600"
className="text-muted-foreground flex items-start justify-between gap-2 text-[10px]"
>
<span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<span className="text-muted-foreground/70 flex shrink-0 items-center gap-0.5 font-mono">
<Clock size={10} /> {t.horasEstimadas}h
</span>
</li>
@@ -116,24 +116,24 @@ export function ImprovementCard({
if (valor[0]?.hasOwnProperty('criterio')) {
return (
<div className="space-y-2">
<div className="mb-1 flex items-center gap-2 text-[10px] font-bold text-slate-400 uppercase">
<div className="text-muted-foreground/70 mb-1 flex items-center gap-2 text-[10px] font-bold uppercase">
<ListChecks size={12} /> Desglose de evaluación
</div>
{valor.map((c: any, idx: number) => (
<div
key={idx}
className="flex items-center justify-between gap-3 rounded-md border border-slate-100 bg-white p-2 shadow-sm"
className="bg-card border-border flex items-center justify-between gap-3 rounded-md border p-2 shadow-sm"
>
<span className="text-[11px] leading-tight text-slate-700">
<span className="text-foreground text-[11px] leading-tight">
{c.criterio}
</span>
<div className="flex shrink-0 items-center gap-1 rounded-full border border-orange-100 bg-orange-50 px-2 py-0.5 text-[10px] font-bold text-orange-600">
<div className="border-accent/30 bg-accent/10 text-accent flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-bold">
{c.porcentaje}%
</div>
</div>
))}
{/* Opcional: Suma total para verificar que de 100% */}
<div className="pt-1 text-right text-[9px] font-medium text-slate-400">
<div className="text-muted-foreground/70 pt-1 text-right text-[9px] font-medium">
Total:{' '}
{valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
@@ -156,17 +156,17 @@ export function ImprovementCard({
// --- ESTADO APLICADO ---
if (sug.aceptada) {
return (
<div className="flex flex-col rounded-xl border border-slate-100 bg-white p-3 opacity-80 shadow-sm">
<div className="bg-card border-border flex flex-col rounded-xl border p-3 opacity-80 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800">
<span className="text-foreground text-sm font-bold">
{sug.campoNombre}
</span>
<div className="flex items-center gap-1.5 rounded-full border border-slate-100 bg-slate-50 px-3 py-1 text-xs font-medium text-slate-400">
<div className="border-border bg-muted/50 text-muted-foreground flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-medium">
<Check size={14} />
Aplicado
</div>
</div>
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500">
<div className="border-primary/20 bg-primary/5 text-muted-foreground rounded-lg border p-3 text-xs leading-relaxed">
{renderContenido(sug.valorSugerido)}
</div>
</div>
@@ -175,16 +175,16 @@ export function ImprovementCard({
// --- ESTADO PENDIENTE ---
return (
<div className="group flex flex-col rounded-xl border border-teal-100 bg-white p-3 shadow-sm transition-all hover:border-teal-200">
<div className="bg-card border-primary/20 hover:border-primary/40 group flex flex-col rounded-xl border p-3 shadow-sm transition-all">
<div className="mb-3 flex items-center justify-between gap-4">
<span className="max-w-[150px] truncate rounded-lg border border-teal-100 bg-teal-50/50 px-2.5 py-1 text-[10px] font-bold tracking-wider text-teal-700 uppercase">
<span className="border-primary/20 bg-primary/10 text-primary max-w-[150px] truncate rounded-lg border px-2.5 py-1 text-[10px] font-bold tracking-wider uppercase">
{sug.campoNombre}
</span>
<Button
size="sm"
disabled={isApplying || !asignatura}
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm hover:bg-teal-700"
className="h-8 w-auto px-4 text-xs font-semibold shadow-sm"
onClick={handleApply}
>
{isApplying ? (
@@ -198,7 +198,7 @@ export function ImprovementCard({
<div
className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
'border-border/60 bg-muted/30 text-muted-foreground rounded-lg border border-dashed p-3 text-xs leading-relaxed',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)}
>
@@ -56,10 +56,9 @@ export const ImprovementCard = ({
if (onApplySuccess) onApplySuccess(key)
// --- CAMBIO AQUÍ: Ahora enviamos el ID del mensaje ---
if (dbMessageId) {
updateAppliedStatus.mutate({
conversacionId: dbMessageId, // Cambiamos el nombre de la propiedad si es necesario
conversacionId: dbMessageId,
campoAfectado: key,
})
}
@@ -81,21 +80,18 @@ export const ImprovementCard = ({
return (
<div
key={sug.key}
className={`rounded-2xl border bg-white p-5 shadow-sm transition-all ${
isApplied ? 'border-teal-200 bg-teal-50/20' : 'border-slate-100'
className={`bg-card rounded-2xl border p-5 shadow-sm transition-all ${
isApplied ? 'border-primary/30 bg-primary/5' : 'border-border'
}`}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-900">{sug.label}</h3>
<h3 className="text-foreground text-sm font-bold">{sug.label}</h3>
<Button
size="sm"
onClick={() => handleApply(sug.key, sug.newValue)}
disabled={isApplied || !!isUpdating}
className={`h-8 rounded-full px-4 text-xs transition-all ${
isApplied
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-[#00a189] text-white hover:bg-[#008f7a]'
}`}
variant={isApplied ? 'secondary' : 'default'}
className="h-8 rounded-full px-4 text-xs transition-all"
>
{isUpdating ? (
<Loader2 size={12} className="animate-spin" />
@@ -112,8 +108,8 @@ export const ImprovementCard = ({
<div
className={`rounded-xl border p-3 text-sm transition-colors duration-300 ${
isApplied
? 'border-teal-100 bg-teal-50/50 text-slate-700'
: 'border-slate-200 bg-slate-50 text-slate-500'
? 'border-primary/20 bg-primary/10 text-foreground'
: 'border-border bg-muted/50 text-muted-foreground'
}`}
>
{sug.newValue}
+20 -21
View File
@@ -114,13 +114,13 @@ function RouteComponent() {
}
}
return (
<div className="min-h-screen bg-white">
<div className="bg-background min-h-screen">
{/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="bg-background/80 sticky top-0 z-20 border-b shadow-sm backdrop-blur-sm">
<div className="px-6 py-2">
<Link
to="/planes"
className="flex w-fit items-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-800"
className="text-muted-foreground hover:text-foreground flex w-fit items-center gap-1 text-xs transition-colors"
>
<ChevronLeft size={14} /> Volver a planes
</Link>
@@ -139,7 +139,7 @@ function RouteComponent() {
) : (
<div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div>
<h1 className="flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight text-slate-900">
<h1 className="text-foreground flex flex-wrap items-baseline gap-2 text-3xl leading-tight font-bold tracking-tight">
{/* El prefijo "Nivel en" lo mantenemos simple */}
<span className="shrink-0">{nivelPlan} en</span>
<span
@@ -149,7 +149,7 @@ function RouteComponent() {
suppressContentEditableWarning
spellCheck={false}
onKeyDown={handleKeyDown}
onPaste={handlePaste} // Añadido para controlar lo que pegan
onPaste={handlePaste}
onBlur={(e) => {
const nuevoNombre =
e.currentTarget.textContent?.trim() || ''
@@ -158,21 +158,20 @@ function RouteComponent() {
mutate({ planId, patch: { nombre: nuevoNombre } })
}
}}
// Clases añadidas: break-words y whitespace-pre-wrap para el wrap
className="block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500 sm:inline-block sm:w-auto"
className="hover:border-input focus:border-primary block w-full cursor-text border-b border-transparent break-words whitespace-pre-wrap transition-colors outline-none select-text sm:inline-block sm:w-auto"
style={{ textDecoration: 'none' }}
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
<p className="text-muted-foreground mt-1 text-lg font-medium">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
<div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<Badge className="border-primary/20 bg-primary/10 text-primary hover:bg-primary/20 gap-1 px-3">
{data?.estados_plan?.etiqueta}
</Badge>
</div>
@@ -184,7 +183,7 @@ function RouteComponent() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<InfoCard
icon={<GraduationCap className="text-slate-400" />}
icon={<GraduationCap className="text-muted-foreground" />}
label="Nivel"
value={nivelPlan}
isEditable
@@ -209,17 +208,17 @@ function RouteComponent() {
</DropdownMenu>
<InfoCard
icon={<Clock className="text-slate-400" />}
icon={<Clock className="text-muted-foreground" />}
label="Duración"
value={`${data?.numero_ciclos || 0} Ciclos`}
/>
<InfoCard
icon={<Hash className="text-slate-400" />}
icon={<Hash className="text-muted-foreground" />}
label="Créditos"
value="320"
/>
<InfoCard
icon={<CalendarDays className="text-slate-400" />}
icon={<CalendarDays className="text-muted-foreground" />}
label="Creación"
value={data?.creado_en?.split('T')[0]}
/>
@@ -276,20 +275,20 @@ const InfoCard = forwardRef<
<div
ref={ref}
{...props}
className={`flex h-18 w-full items-center gap-4 rounded-xl border border-slate-200/60 bg-slate-50/50 p-4 shadow-sm transition-all ${
className={`border-border/60 bg-muted/30 flex h-18 w-full items-center gap-4 rounded-xl border p-4 shadow-sm transition-all ${
isEditable
? 'cursor-pointer hover:border-teal-200 hover:bg-white focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-500/40'
? 'hover:border-primary/50 hover:bg-accent focus-visible:ring-primary/40 cursor-pointer focus:outline-none focus-visible:ring-2'
: ''
} ${className ?? ''}`}
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border bg-white shadow-sm">
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
{icon}
</div>
<div className="min-w-0 flex-1">
<p className="mb-0.5 truncate text-[10px] font-bold tracking-wider text-slate-400 uppercase">
<p className="text-muted-foreground mb-0.5 truncate text-[10px] font-bold tracking-wider uppercase">
{label}
</p>
<p className="truncate text-sm font-semibold text-slate-700">
<p className="text-foreground truncate text-sm font-semibold">
{value || '---'}
</p>
</div>
@@ -311,8 +310,8 @@ function Tab({
<Link
to={to}
params={params}
className="border-b-2 border-transparent pb-3 text-sm font-medium text-slate-500 transition-all hover:text-slate-800"
activeProps={{ className: 'border-teal-600 text-teal-700 font-bold' }}
className="text-muted-foreground hover:text-foreground border-b-2 border-transparent pb-3 text-sm font-medium transition-all"
activeProps={{ className: 'border-primary text-primary font-bold' }}
activeOptions={{
exact: true,
}}
@@ -324,7 +323,7 @@ function Tab({
function DatosGeneralesSkeleton() {
return (
<div className="rounded-xl border bg-white">
<div className="bg-card rounded-xl border">
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-3">
<Skeleton className="h-4 w-40" />
@@ -152,7 +152,7 @@ function AsignaturasPage() {
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"
>
<Plus className="mr-2 h-4 w-4" /> Nueva Asignatura
</Button>
@@ -122,11 +122,7 @@ function RouteComponent() {
>
<RefreshCcw size={16} /> Regenerar
</Button>
<Button
size="sm"
className="gap-2 bg-teal-700 hover:bg-teal-800"
onClick={handleDownloadWord}
>
<Button size="sm" className="gap-2" onClick={handleDownloadWord}>
<Download size={16} /> Descargar Word
</Button>
<Button
+1 -1
View File
@@ -146,7 +146,7 @@ function RouteComponent() {
/>
</div>
<Button className="w-full bg-teal-600 hover:bg-teal-700" disabled>
<Button className="w-full" disabled>
Avanzar a Revisión Expertos
</Button>
</CardContent>
@@ -37,24 +37,24 @@ const getEventConfig = (tipo: string, campo: string) => {
return {
label: 'Creación',
icon: <PlusCircle className="h-4 w-4" />,
color: 'teal',
color: 'primary',
}
if (campo === 'estado')
return {
label: 'Cambio de estado',
icon: <GitBranch className="h-4 w-4" />,
color: 'blue',
color: 'secondary',
}
if (campo === 'datos')
return {
label: 'Edición de Datos',
icon: <Edit3 className="h-4 w-4" />,
color: 'amber',
color: 'accent',
}
return {
label: 'Actualización',
icon: <RefreshCw className="h-4 w-4" />,
color: 'slate',
color: 'muted',
}
}
@@ -104,7 +104,7 @@ function RouteComponent() {
},
}
})
}, [rawData])
}, [rawData, structure, data])
const openCompareModal = (event: any) => {
setSelectedEvent(event)
@@ -120,7 +120,7 @@ function RouteComponent() {
if (isLoading)
return (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-teal-600" />
<Loader2 className="text-primary h-8 w-8 animate-spin" />
</div>
)
@@ -128,8 +128,8 @@ function RouteComponent() {
<div className="mx-auto">
<div className="mb-8 flex items-end justify-between">
<div>
<h1 className="flex items-center gap-2 text-xl font-bold text-slate-800">
<Clock className="h-5 w-5 text-teal-600" /> Historial de Cambios del
<h1 className="text-foreground flex items-center gap-2 text-xl font-bold">
<Clock className="text-primary h-5 w-5" /> Historial de Cambios del
Plan
</h1>
<p className="text-muted-foreground text-sm">
@@ -139,9 +139,11 @@ function RouteComponent() {
</div>
<div className="relative space-y-0">
<div className="absolute top-0 bottom-0 left-6 w-px bg-slate-200 md:left-9" />
<div className="bg-border absolute top-0 bottom-0 left-6 w-px md:left-9" />
{historyEvents.length === 0 ? (
<div className="ml-20 py-10 text-slate-500">No hay registros.</div>
<div className="text-muted-foreground ml-20 py-10">
No hay registros.
</div>
) : (
historyEvents.map((event) => (
<div
@@ -149,18 +151,18 @@ function RouteComponent() {
className="group relative flex gap-3 pb-8 md:gap-6"
>
<div className="relative z-10 flex flex-col items-center">
<div className="flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 border-white bg-slate-100 text-slate-600 shadow-sm transition-colors group-hover:bg-teal-50 group-hover:text-teal-600">
<div className="border-background bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary flex h-[42px] w-[42px] items-center justify-center rounded-full border-4 shadow-sm transition-colors">
{event.icon}
</div>
</div>
<Card className="flex-1 border-slate-200 shadow-none transition-colors hover:border-teal-200">
<Card className="border-border hover:border-primary/50 flex-1 shadow-none transition-colors">
<CardContent className="p-4">
<div className="flex flex-col gap-2">
{/* LÍNEA SUPERIOR: Título a la izquierda --- Usuario, Botón y Fecha a la derecha */}
<div className="flex flex-col justify-between gap-2 md:flex-row md:items-center">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-bold text-slate-800">
<span className="text-foreground text-sm font-bold">
{event.type}
</span>
<Badge
@@ -175,7 +177,7 @@ function RouteComponent() {
</div>
{/* Grupo de elementos alineados a la derecha */}
<div className="flex flex-wrap items-center gap-3 text-slate-500 md:gap-4">
<div className="text-muted-foreground flex flex-wrap items-center gap-3 md:gap-4">
{/* Usuario e Icono */}
<div className="flex items-center gap-1.5 text-xs">
<User className="h-3.5 w-3.5" />
@@ -187,14 +189,14 @@ function RouteComponent() {
{/* Botón Ver Cambios */}
<button
onClick={() => openCompareModal(event)}
className="group/btn flex items-center gap-1.5 text-xs font-medium text-teal-600 md:text-slate-500 md:hover:text-teal-600"
className="text-primary md:text-muted-foreground md:hover:text-primary group/btn flex items-center gap-1.5 text-xs font-medium"
>
<Eye className="h-4 w-4 text-slate-400 group-hover/btn:text-teal-600" />
<Eye className="text-muted-foreground/70 group-hover/btn:text-primary h-4 w-4" />
<span>Ver cambios</span>
</button>
{/* Fecha exacta (Solo visible en desktop para no amontonar) */}
<span className="hidden text-[11px] text-slate-400 lg:block">
<span className="text-muted-foreground/70 hidden text-[11px] lg:block">
{format(event.date, 'yyyy-MM-dd HH:mm')}
</span>
</div>
@@ -202,7 +204,7 @@ function RouteComponent() {
{/* LÍNEA INFERIOR: Descripción */}
<div className="mt-1">
<p className="text-sm text-slate-600">
<p className="text-muted-foreground text-sm">
{event.description}
</p>
@@ -213,16 +215,16 @@ function RouteComponent() {
<div className="mt-2 flex items-center gap-1.5">
<Badge
variant="secondary"
className="bg-red-50 px-1.5 text-[9px] text-red-700"
className="bg-destructive/10 text-destructive px-1.5 text-[9px]"
>
{event.details.from}
</Badge>
<span className="text-[10px] text-slate-400">
<span className="text-muted-foreground/70 text-[10px]">
</span>
<Badge
variant="secondary"
className="bg-emerald-50 px-1.5 text-[9px] text-emerald-700"
className="bg-primary/10 text-primary px-1.5 text-[9px]"
>
{event.details.to}
</Badge>
@@ -237,7 +239,7 @@ function RouteComponent() {
)}
{historyEvents.length > 0 && (
<div className="mt-10 ml-12 flex flex-col gap-3 border-t pt-4 md:ml-20 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-slate-500">
<p className="text-muted-foreground text-xs">
Mostrando {rawData.length} de {totalRecords} cambios
</p>
@@ -255,7 +257,7 @@ function RouteComponent() {
Anterior
</Button>
<span className="text-sm font-medium text-slate-700">
<span className="text-foreground text-sm font-medium">
Página {page + 1} de {totalPages || 1}
</span>
@@ -266,7 +268,6 @@ function RouteComponent() {
setPage((p) => p + 1)
window.scrollTo(0, 0)
}}
// Ahora se deshabilita si llegamos a la última página real
disabled={page + 1 >= totalPages || isLoading}
>
Siguiente
@@ -280,9 +281,9 @@ function RouteComponent() {
{/* MODAL DE COMPARACIÓN CON SCROLL INTERNO */}
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
<DialogContent className="flex max-h-[90vh] max-w-4xl flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="border-b bg-slate-50/50 p-6">
<DialogHeader className="bg-muted/50 border-b p-6">
<DialogTitle className="flex items-center gap-2">
<History className="h-5 w-5 text-teal-600" /> Comparación de
<History className="text-primary h-5 w-5" /> Comparación de
Versiones
</DialogTitle>
<div className="text-muted-foreground flex items-center gap-4 pt-2 text-xs">
@@ -301,17 +302,16 @@ function RouteComponent() {
<div className="flex-1 overflow-y-auto p-6">
<div className="grid h-full grid-cols-2 gap-6">
{/* Lado Antes */}
{/* Lado Antes: Solo se renderiza si existe valor_anterior */}
{selectedEvent?.details.from && (
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-red-400" />
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
<div className="bg-destructive h-2 w-2 rounded-full" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Versión Anterior
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-red-100 bg-red-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
<div className="border-destructive/20 bg-destructive/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{renderValue(selectedEvent.details.from)}
</div>
</div>
@@ -319,27 +319,22 @@ function RouteComponent() {
{/* Lado Después */}
<div className="flex flex-col space-y-2">
<div className="sticky top-0 z-10 flex items-center gap-2 bg-white py-1">
<div className="h-2 w-2 rounded-full bg-emerald-400" />
<div className="bg-background sticky top-0 z-10 flex items-center gap-2 py-1">
<div className="bg-primary h-2 w-2 rounded-full" />
<span className="text-muted-foreground text-[10px] font-bold tracking-widest uppercase">
Nueva Versión
</span>
</div>
<div className="max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border border-emerald-100 bg-emerald-50/30 p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap text-slate-700">
<div className="border-primary/20 bg-primary/5 text-foreground max-h-[500px] min-h-[250px] flex-1 overflow-y-auto rounded-lg border p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
{renderValue(selectedEvent?.details.to)}
</div>
</div>
</div>
</div>
<div className="flex justify-center border-t bg-slate-50 p-4">
<div className="bg-muted/30 flex justify-center border-t p-4">
<Badge variant="outline" className="font-mono text-[10px]">
Campo: {selectedEvent?.campo}
{console.log(
data?.estructuras_plan?.definicion?.properties?.[
selectedEvent?.campo
]?.title,
)}
</Badge>
</div>
</DialogContent>
+81 -144
View File
@@ -115,7 +115,7 @@ function RouteComponent() {
const { data: lastConversation, isLoading: isLoadingConv } =
useConversationByPlan(planId)
const { data: mensajesDelChat, isLoading: isLoadingMessages } =
useMessagesByChat(activeChatId ?? null) // Si es undefined, pasa null
useMessagesByChat(activeChatId ?? null)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
@@ -155,8 +155,7 @@ function RouteComponent() {
const definicion = data?.estructuras_plan
?.definicion as EstructuraDefinicion
// Encadenamiento opcional para evitar errores si data es null
if (!definicion.properties) return []
if (!definicion?.properties) return []
return Object.entries(definicion.properties).map(([key, value]) => ({
key,
@@ -169,28 +168,24 @@ function RouteComponent() {
return availableFields.filter(
(field) =>
field.label.toLowerCase().includes(filterQuery.toLowerCase()) &&
!selectedFields.some((s) => s.key === field.key), // No mostrar ya seleccionados
!selectedFields.some((s) => s.key === field.key),
)
}, [availableFields, filterQuery, selectedFields])
const chatMessages = useMemo(() => {
if (!activeChatId || !mensajesDelChat) return []
// flatMap nos permite devolver 2 elementos (pregunta y respuesta) por cada registro de la BD
return mensajesDelChat.flatMap((msg: any) => {
const messages = []
// 1. Mensaje del Usuario
messages.push({
id: `${msg.id}-user`,
role: 'user',
content: msg.mensaje,
selectedFields: msg.campos || [], // Aquí están tus campos
selectedFields: msg.campos || [],
})
// 2. Mensaje del Asistente (si hay respuesta)
if (msg.respuesta) {
// Extraemos las recomendaciones de la nueva estructura: msg.propuesta.recommendations
const rawRecommendations = msg.propuesta?.recommendations || []
messages.push({
@@ -219,8 +214,6 @@ function RouteComponent() {
})
}, [mensajesDelChat, activeChatId, availableFields])
// En tu componente principal (el padre)
const handleApplyMultiple = async (
sugerencias: Array<any>,
dbMessageId: string,
@@ -258,15 +251,12 @@ function RouteComponent() {
conversacionId: dbMessageId,
campoAfectado: sug.key,
})
console.log(sug.key)
removeSelectedField(sug.key)
} catch (err) {
console.error(
`Error al marcar aplicada la sugerencia: ${sug.key}`,
err,
)
// Si una falla, el bucle sigue con las demás
}
}
@@ -335,17 +325,14 @@ function RouteComponent() {
useEffect(() => {
if (chatMessages.length > 0) {
if (isInitialLoad.current) {
// Si es el primer render con mensajes, vamos al final al instante
scrollToBottom('instant')
isInitialLoad.current = false
} else {
// Si ya estaba cargado y llegan nuevos, hacemos el smooth
scrollToBottom('smooth')
}
}
}, [chatMessages])
// 2. Resetear el flag cuando cambies de chat activo
useEffect(() => {
isInitialLoad.current = true
}, [activeChatId])
@@ -358,14 +345,12 @@ function RouteComponent() {
)
const isCreationMode = messages.length === 1 && messages[0].id === 'welcome'
// 1. Si el chat que teníamos seleccionado ya no existe (ej. se archivó)
if (activeChatId && !currentChatExists && !isCreationMode) {
setActiveChatId(undefined)
setMessages([])
return
}
// 2. Auto-selección inicial: Solo si no hay ID, no estamos creando y hay chats
if (
!activeChatId &&
activeChats.length > 0 &&
@@ -399,7 +384,7 @@ function RouteComponent() {
}, [availableFields, routerState.location.state])
const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo handleSend creará uno nuevo
setActiveChatId(undefined)
setMessages([
{
id: 'welcome',
@@ -408,7 +393,6 @@ function RouteComponent() {
},
])
setInput('')
// setSelectedFields([])
}
const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -450,16 +434,15 @@ function RouteComponent() {
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value
const cursorPosition = e.target.selectionStart // Dónde está escribiendo el usuario
const cursorPosition = e.target.selectionStart
setInput(val)
// Busca un ":" seguido de letras justo antes del cursor
const textBeforeCursor = val.slice(0, cursorPosition)
const match = textBeforeCursor.match(/:(\w*)$/)
if (match) {
setShowSuggestions(true)
setFilterQuery(match[1]) // Esto es lo que se usa para el filtrado
setFilterQuery(match[1])
} else {
setShowSuggestions(false)
setFilterQuery('')
@@ -470,40 +453,31 @@ function RouteComponent() {
input: string,
fields: Array<SelectedField>,
) => {
// 1. Limpiamos cualquier rastro anterior de la etiqueta (por si acaso)
// Esta regex ahora también limpia si el texto termina de forma natural
const cleaned = input.replace(/[:\s]+[^:]*$/, '').trim()
if (fields.length === 0) return cleaned
const fieldLabels = fields.map((f) => f.label).join(', ')
// 2. Devolvemos un formato natural: "Mejora este campo: Nombre del Campo"
return `${cleaned}: ${fieldLabels}`
}
const toggleField = (field: SelectedField) => {
// 1. Lo agregamos a la lista de "SelectedFields" (para que la IA sepa qué procesar)
setSelectedFields((prev) => {
const isSelected = prev.find((f) => f.key === field.key)
return isSelected ? prev : [...prev, field]
})
// 2. Insertamos el nombre del campo en el texto exactamente donde estaba el ":"
setInput((prev) => {
// Reemplaza el último ":" y cualquier texto de filtro por el label del campo
const nuevoTexto = prev.replace(/:(\w*)$/, field.label)
return nuevoTexto + ' ' // Añadimos un espacio para que el usuario siga escribiendo
return nuevoTexto + ' '
})
// 3. Limpiamos estados de búsqueda
setShowSuggestions(false)
setFilterQuery('')
}
const buildPrompt = (userInput: string, fields: Array<SelectedField>) => {
if (fields.length === 0) return userInput
return ` ${userInput}`
}
@@ -516,7 +490,6 @@ function RouteComponent() {
setIsSending(true)
setOptimisticMessage(finalContent)
setInput('')
// setSelectedFields([])
try {
const payload = {
@@ -535,7 +508,6 @@ function RouteComponent() {
setActiveChatId(response.conversacionId)
}
// ESPERAMOS a que la caché se actualice antes de quitar el "isSending"
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['conversation-by-plan', planId],
@@ -548,19 +520,15 @@ function RouteComponent() {
console.error('Error:', error)
setOptimisticMessage(null)
} finally {
// Solo ahora quitamos los indicadores de carga
setIsSending(false)
// setOptimisticMessage(null)
}
}
useEffect(() => {
if (!isSyncing || !mensajesDelChat || mensajesDelChat.length === 0) return
// Forzamos el tipo a 'any' o a tu interfaz de mensaje para saltarnos la unión de tipos compleja
const ultimoMensajeDB = mensajesDelChat[mensajesDelChat.length - 1] as any
// Ahora la validación es directa y no debería dar avisos de "unnecessary"
if (ultimoMensajeDB?.respuesta) {
setIsSyncing(false)
setOptimisticMessage(null)
@@ -576,14 +544,13 @@ function RouteComponent() {
}, [selectedArchivoIds, selectedRepositorioIds, uploadedFiles])
const removeSelectedField = (fieldKey: string) => {
console.log(fieldKey)
setSelectedFields((prev) => prev.filter((f) => f.key !== fieldKey))
}
return (
<div className="flex h-[calc(100vh-80px)] w-full flex-col gap-4 pb-1 md:h-[calc(100vh-160px)] md:max-h-[calc(100vh-160px)] md:flex-row md:overflow-hidden">
{/* --- HEADER MÓVIL (Solo visible en < md) --- */}
<div className="flex shrink-0 items-center justify-between rounded-lg border bg-white p-2 shadow-sm md:hidden">
{/* --- HEADER MÓVIL --- */}
<div className="bg-background flex shrink-0 items-center justify-between rounded-lg border p-2 shadow-sm md:hidden">
<Button
variant="ghost"
size="sm"
@@ -596,14 +563,13 @@ function RouteComponent() {
size="sm"
onClick={() => setIsActionsOpen(true)}
>
<Lightbulb size={18} className="mr-2 text-orange-500" /> Acciones
<Lightbulb size={18} className="text-primary mr-2" /> Acciones
</Button>
</div>
{/* --- PANEL IZQUIERDO: HISTORIAL (Escritorio) --- */}
{/* --- PANEL IZQUIERDO: HISTORIAL --- */}
<div className="hidden w-64 flex-col border-r pr-4 md:flex">
{/* ... (Tu código actual del historial de chats) ... */}
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
Chats
</h2>
<Button
@@ -615,8 +581,6 @@ function RouteComponent() {
</Button>
<ScrollArea className="flex-1">
<div className="space-y-1 pr-2">
{' '}
{/* Agregamos un pr-2 para que el scrollbar no tape botones */}
{!showArchived ? (
activeChats.map((chat) => (
<div
@@ -624,23 +588,19 @@ function RouteComponent() {
onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full items-center overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
? 'bg-accent text-foreground font-medium'
: 'text-muted-foreground hover:bg-accent/50'
}`}
>
{/* LADO IZQUIERDO: Icono + Texto */}
<div
className="flex min-w-0 flex-1 items-center gap-3 transition-all duration-200"
style={{
// Aplicamos la máscara solo cuando el mouse está encima para que se note el desvanecimiento
// donde aparecen los botones
maskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
WebkitMaskImage:
'linear-gradient(to right, black 70%, transparent 95%)',
}}
>
{/* pr-12 reserva espacio para los botones absolutos */}
<FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
@@ -654,7 +614,7 @@ function RouteComponent() {
suppressContentEditableWarning={true}
className={`block truncate outline-none ${
editingChatId === chat.id
? 'max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded bg-white px-1 break-all shadow-sm ring-1 ring-teal-500'
? 'bg-background ring-primary max-h-20 min-w-[100px] cursor-text overflow-y-auto rounded px-1 break-all shadow-sm ring-1'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
@@ -703,10 +663,9 @@ function RouteComponent() {
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones ABSOLUTAS */}
<div
className={`absolute top-1/2 right-2 z-20 flex -translate-y-1/2 items-center gap-1 rounded-md px-1 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
activeChatId === chat.id ? 'bg-accent' : 'bg-transparent'
}`}
>
<button
@@ -715,13 +674,13 @@ function RouteComponent() {
setEditingChatId(chat.id)
setTimeout(() => editableRef.current?.focus(), 50)
}}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
className="text-muted-foreground hover:text-primary rounded-md p-1 transition-colors"
>
<Send size={12} className="rotate-45" />
</button>
<button
onClick={(e) => archiveChat(e, chat.id)}
className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
className="text-muted-foreground hover:text-destructive rounded-md p-1 transition-colors"
>
<Archive size={14} />
</button>
@@ -729,15 +688,14 @@ function RouteComponent() {
</div>
))
) : (
/* Sección de archivados (Simplificada para mantener consistencia) */
<div className="animate-in fade-in slide-in-from-left-2 px-1">
<p className="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
<p className="text-muted-foreground mb-2 px-2 text-[10px] font-bold uppercase">
Archivados
</p>
{archivedChats.map((chat) => (
<div
key={chat.id}
className="group relative mb-1 flex w-full items-center overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
className="bg-muted/50 text-muted-foreground group relative mb-1 flex w-full items-center overflow-hidden rounded-lg px-3 py-2 text-sm"
>
<div className="flex min-w-0 flex-1 items-center gap-3 pr-10">
<Archive size={14} className="shrink-0 opacity-30" />
@@ -748,7 +706,7 @@ function RouteComponent() {
</div>
<button
onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded bg-slate-100 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
className="bg-accent hover:text-primary absolute top-1/2 right-2 shrink-0 -translate-y-1/2 rounded p-1 opacity-0 transition-opacity group-hover:opacity-100"
>
<RotateCcw size={14} />
</button>
@@ -760,22 +718,21 @@ function RouteComponent() {
</ScrollArea>
</div>
{/* --- PANEL DE CHAT PRINCIPAL (Centro) --- */}
<div className="relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm md:h-full md:flex-[3]">
{/* NUEVO: Barra superior de campos seleccionados */}
<div className="z-10 shrink-0 border-b bg-white p-3">
{/* --- PANEL DE CHAT PRINCIPAL --- */}
<div className="border-border/60 bg-muted/30 relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-xl border shadow-sm md:h-full md:flex-[3]">
<div className="bg-background z-10 shrink-0 border-b p-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-400 uppercase">
<span className="text-muted-foreground text-[10px] font-bold uppercase">
Mejorar con IA
</span>
<button
onClick={() => setOpenIA(true)}
className="flex items-center gap-2 rounded-md bg-slate-100 px-3 py-1.5 text-xs font-medium transition hover:bg-slate-200"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80 flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition"
>
<Archive size={14} className="text-slate-500" />
<Archive size={14} className="opacity-70" />
Referencias
{totalReferencias > 0 && (
<span className="flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
<span className="bg-primary text-primary-foreground flex h-4 min-w-[16px] items-center justify-center rounded-full px-1 text-[10px]">
{totalReferencias}
</span>
)}
@@ -783,7 +740,6 @@ function RouteComponent() {
</div>
</div>
{/* CONTENIDO DEL CHAT */}
<div className="relative flex min-h-0 flex-1 flex-col">
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="mx-auto flex max-w-3xl flex-col gap-6 p-6">
@@ -793,12 +749,12 @@ function RouteComponent() {
<div className="flex h-[400px] flex-col items-center justify-center text-center opacity-40">
<MessageSquarePlus
size={48}
className="mb-4 text-slate-300"
className="text-muted-foreground/50 mb-4"
/>
<h3 className="text-lg font-medium text-slate-900">
<h3 className="text-foreground text-lg font-medium">
No hay un chat seleccionado
</h3>
<p className="text-sm text-slate-500">
<p className="text-muted-foreground text-sm">
Selecciona un chat del historial o crea uno nuevo para
empezar.
</p>
@@ -808,7 +764,6 @@ function RouteComponent() {
{chatMessages.map((msg: any) => {
const isAI = msg.role === 'assistant'
const isUser = msg.role === 'user'
// IMPORTANTE: Asegúrate de que msg.id contenga la info de procesamiento o pásala en el map
const isProcessing = msg.isProcessing
return (
@@ -821,48 +776,44 @@ function RouteComponent() {
<div
className={`relative rounded-2xl p-3 text-sm whitespace-pre-wrap shadow-sm transition-all duration-300 ${
isUser
? 'rounded-tr-none bg-teal-600 text-white'
: `rounded-tl-none border bg-white text-slate-700 ${
? 'bg-primary text-primary-foreground rounded-tr-none'
: `bg-card text-card-foreground rounded-tl-none border ${
msg.isRefusal
? 'border-red-200 bg-red-50/50 ring-1 ring-red-100'
: 'border-slate-100'
? 'border-destructive/50 bg-destructive/10 ring-destructive/20 ring-1'
: 'border-border'
}`
}`}
>
{/* Aviso de Refusal */}
{msg.isRefusal && (
<div className="mb-1 flex items-center gap-1 text-[10px] font-bold text-red-500 uppercase">
<div className="text-destructive mb-1 flex items-center gap-1 text-[10px] font-bold uppercase">
<span>Aviso del Asistente</span>
</div>
)}
{/* CONTENIDO CORRECTO: Usamos msg.content */}
{isAI && isProcessing ? (
<div className="flex items-center gap-2 py-1">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.15s]" />
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" />
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full" />
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]" />
<span className="bg-primary h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]" />
</div>
</div>
) : (
msg.content // <--- CAMBIO CLAVE
msg.content
)}
{/* Recomendaciones */}
{isAI && msg.suggestions?.length > 0 && (
<div className="mt-4 w-full space-y-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<div className="border-border/60 bg-muted/50 mt-4 w-full space-y-3 rounded-xl border p-3">
<div className="flex items-center justify-between px-1">
<span className="text-[10px] font-bold text-slate-400 uppercase">
<span className="text-muted-foreground text-[10px] font-bold uppercase">
Sugerencias de mejora
</span>
{/* Botón Seleccionar Todo */}
{msg.suggestions.some(
(s: any) => !s.applied,
) && (
<Button
variant="ghost"
className="h-6 px-2 text-[10px] text-teal-600 hover:bg-teal-100"
className="text-primary hover:bg-primary/10 h-6 px-2 text-[10px]"
onClick={() =>
toggleAllFromMessage(msg.suggestions)
}
@@ -883,7 +834,7 @@ function RouteComponent() {
{!sug.applied && (
<input
type="checkbox"
className="mt-4 h-4 w-4 shrink-0 rounded border-slate-300 accent-teal-600"
className="border-input accent-primary mt-4 h-4 w-4 shrink-0 rounded"
checked={selectedImprovements.includes(
sug.key,
)}
@@ -894,7 +845,7 @@ function RouteComponent() {
)}
<div className="flex-1">
<ImprovementCard
suggestions={[sug]} // Pasamos solo una para que la card actúe individualmente
suggestions={[sug]}
dbMessageId={msg.dbMessageId}
planId={planId}
currentDatos={data?.datos}
@@ -907,14 +858,13 @@ function RouteComponent() {
</div>
))}
{/* Botón de Acción Masiva específico para este mensaje */}
{msg.suggestions.some((s: any) =>
selectedImprovements.includes(s.key),
) && (
<Button
size="sm"
disabled={isSending}
className="w-full bg-teal-600 py-1 text-xs font-bold text-white hover:bg-teal-700"
className="w-full py-1 text-xs font-bold"
onClick={() => {
const seleccionadas =
msg.suggestions.filter((s: any) =>
@@ -952,20 +902,20 @@ function RouteComponent() {
{(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-bottom-2 flex gap-4">
<Avatar className="h-9 w-9 shrink-0 border bg-teal-600 text-white shadow-sm">
<Avatar className="bg-primary text-primary-foreground h-9 w-9 shrink-0 border shadow-sm">
<AvatarFallback>
<Sparkles size={16} className="animate-pulse" />
</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start gap-2">
<div className="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="bg-card border-border rounded-2xl rounded-tl-none border p-4 shadow-sm">
<div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.3s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400 [animation-delay:-0.15s]"></span>
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.3s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full [animation-delay:-0.15s]"></span>
<span className="bg-muted-foreground/50 h-1.5 w-1.5 animate-bounce rounded-full"></span>
</div>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
<span className="text-muted-foreground text-[10px] font-medium italic">
La IA está analizando tu solicitud...
</span>
</div>
@@ -976,9 +926,8 @@ function RouteComponent() {
</div>
</ScrollArea>
{/* Botones flotantes de aplicación */}
{pendingSuggestion && !isLoading && (
<div className="animate-in fade-in slide-in-from-bottom-2 absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border bg-white p-1.5 shadow-2xl">
<div className="animate-in fade-in slide-in-from-bottom-2 bg-card border-border absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2 rounded-full border p-1.5 shadow-2xl">
<Button
variant="ghost"
size="sm"
@@ -987,23 +936,19 @@ function RouteComponent() {
>
<X className="mr-1 h-3 w-3" /> Descartar
</Button>
<Button
size="sm"
className="h-8 rounded-full bg-teal-600 text-xs text-white hover:bg-teal-700"
>
<Button size="sm" className="h-8 rounded-full text-xs">
<Check className="mr-1 h-3 w-3" /> Aplicar cambios
</Button>
</div>
)}
</div>
{/* INPUT FIJO AL FONDO CON SUGERENCIAS : */}
<div className="shrink-0 border-t bg-white p-4">
{/* INPUT FIJO AL FONDO */}
<div className="bg-background border-border shrink-0 border-t p-4">
<div className="relative mx-auto max-w-4xl">
{/* MENÚ DE SUGERENCIAS FLOTANTE */}
{showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full mb-2 w-full rounded-xl border bg-white shadow-2xl">
<div className="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
<div className="animate-in slide-in-from-bottom-2 bg-popover border-border absolute bottom-full mb-2 w-full rounded-xl border shadow-2xl">
<div className="bg-muted text-muted-foreground border-b px-3 py-2 text-[10px] font-bold uppercase">
Resultados para "{filterQuery}"
</div>
<div className="max-h-64 overflow-y-auto p-1">
@@ -1014,8 +959,8 @@ function RouteComponent() {
onClick={() => toggleField(field)}
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors ${
index === 0
? 'bg-teal-50 text-teal-700 ring-1 ring-teal-200 ring-inset'
: 'hover:bg-slate-50'
? 'bg-primary/10 text-primary ring-primary/30 ring-1 ring-inset'
: 'hover:bg-accent'
}`}
>
<span>{field.label}</span>
@@ -1027,7 +972,7 @@ function RouteComponent() {
</button>
))
) : (
<div className="p-3 text-center text-xs text-slate-400">
<div className="text-muted-foreground p-3 text-center text-xs">
No hay coincidencias
</div>
)}
@@ -1035,20 +980,18 @@ function RouteComponent() {
</div>
)}
{/* CONTENEDOR DEL INPUT TRANSFORMADO */}
<div className="flex flex-col gap-2 rounded-xl border bg-slate-50 p-2 transition-all focus-within:bg-white focus-within:ring-1 focus-within:ring-teal-500">
{/* 1. Visualización de campos dentro del input ) */}
<div className="bg-muted/50 focus-within:bg-background focus-within:ring-primary flex flex-col gap-2 rounded-xl border p-2 transition-all focus-within:ring-1">
{selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1">
{selectedFields.map((field) => (
<div
key={field.key}
className="animate-in zoom-in-95 flex items-center gap-1 rounded-md border border-teal-200 bg-teal-100 px-2 py-0.5 text-[11px] font-semibold text-teal-800"
className="animate-in zoom-in-95 border-primary/20 bg-primary/10 text-primary flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-semibold"
>
<span className="opacity-70">Campo:</span> {field.label}
<button
onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200"
className="hover:bg-primary/20 ml-1 rounded-full p-0.5 transition-colors"
>
<X size={10} />
</button>
@@ -1057,7 +1000,6 @@ function RouteComponent() {
</div>
)}
{/* 2. Área de escritura */}
<div className="flex items-end gap-2">
<Textarea
value={input}
@@ -1076,7 +1018,6 @@ function RouteComponent() {
setFilterQuery('')
}
} else {
// Si el usuario borra y el input está vacío, eliminar el último campo
if (
e.key === 'Backspace' &&
input === '' &&
@@ -1104,7 +1045,7 @@ function RouteComponent() {
isSending || (!input.trim() && selectedFields.length === 0)
}
size="icon"
className="mb-1 h-9 w-9 shrink-0 bg-teal-600 hover:bg-teal-700"
className="mb-1 h-9 w-9 shrink-0"
>
{isSending ? (
<Loader2 className="animate-spin" size={16} />
@@ -1118,22 +1059,22 @@ function RouteComponent() {
</div>
</div>
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS (Escritorio) --- */}
{/* --- PANEL LATERAL: ACCIONES RÁPIDAS --- */}
<div className="hidden flex-[1] flex-col gap-4 overflow-y-auto md:flex">
<h4 className="flex items-center gap-2 text-left text-sm font-bold text-slate-800">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
<h4 className="text-foreground flex items-center gap-2 text-left text-sm font-bold">
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
</h4>
<div className="space-y-2 p-1">
{PRESETS.map((preset) => (
<button
key={preset.id}
onClick={() => handleSend(preset.prompt)}
className="group flex w-full items-center gap-3 rounded-xl border bg-white p-3 text-left text-sm shadow-sm transition-all hover:border-teal-500 hover:bg-teal-50"
className="bg-card hover:border-primary hover:bg-primary/5 group flex w-full items-center gap-3 rounded-xl border p-3 text-left text-sm shadow-sm transition-all"
>
<div className="rounded-lg bg-slate-100 p-2 text-slate-500 group-hover:bg-teal-100 group-hover:text-teal-600">
<div className="bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary rounded-lg p-2 transition-colors">
<preset.icon size={16} />
</div>
<span className="leading-tight font-medium text-slate-700">
<span className="text-foreground leading-tight font-medium">
{preset.label}
</span>
</button>
@@ -1149,13 +1090,12 @@ function RouteComponent() {
createNewChat()
setIsHistoryOpen(false)
}}
className="mb-4 w-full bg-teal-600 text-white"
className="mb-4 w-full"
>
<MessageSquarePlus size={18} className="mr-2" /> Nuevo Chat
</Button>
<ScrollArea className="flex-1">
{/* Reutiliza aquí el mapeo de chats que tienes en el panel izquierdo */}
<p className="mb-4 text-xs font-bold text-slate-400 uppercase">
<p className="text-muted-foreground mb-4 text-xs font-bold uppercase">
Historial Reciente
</p>
{activeChats.map((chat) => (
@@ -1165,7 +1105,7 @@ function RouteComponent() {
setActiveChatId(chat.id)
setIsHistoryOpen(false)
}}
className="border-b p-3 text-sm"
className="border-border border-b p-3 text-sm"
>
{chat.nombre || 'Chat sin nombre'}
</div>
@@ -1178,7 +1118,7 @@ function RouteComponent() {
<Drawer open={isActionsOpen} onOpenChange={setIsActionsOpen}>
<DrawerContent className="h-[60vh] p-4">
<h4 className="mb-4 flex items-center gap-2 font-bold">
<Lightbulb size={18} className="text-orange-500" /> Acciones rápidas
<Lightbulb size={18} className="text-primary" /> Acciones rápidas
</h4>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{PRESETS.map((preset) => (
@@ -1188,7 +1128,7 @@ function RouteComponent() {
handleSend(preset.prompt)
setIsActionsOpen(false)
}}
className="flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
className="border-border flex items-center gap-3 rounded-xl border p-4 text-left text-sm"
>
<preset.icon size={16} />
<span>{preset.label}</span>
@@ -1198,23 +1138,20 @@ function RouteComponent() {
</DrawerContent>
</Drawer>
{/* Tu Drawer de Referencias IA se queda igual */}
<Drawer open={openIA} onOpenChange={setOpenIA}>
<DrawerContent className="fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border bg-white shadow-2xl">
{/* Cabecera más compacta */}
<div className="flex items-center justify-between border-b bg-slate-50/50 px-4 py-3">
<h2 className="text-xs font-bold tracking-wider text-slate-500 uppercase">
<DrawerContent className="bg-background fixed inset-x-0 bottom-0 mx-auto mb-4 flex h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl">
<div className="bg-muted/50 border-border flex items-center justify-between border-b px-4 py-3">
<h2 className="text-muted-foreground text-xs font-bold tracking-wider uppercase">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 transition-colors hover:text-slate-600"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={18} />
</button>
</div>
{/* Contenido con scroll interno */}
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
+20 -39
View File
@@ -3,7 +3,6 @@ import {
useNavigate,
useLocation,
} from '@tanstack/react-router'
// import confetti from 'canvas-confetti'
import { Pencil, Check, X, Sparkles, AlertCircle } from 'lucide-react'
import { useState, useEffect } from 'react'
@@ -20,7 +19,6 @@ import {
} from '@/components/ui/tooltip'
import { usePlan, useUpdatePlanFields } from '@/data'
// import { toast } from 'sonner' // Asegúrate de tener sonner instalado o quita la línea
export const Route = createFileRoute('/planes/$planId/_detalle/')({
component: DatosGeneralesPage,
})
@@ -34,21 +32,20 @@ function DatosGeneralesPage() {
const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId)
const navigate = useNavigate()
// Inicializamos campos como un arreglo vacío
const [campos, setCampos] = useState<Array<DatosGeneralesField>>([])
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const location = useLocation()
const updatePlan = useUpdatePlanFields()
// Confetti al llegar desde creación
useEffect(() => {
if (location.state.showConfetti) {
lateralConfetti()
window.history.replaceState({}, document.title) // Limpiar el estado para que no se repita
window.history.replaceState({}, document.title)
}
}, [location.state])
// Efecto para transformar data?.datos en el arreglo de campos
useEffect(() => {
const definicion = data?.estructuras_plan?.definicion as any
const properties = definicion?.properties
@@ -59,18 +56,13 @@ function DatosGeneralesPage() {
if (properties && typeof properties === 'object') {
let keys = Object.keys(properties)
// Ordenar llaves basado en la lista "required" si existe
if (Array.isArray(requiredOrder)) {
keys = keys.sort((a, b) => {
const indexA = requiredOrder.indexOf(a)
const indexB = requiredOrder.indexOf(b)
// Si 'a' está en la lista y 'b' no -> 'a' primero (-1)
if (indexA !== -1 && indexB === -1) return -1
// Si 'b' está en la lista y 'a' no -> 'b' primero (1)
if (indexA === -1 && indexB !== -1) return 1
// Si ambos están, comparar índices
if (indexA !== -1 && indexB !== -1) return indexA - indexB
// Ninguno en la lista, mantener orden relativo
return 0
})
}
@@ -108,18 +100,14 @@ function DatosGeneralesPage() {
}
}, [data])
// 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (nuevoCampo: DatosGeneralesField) => {
// 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue)
}
}
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value)
}
@@ -128,7 +116,7 @@ function DatosGeneralesPage() {
setEditingId(null)
setEditValue('')
}
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = (
data: any,
campo: DatosGeneralesField,
@@ -167,7 +155,6 @@ function DatosGeneralesPage() {
patch: { datos: datosActualizados },
})
// Actualizar UI localmente
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
)
@@ -185,13 +172,11 @@ function DatosGeneralesPage() {
currentValue !== null &&
'description' in currentValue
) {
// Caso 1: objeto con description
newValue = {
...currentValue,
description: editValue,
}
} else {
// Caso 2: valor plano (string, number, etc)
newValue = editValue
}
@@ -207,7 +192,6 @@ function DatosGeneralesPage() {
},
})
// UI optimista
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
)
@@ -217,17 +201,18 @@ function DatosGeneralesPage() {
setEditValue('')
}
const handleIARequest = (clave: string) => {
const handleIARequest = (campo: DatosGeneralesField) => {
navigate({
to: '/planes/$planId/iaplan',
params: {
planId: planId, // o dinámico
planId: planId,
},
state: {
campo_edit: clave,
campo_edit: campo.clave,
} as any,
})
}
return (
<div className="animate-in fade-in duration-500">
<div className="mb-6">
@@ -246,19 +231,19 @@ function DatosGeneralesPage() {
return (
<div
key={campo.id}
className={`rounded-xl border transition-all ${
className={`bg-card rounded-xl border transition-all ${
isEditing
? 'border-teal-500 shadow-lg ring-2 ring-teal-50'
: 'bg-white hover:shadow-md'
? 'border-primary ring-primary/20 shadow-lg ring-2'
: 'hover:shadow-md'
}`}
>
{/* Header de la Card */}
<TooltipProvider>
<div className="flex items-center justify-between border-b bg-slate-50/50 px-5 py-3">
<div className="bg-muted/30 flex items-center justify-between border-b px-5 py-3">
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<h3 className="cursor-help text-sm font-medium text-slate-700">
<h3 className="text-foreground cursor-help text-sm font-medium">
{campo.label}
</h3>
</TooltipTrigger>
@@ -268,7 +253,7 @@ function DatosGeneralesPage() {
</Tooltip>
{campo.requerido && (
<span className="text-xs text-red-500">*</span>
<span className="text-destructive text-xs">*</span>
)}
</div>
@@ -279,7 +264,7 @@ function DatosGeneralesPage() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-teal-600"
className="text-primary hover:text-primary/90 h-8 w-8"
onClick={() => handleIARequest(campo)}
>
<Sparkles size={14} />
@@ -293,7 +278,7 @@ function DatosGeneralesPage() {
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="text-muted-foreground hover:text-foreground h-8 w-8"
onClick={() => handleEdit(campo)}
>
<Pencil size={14} />
@@ -324,11 +309,7 @@ function DatosGeneralesPage() {
>
<X size={14} className="mr-1" /> Cancelar
</Button>
<Button
size="sm"
className="bg-teal-600 hover:bg-teal-700"
onClick={() => handleSave(campo)}
>
<Button size="sm" onClick={() => handleSave(campo)}>
<Check size={14} className="mr-1" /> Guardar
</Button>
</div>
@@ -336,12 +317,12 @@ function DatosGeneralesPage() {
) : (
<div className="min-h-25">
{campo.value ? (
<div className="text-sm leading-relaxed text-slate-600">
<div className="text-muted-foreground text-sm leading-relaxed">
{campo.tipo === 'lista' ? (
<ul className="space-y-1">
{campo.value.split('\n').map((item, i) => (
<li key={i} className="flex gap-2">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
<span className="bg-primary mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full" />
{item}
</li>
))}
@@ -351,7 +332,7 @@ function DatosGeneralesPage() {
)}
</div>
) : (
<div className="flex items-center gap-2 text-sm text-slate-400">
<div className="text-muted-foreground/70 flex items-center gap-2 text-sm">
<AlertCircle size={14} />
<span>Sin contenido.</span>
</div>
+2 -2
View File
@@ -1013,7 +1013,7 @@ function MapaCurricularPage() {
<div className="col-span-full">
<div className="sticky left-0 z-10 w-35">
<Button
className="ring-offset-background bg-primary text-primary-foreground hover:bg-primary/90 inline-flex h-11 w-full items-center justify-start gap-2 rounded-md px-8 text-sm font-medium shadow-md transition-colors"
className="shadow-md"
onClick={() => setIsAddLineaDialogOpen(true)}
>
<Plus size={14} /> Agregar línea
@@ -1277,7 +1277,7 @@ function MapaCurricularPage() {
<div className="mt-2 flex items-center justify-end gap-3 border-t pt-4">
<Button
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"
onClick={handleAgregarLinea}
disabled={!canAddLinea || isCreatingLinea}
>
+8 -6
View File
@@ -8,6 +8,7 @@ import BarraBusqueda from '@/components/planes/BarraBusqueda'
import Filtro from '@/components/planes/Filtro'
import PlanEstudiosCard from '@/components/planes/PlanEstudiosCard'
// Hooks y Utils (ajusta las rutas de importación)
import { Button } from '@/components/ui/button'
import { usePlanes, useCatalogosPlanes } from '@/data/hooks/usePlans'
import { getIconByName } from '@/features/planes/utils/icon-utils'
@@ -131,16 +132,16 @@ function RouteComponent() {
</p>
</div>
</div>
<button
<Button
onClick={() => {
console.log('planId')
navigate({ to: '/planes/nuevo', 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"
>
<Icons.Plus /> Nuevo plan de estudios
</button>
</Button>
</div>
{/* Barra de Filtros */}
@@ -188,16 +189,17 @@ function RouteComponent() {
placeholder="Estado"
/>
</div>
<button
<Button
type="button"
variant="secondary"
onClick={resetFilters}
disabled={isClearDisabled}
className={`ring-offset-background bg-secondary text-secondary-foreground hover:bg-secondary/90 inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium shadow-md transition-colors ${
className={`shadow-md ${
isClearDisabled ? 'cursor-not-allowed opacity-50' : ''
}`}
>
<Icons.X className="h-4 w-4" /> Limpiar
</button>
</Button>
</div>
</div>