19 Commits

Author SHA1 Message Date
25d451839e hotfix: se mejoró UX modificando el tipo de cursor que se muestra al hacer hover sobre elementos interactuables y se restringió el input de horas estimadas a un rango de 0 a 200 pero permitiendo medias horas 2026-03-17 13:33:20 -06:00
fe8f1d4753 Merge pull request 'contenido es ordenable, botón de nueva unidad después de cada unidad, mejora de UX con unidades expandidas' (#185) from issue/182-funcionalidad-de-reacomodo-e-insercin-aleatoria-de into main
Reviewed-on: #185
2026-03-17 18:47:56 +00:00
518b1124d8 close #182: se implementó la lista de unidades como sortable con dnd-kit y se solucionó el parpadeo al reordenar.
Se convirtió la lista de unidades en un sortable controlado usando @dnd-kit/react; al arrastrar desde el GripVertical se reordenan las unidades en la UI y persiste el orden en la base de datos.
Se colocó el botón "Nueva unidad" como un overlay que aparece debajo de cada unidad al hacer hover (posición bottom) y su clic inserta una unidad entre las existentes o al final si se pulsa después de la última, sin desplazar el layout.
Se hizo que, al cargar el componente por primera vez, la primera unidad quede desplegada automáticamente; una vez que el usuario realiza cualquier modificación y se guarda, se empieza a respetar el conjunto de unidades que el usuario tenga expandidas (la bandera de inicialización se activa durante la persistencia).
Se arregló un bug en el que al reordenar la lista de unidades sucedía una recarga de la lista.
Por qué ocurría el parpadeo y cómo fue arreglado:
- Causa: tras el reorder optimista la UI quedaba actualizada, pero cuando la lista fresca llegaba del servidor un useEffect reasignaba IDs por posición (índice), provocando que React creyera que los elementos eran nuevos, se destruyeran y se volvieran a montar — de ahí el "parpadeo" y la pérdida del estado de los acordiones.
- Solución (Escudo Optimista): se añadieron dos defensas.
  1) Escudo de aborto temprano: si el payload actual (UI optimista) y el payload entrante del servidor son idénticos (JSON), se aborta el procesamiento del useEffect para evitar re-render innecesario.
  2) Reciclaje por contenido: cuando los datos difieren, las IDs locales se reciclan buscando coincidencias por contenido (título) en lugar de por posición, de modo que cada unidad conserva su ID real aunque cambie de lugar; así React mueve las tarjetas en vez de destruirlas.

Con esto, el reorder es estable, el overlay de inserción funciona sin alterar el flow visual y el estado de expansiones se preserva tras ediciones del usuario.
2026-03-17 12:36:14 -06:00
8bdaf935ca fix #181: al darle a siguiente desde estructura, se fuerza la regeneración de citas 2026-03-13 12:38:02 -06:00
0d636cbf3b Merge pull request 'Consistencia y mensajes del chat de la IA fix #179 fix #178' (#183) from issue/179-consistencia-y-mensajes-del-chat-de-la-ia into main
Reviewed-on: #183
2026-03-13 18:17:31 +00:00
82d047e1c2 Consistencia y mensajes del chat de la IA
fix #179
fix #178
2026-03-13 12:17:01 -06:00
674c8a6bee Merge pull request 'Propuesta de vista para elegir entre sugerencias de bibliografia encontradas en línea y coincidencias encontradas en la biblioteca' (#177) from issue/169-crear-vista-de-validacin-en-biblioteca-la-salle into main
Reviewed-on: #177
2026-03-12 22:18:51 +00:00
3acea813b6 close #169: Se actualizó el modal de nueva bibliografía y se añadió el paso "Biblioteca"
- Se unificó el stepper en cinco pasos y se configuró para omitir el paso "Biblioteca" cuando el método sea MANUAL.
- Se añadió el paso "Biblioteca" con un accordion múltiple para comparar cada sugerencia con alternativas de la biblioteca; se eliminaron los estados de "Buscando" y su badge.
- Se incorporaron tres conjuntos hardcodeados de coincidencias (0, 2 y 5) que se asignan al azar si la sugerencia no trae datos de biblioteca; si no hay coincidencias la sugerencia se marca automáticamente como mantenida.
- Se implementó BookSelectionAccordion para elegir conservar la sugerencia o sustituirla por una coincidencia; se preservó el estilo visual de las opciones.
- Se añadieron validaciones y comportamientos de navegación: bloqueo de avance si quedan accordions por revisar, apertura y scroll al primer accordion sin resolver, y salto del paso "Biblioteca" en modo MANUAL.
2026-03-12 16:17:58 -06:00
e68954e03c Merge pull request 'Que pueda generar el contenido temático Y el sistema de evaluación fix #174 fix #163 fix # 173' (#176) from issue/174-que-pueda-generar-el-contenido-temtico-y-el-sistem into main
Reviewed-on: #176
2026-03-12 22:05:41 +00:00
296fbfee79 Merge branch 'main' into issue/174-que-pueda-generar-el-contenido-temtico-y-el-sistem 2026-03-12 22:05:31 +00:00
a55910c226 Que pueda generar el contenido temático Y el sistema de evaluación fix #174
fix #163
fix # 173
2026-03-12 16:04:53 -06:00
88c6dc6b4d wip 2026-03-12 13:47:56 -06:00
03caa791ad hotfix: correccion de columnas de bibliografia 2026-03-12 12:08:22 -06:00
577daaff03 Actualizar src/data/api/subjects.api.ts 2026-03-12 15:06:04 +00:00
f75680e8dd Merge pull request 'Se homologa vista y funcionalidades de chat de asignatura ( Guardar cambios o mejora es decir aplicar mejora, crear conversaciones, renombrar conversaciones, archivar conversaciones visualizar modal de referencias)' (#172) from issue/160-chats-de-ia-en-segundo-plano-para-asignaturas into main
Reviewed-on: #172
2026-03-11 22:07:32 +00:00
0b7f45c150 Merge branch 'main' into issue/160-chats-de-ia-en-segundo-plano-para-asignaturas 2026-03-11 22:07:19 +00:00
56ac8c0155 Se homologa vista y funcionalidades de chat de asignatura ( Guardar cambios o mejora es decir aplicar mejora, crear conversaciones, renombrar conversaciones, archivar conversaciones visualizar modal de referencias) 2026-03-11 16:06:26 -06:00
8ecb0f205a Merge pull request 'Se añadieron validaciones y mejoras en el modal de nueva bibliografía, incluida la validacion de al menos tres caracteres para el query' (#171) from issue/170-validacin-de-mnimo-3-caracteres-en-la-bsqueda-de-b into main
Reviewed-on: #171
2026-03-11 22:04:26 +00:00
ea842ee46c close #170: se añadieron validaciones y mejoras en el modal de nueva bibliografía
-Se implementaron restricciones en SugerenciasStep: el campo de búsqueda se limitó a 200 caracteres y la generación quedó bloqueada si hay 20 o más referencias seleccionadas; se añadió tooltip en el botón de generar cuando la query tiene menos de 3 caracteres.
-Se reforzaron validaciones en FormatoYCitasStep y DatosBasicosManualStep: el título se trim-eó y se forzó a no quedar vacío (max 500 caracteres); si un título queda vacío se hace scroll al input/card, se muestra mensaje de error junto al label y se resalta el input; autores se limitó a 2000 caracteres; editorial a 300 caracteres; ISBN a 20 caracteres; el año se convirtió en input numérico permitiendo vacío o un año de 4 dígitos entre 1450 y el año actual +1.
-Se añadieron checkboxes "Año aproximado" y "En prensa" (mutuamente excluyentes): "En prensa" deshabilita el input de año y se marca el estado para citeproc; "Año aproximado" se envía como circa en issued.
-Al generar CSL se incluyeron las propiedades issued.circa y status ('in press') según los flags del ref.
-En ResumenStep se añadieron advertencias por referencia cuando falte autor(es), año (si no está "en prensa"), editorial o ISBN.
-Se corrigieron detalles de UX en edición de autores para preservar saltos de línea y se añadieron handlers para evitar errores de validación al mover entre pasos.
2026-03-11 16:03:05 -06:00
13 changed files with 2708 additions and 968 deletions

View File

@@ -4,6 +4,7 @@
"": { "": {
"name": "acad-ia-2", "name": "acad-ia-2",
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
@@ -138,6 +139,18 @@
"@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="],
"@dnd-kit/abstract": ["@dnd-kit/abstract@0.3.2", "", { "dependencies": { "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-uvPVK+SZYD6Viddn9M0K0JQdXknuVSxA/EbMlFRanve3P/XTc18oLa5zGftKSGjfQGmuzkZ34E26DSbly1zi3Q=="],
"@dnd-kit/collision": ["@dnd-kit/collision@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-pNmNSLCI8S9fNQ7QJ3fBCDjiT0sqBhUFcKgmyYaGvGCAU+kq0AP8OWlh0JSisc9k5mFyxmRpmFQcnJpILz/RPA=="],
"@dnd-kit/dom": ["@dnd-kit/dom@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/collision": "^0.3.2", "@dnd-kit/geometry": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-cIUAVgt2szQyz6JRy7I+0r+xeyOAGH21Y15hb5bIyHoDEaZBvIDH+OOlD9eoLjCbsxDLN9WloU2CBi3OE6LYDg=="],
"@dnd-kit/geometry": ["@dnd-kit/geometry@0.3.2", "", { "dependencies": { "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" } }, "sha512-3UBPuIS7E3oGiHxOE8h810QA+0pnrnCtGxl4Os1z3yy5YkC/BEYGY+TxWPTQaY1/OMV7GCX7ZNMlama2QN3n3w=="],
"@dnd-kit/react": ["@dnd-kit/react@0.3.2", "", { "dependencies": { "@dnd-kit/abstract": "^0.3.2", "@dnd-kit/dom": "^0.3.2", "@dnd-kit/state": "^0.3.2", "tslib": "^2.6.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-1Opg1xw6I75Z95c+rF2NJa0pdGb8rLAENtuopKtJ1J0PudWlz+P6yL137xy/6DV43uaRmNGtsdbMbR0yRYJ72g=="],
"@dnd-kit/state": ["@dnd-kit/state@0.3.2", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-dLUIkoYrIJhGXfF2wGLTfb46vUokEsO/OoE21TSfmahYrx7ysTmnwbePsznFaHlwgZhQEh6AlLvthLCeY21b1A=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -250,6 +263,8 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@preact/signals-core": ["@preact/signals-core@1.14.0", "", {}, "sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],

View File

@@ -17,6 +17,7 @@
"ci:verify": "prettier --check . && eslint . && tsc --noEmit" "ci:verify": "prettier --check . && eslint . && tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",

View File

@@ -1,3 +1,5 @@
import { DragDropProvider } from '@dnd-kit/react'
import { isSortable, useSortable } from '@dnd-kit/react/sortable'
import { useParams } from '@tanstack/react-router' import { useParams } from '@tanstack/react-router'
import { import {
Plus, Plus,
@@ -11,7 +13,7 @@ import {
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent } from 'react' import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
import { import {
AlertDialog, AlertDialog,
@@ -50,6 +52,95 @@ export interface UnidadTematica {
temas: Array<Tema> temas: Array<Tema>
} }
function createClientId(prefix: string) {
try {
const c = (globalThis as any).crypto
if (c && typeof c.randomUUID === 'function')
return `${prefix}-${c.randomUUID()}`
} catch {
// ignore
}
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function arrayMove<T>(array: Array<T>, fromIndex: number, toIndex: number) {
const next = array.slice()
const startIndex = fromIndex < 0 ? next.length + fromIndex : fromIndex
if (startIndex < 0 || startIndex >= next.length) return next
const endIndex = toIndex < 0 ? next.length + toIndex : toIndex
const [item] = next.splice(startIndex, 1)
next.splice(endIndex, 0, item)
return next
}
function renumberUnidades(unidades: Array<UnidadTematica>) {
return unidades.map((u, idx) => ({ ...u, numero: idx + 1 }))
}
function InsertUnidadOverlay({
onInsert,
position,
}: {
onInsert: () => void
position: 'top' | 'bottom'
}) {
return (
<div
className={cn(
'pointer-events-auto absolute right-0 left-0 z-30 flex justify-center',
// Match the `space-y-4` gap so the hover target is *between* units.
position === 'top' ? '-top-4 h-4' : '-bottom-4 h-4',
)}
>
<Button
type="button"
variant="outline"
size="sm"
className="bg-background/95 border-border/60 hover:bg-background cursor-pointer opacity-0 shadow-sm transition-opacity group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation()
onInsert()
}}
>
<Plus className="mr-2 h-3 w-3" /> Nueva unidad
</Button>
</div>
)
}
function SortableUnidad({
id,
index,
registerContainer,
children,
}: {
id: string
index: number
registerContainer: (el: HTMLDivElement | null) => void
children: (args: { handleRef: (el: HTMLElement | null) => void }) => ReactNode
}) {
const { ref, handleRef, isDragSource, isDropTarget } = useSortable({
id,
index,
})
return (
<div
ref={(el) => {
ref(el)
registerContainer(el)
}}
className={cn(
'group relative',
isDragSource && 'opacity-80',
isDropTarget && 'ring-primary/20 ring-2',
)}
>
{children({ handleRef })}
</div>
)
}
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value) return typeof value === 'object' && value !== null && !Array.isArray(value)
} }
@@ -100,20 +191,18 @@ function mapContenidoItem(value: unknown, index: number): ContenidoApi | null {
if (Array.isArray(value.temas)) { if (Array.isArray(value.temas)) {
temas = value.temas temas = value.temas
.map(mapTemaValue) .map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null) .filter((x): x is ContenidoTemaApi => x !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
} }
return { unidad, titulo, temas } return {
...value,
unidad,
titulo,
temas,
}
} }
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> { function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') { if (typeof value === 'string') {
try { try {
return mapContenidoTematicoFromDb(JSON.parse(value)) return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('') const [temaDraftHoras, setTemaDraftHoras] = useState('')
const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const [temaOriginalHoras, setTemaOriginalHoras] = useState(0)
const didInitExpandedUnitsRef = useRef(false)
const unidadesRef = useRef<Array<UnidadTematica>>([])
useEffect(() => {
unidadesRef.current = unidades
}, [unidades])
const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => { const persistUnidades = async (nextUnidades: Array<UnidadTematica>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades) const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({ await updateContenido.mutateAsync({
subjectId: asignaturaId, subjectId: asignaturaId,
@@ -246,10 +344,17 @@ export function ContenidoTematico() {
}) })
} }
const parseHorasEstimadas = (raw: string): number => {
const normalized = raw.trim().replace(',', '.')
const parsed = Number.parseFloat(normalized)
if (!Number.isFinite(parsed)) return 0
return parsed
}
const commitEditTema = () => { const commitEditTema = () => {
if (!editingTema) return if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10) const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const next = unidades.map((u) => { const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u if (u.id !== editingTema.unitId) return u
@@ -303,28 +408,110 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined, data ? data.contenido_tematico : undefined,
) )
const transformed = contenido.map((u, idx) => ({ // 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
id: `u-${u.unidad || idx + 1}`, // (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
numero: u.unidad || idx + 1, const currentPayload = JSON.stringify(
nombre: u.titulo || 'Sin título', serializeUnidadesToApi(unidadesRef.current),
)
// Normalizamos la data de la BD para que tenga exactamente la misma forma que el payload
const incomingPayload = JSON.stringify(
contenido.map((u, idx) => ({
unidad: u.unidad || idx + 1,
titulo: u.titulo || 'Sin título',
temas: Array.isArray(u.temas) temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({ ? u.temas.map((t) => {
id: `t-${u.unidad || idx + 1}-${tidx + 1}`, if (typeof t === 'string') {
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', return {
horasEstimadas: t?.horasEstimadas || 0, nombre: t,
})) horasEstimadas: 0,
descripcion: undefined,
}
}
return {
nombre: t.nombre || 'Tema',
horasEstimadas: t.horasEstimadas ?? 0,
descripcion: t.descripcion,
}
})
: [], : [],
})) })),
)
// Si los datos son idénticos, abortamos el useEffect.
// ¡Nuestros IDs locales se salvan y no hay parpadeos!
if (currentPayload === incomingPayload && unidadesRef.current.length > 0) {
return
}
// 2. Si llegamos aquí, es la carga inicial o alguien más editó la BD desde otro lado.
// Reciclamos IDs buscando por CONTENIDO (nombre), NUNCA POR ÍNDICE.
const prevUnidades = [...unidadesRef.current]
const transformed = contenido.map((u, idx) => {
const dbTitulo = u.titulo || 'Sin título'
// Buscamos si ya existe una unidad con este mismo título
const existingUnitIndex = prevUnidades.findIndex(
(prev) => prev.nombre === dbTitulo,
)
let unidadId
let existingUnit = null
if (existingUnitIndex !== -1) {
existingUnit = prevUnidades[existingUnitIndex]
unidadId = existingUnit.id
prevUnidades.splice(existingUnitIndex, 1) // Lo sacamos de la lista para no repetirlo
} else {
unidadId = createClientId(`u-${u.unidad || idx + 1}`)
}
return {
id: unidadId,
numero: u.unidad || idx + 1,
nombre: dbTitulo,
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => {
const dbTemaNombre =
typeof t === 'string' ? t : t?.nombre || 'Tema'
// Reciclamos subtemas por nombre también
const existingTema = existingUnit?.temas.find(
(prevT) => prevT.nombre === dbTemaNombre,
)
const temaId = existingTema
? existingTema.id
: createClientId(`t-${u.unidad || idx + 1}-${tidx + 1}`)
return {
id: temaId,
nombre: dbTemaNombre,
horasEstimadas:
coerceNumber(
typeof t === 'string' ? undefined : t?.horasEstimadas,
) ?? 0,
}
})
: [],
}
})
setUnidades(transformed) setUnidades(transformed)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id)) const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set( const filtered = new Set(
Array.from(prev).filter((id) => validIds.has(id)), Array.from(prev).filter((id) => validIds.has(id)),
) )
if (filtered.size > 0) return filtered
return transformed.length > 0 ? new Set([transformed[0].id]) : new Set() // Expandir la primera unidad solo una vez al llegar a la ruta.
// Luego, no auto-expandimos de nuevo (aunque `data` cambie).
if (!didInitExpandedUnitsRef.current && transformed.length > 0) {
return filtered.size > 0 ? filtered : new Set([transformed[0].id])
}
return filtered
}) })
}, [data]) }, [data])
@@ -353,7 +540,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos) // 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce( const totalHoras = unidades.reduce(
(acc, u) => (acc, u) =>
acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas || 0), 0), acc + u.temas.reduce((sum, t) => sum + (t.horasEstimadas ?? 0), 0),
0, 0,
) )
@@ -364,16 +551,22 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded) setExpandedUnits(newExpanded)
} }
const addUnidad = () => { const insertUnidadAt = (insertIndex: number) => {
const newNumero = unidades.length + 1 const newId = createClientId('u')
const newId = `u-${newNumero}`
const newUnidad: UnidadTematica = { const newUnidad: UnidadTematica = {
id: newId, id: newId,
nombre: 'Nueva Unidad', nombre: 'Nueva Unidad',
numero: newNumero, numero: 0,
temas: [], temas: [],
} }
const next = [...unidades, newUnidad]
const clampedIndex = Math.max(0, Math.min(insertIndex, unidades.length))
const next = renumberUnidades([
...unidades.slice(0, clampedIndex),
newUnidad,
...unidades.slice(clampedIndex),
])
setUnidades(next) setUnidades(next)
setExpandedUnits((prev) => { setExpandedUnits((prev) => {
const n = new Set(prev) const n = new Set(prev)
@@ -382,10 +575,40 @@ export function ContenidoTematico() {
}) })
setPendingScrollUnitId(newId) setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId) setEditingUnit(newId)
setUnitDraftNombre(newUnidad.nombre) setUnitDraftNombre(newUnidad.nombre)
setUnitOriginalNombre(newUnidad.nombre) setUnitOriginalNombre(newUnidad.nombre)
void persistUnidades(next)
}
const handleReorderEnd = (event: any) => {
if (event?.canceled) return
const source = event?.operation?.source
if (!source) return
// Type-guard nativo de dnd-kit para asegurar que el elemento tiene metadata de orden
if (!isSortable(source)) return
// Extraemos las posiciones exactas calculadas por dnd-kit
const { initialIndex, index } = source.sortable
// Si lo soltó en la misma posición de la que salió, cancelamos
if (initialIndex === index) return
setUnidades((prev) => {
// Hacemos el movimiento usando los índices directos
const moved = arrayMove(prev, initialIndex, index)
const next = renumberUnidades(moved)
// Disparamos la persistencia hacia Supabase
void persistUnidades(next).catch((err) => {
console.error('No se pudo guardar el orden de unidades', err)
})
return next
})
} }
// --- Lógica de Temas --- // --- Lógica de Temas ---
@@ -451,15 +674,25 @@ export function ContenidoTematico() {
</div> </div>
</div> </div>
<DragDropProvider onDragEnd={handleReorderEnd}>
<div className="space-y-4"> <div className="space-y-4">
{unidades.map((unidad) => ( {unidades.map((unidad, index) => (
<div <SortableUnidad
key={unidad.id} key={unidad.id}
ref={(el) => { id={unidad.id}
index={index}
registerContainer={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el) if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id) else unitContainerRefs.current.delete(unidad.id)
}} }}
> >
{({ handleRef }) => (
<>
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
/>
<Card className="overflow-hidden border-slate-200 shadow-sm"> <Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible <Collapsible
open={expandedUnits.has(unidad.id)} open={expandedUnits.has(unidad.id)}
@@ -467,9 +700,19 @@ export function ContenidoTematico() {
> >
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3"> <CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" /> <span
ref={handleRef as any}
className="inline-flex cursor-grab touch-none items-center text-slate-300"
aria-label="Reordenar unidad"
>
<GripVertical className="h-4 w-4" />
</span>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-0"> <Button
variant="ghost"
size="sm"
className="h-auto cursor-pointer p-0"
>
{expandedUnits.has(unidad.id) ? ( {expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-4 w-4" />
) : ( ) : (
@@ -485,7 +728,9 @@ export function ContenidoTematico() {
<Input <Input
ref={unitTitleInputRef} ref={unitTitleInputRef}
value={unitDraftNombre} value={unitDraftNombre}
onChange={(e) => setUnitDraftNombre(e.target.value)} onChange={(e) =>
setUnitDraftNombre(e.target.value)
}
onBlur={() => { onBlur={() => {
if (cancelNextBlurRef.current) { if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false cancelNextBlurRef.current = false
@@ -518,7 +763,7 @@ export function ContenidoTematico() {
)} )}
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs font-medium text-slate-400"> <span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '} <Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce( {unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0), (sum, t) => sum + (t.horasEstimadas || 0),
@@ -529,9 +774,12 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500" className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
onClick={() => onClick={() =>
setDeleteDialog({ type: 'unidad', id: unidad.id }) setDeleteDialog({
type: 'unidad',
id: unidad.id,
})
} }
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -554,10 +802,14 @@ export function ContenidoTematico() {
} }
draftNombre={temaDraftNombre} draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras} draftHoras={temaDraftHoras}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)} onBeginEdit={() =>
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre} onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras} onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture} onEditorBlurCapture={
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={ onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture handleTemaEditorKeyDownCapture
} }
@@ -576,7 +828,7 @@ export function ContenidoTematico() {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700" className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)} onClick={() => addTema(unidad.id)}
> >
<Plus className="mr-2 h-3 w-3" /> Añadir subtema <Plus className="mr-2 h-3 w-3" /> Añadir subtema
@@ -586,23 +838,12 @@ export function ContenidoTematico() {
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>
</Card> </Card>
</div> </>
)}
</SortableUnidad>
))} ))}
</div> </div>
</DragDropProvider>
<div className="flex justify-center pt-2">
<Button
variant="outline"
className="gap-2"
onClick={(e) => {
// Evita que Enter vuelva a disparar el click sobre el botón.
e.currentTarget.blur()
addUnidad()
}}
>
<Plus className="h-4 w-4" /> Nueva unidad
</Button>
</div>
<DeleteConfirmDialog <DeleteConfirmDialog
dialog={deleteDialog} dialog={deleteDialog}
@@ -667,6 +908,9 @@ function TemaRow({
<Input <Input
type="number" type="number"
value={draftHoras} value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)} onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white" className="h-8 w-16 bg-white"
/> />
@@ -675,7 +919,7 @@ function TemaRow({
<> <>
<button <button
type="button" type="button"
className="flex flex-1 items-center gap-3 text-left" className="flex flex-1 cursor-pointer items-center gap-3 text-left"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -690,7 +934,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-blue-600" className="h-7 w-7 cursor-pointer text-slate-400 hover:text-blue-600"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onBeginEdit() onBeginEdit()
@@ -701,7 +945,7 @@ function TemaRow({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 text-slate-400 hover:text-red-500" className="h-7 w-7 cursor-pointer text-slate-400 hover:text-red-500"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
onDelete() onDelete()

View File

@@ -13,21 +13,33 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
History, // Agregado History,
Edit2, // Agregado
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
import { ImprovementCard } from './SaveAsignatura/ImprovementCardProps'
import type { IASugerencia } from '@/types/asignatura' import type { IASugerencia } from '@/types/asignatura'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar' import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAISubjectChat, useAISubjectChat,
useConversationBySubject, useConversationBySubject,
useMessagesBySubjectChat, useMessagesBySubjectChat,
useSubject, useSubject,
useUpdateSubjectConversationName,
useUpdateSubjectConversationStatus, useUpdateSubjectConversationStatus,
} from '@/data' } from '@/data'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -75,6 +87,24 @@ export function IAAsignaturaTab({
const { mutate: updateStatus } = useUpdateSubjectConversationStatus() const { mutate: updateStatus } = useUpdateSubjectConversationStatus()
const [isCreatingNewChat, setIsCreatingNewChat] = useState(false) const [isCreatingNewChat, setIsCreatingNewChat] = useState(false)
const hasInitialSelected = useRef(false) const hasInitialSelected = useRef(false)
const { mutate: updateName } = useUpdateSubjectConversationName()
const [editingId, setEditingId] = useState<string | null>(null)
const [tempName, setTempName] = useState('')
const [openIA, setOpenIA] = useState(false)
const [selectedArchivoIds, setSelectedArchivoIds] = useState<Array<string>>(
[],
)
const [selectedRepositorioIds, setSelectedRepositorioIds] = useState<
Array<string>
>([])
const [uploadedFiles, setUploadedFiles] = useState<Array<File>>([])
// Cálculo del total para el Badge del botón
const totalReferencias =
selectedArchivoIds.length +
selectedRepositorioIds.length +
uploadedFiles.length
const isAiThinking = useMemo(() => { const isAiThinking = useMemo(() => {
if (isSending) return true if (isSending) return true
@@ -106,21 +136,64 @@ export function IAAsignaturaTab({
} }
}, [todasConversaciones]) }, [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 =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return {
key,
label:
estructuraProps[key]?.title ||
key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}
})
: []
// 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: 'criterios_de_evaluacion',
label: 'Criterios de evaluación',
value: '',
},
]
// 3. Unimos ambos, filtrando duplicados por si acaso el backend ya los envía
const combined = [...dynamicFields]
hardcodedFields.forEach((hf) => {
if (!combined.some((f) => f.key === hf.key)) {
combined.push(hf)
}
})
return combined
}, [datosGenerales])
// --- PROCESAMIENTO DE MENSAJES ---
// --- PROCESAMIENTO DE MENSAJES --- // --- PROCESAMIENTO DE MENSAJES ---
const messages = useMemo(() => { const messages = useMemo(() => {
if (!rawMessages) return [] const msgs: Array<any> = []
return rawMessages.flatMap((m) => {
const msgs = []
// 1. Mensaje del usuario // 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 }) msgs.push({ id: `${m.id}-user`, role: 'user', content: m.mensaje })
// 2. Respuesta de la IA // Respuesta de la IA (si existe)
if (m.respuesta) { if (m.respuesta) {
// Mapeamos TODAS las recomendaciones del array
const sugerencias = const sugerencias =
m.propuesta?.recommendations?.map((rec: any, index: number) => ({ m.propuesta?.recommendations?.map((rec: any, index: number) => ({
id: `${m.id}-sug-${index}`, // ID único por sugerencia id: `${m.id}-sug-${index}`,
messageId: m.id, messageId: m.id,
campoKey: rec.campo_afectado, campoKey: rec.campo_afectado,
campoNombre: rec.campo_afectado.replace(/_/g, ' '), campoNombre: rec.campo_afectado.replace(/_/g, ' '),
@@ -132,12 +205,23 @@ export function IAAsignaturaTab({
id: `${m.id}-ai`, id: `${m.id}-ai`,
role: 'assistant', role: 'assistant',
content: m.respuesta, content: m.respuesta,
sugerencias: sugerencias, // Ahora es un plural (array) sugerencias: sugerencias,
}) })
} }
return msgs
}) })
}, [rawMessages]) }
// 2. INYECCIÓN OPTIMISTA: Si estamos enviando, mostramos el texto actual del input como mensaje de usuario
if (isSending && input.trim()) {
msgs.push({
id: 'optimistic-user-msg',
role: 'user',
content: input,
})
}
return msgs
}, [rawMessages, isSending, input])
// Auto-selección inicial // Auto-selección inicial
useEffect(() => { useEffect(() => {
@@ -150,6 +234,58 @@ export function IAAsignaturaTab({
} }
}, [activeChats, loadingConv]) }, [activeChats, loadingConv])
const filteredFields = useMemo(() => {
if (!showSuggestions) return availableFields
// Extraemos lo que hay después del último ':' para filtrar
const lastColonIndex = input.lastIndexOf(':')
const query = input.slice(lastColonIndex + 1).toLowerCase()
return availableFields.filter(
(f) =>
f.label.toLowerCase().includes(query) ||
f.key.toLowerCase().includes(query),
)
}, [availableFields, input, showSuggestions])
// 2. Efecto para cerrar con ESC
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShowSuggestions(false)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
// 3. Función para insertar el campo y limpiar el prompt
const handleSelectField = (field: SelectedField) => {
// 1. Agregamos al array de objetos (para tu lógica de API)
if (!selectedFields.find((f) => f.key === field.key)) {
setSelectedFields((prev) => [...prev, field])
}
// 2. Lógica de autocompletado en el texto
const lastColonIndex = input.lastIndexOf(':')
if (lastColonIndex !== -1) {
// Tomamos lo que había antes del ":" + el Nombre del Campo + un espacio
const nuevoTexto = input.slice(0, lastColonIndex) + `${field.label} `
setInput(nuevoTexto)
}
// 3. Cerramos el buscador y devolvemos el foco al textarea
setShowSuggestions(false)
// Opcional: Si tienes una ref del textarea, puedes hacer:
// textareaRef.current?.focus()
}
const handleSaveName = (id: string) => {
if (tempName.trim()) {
updateName({ id, nombre: tempName })
}
setEditingId(null)
}
const handleSend = async (promptOverride?: string) => { const handleSend = async (promptOverride?: string) => {
const text = promptOverride || input const text = promptOverride || input
if (!text.trim() && selectedFields.length === 0) return if (!text.trim() && selectedFields.length === 0) return
@@ -169,7 +305,7 @@ export function IAAsignaturaTab({
} }
setInput('') setInput('')
setSelectedFields([]) // setSelectedFields([])
// Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo) // Invalidamos la lista de conversaciones para que el nuevo chat aparezca en el historial (panel izquierdo)
queryClient.invalidateQueries({ queryClient.invalidateQueries({
@@ -190,18 +326,6 @@ export function IAAsignaturaTab({
) )
} }
const availableFields = useMemo(() => {
if (!datosGenerales?.datos) return []
const estructuraProps =
datosGenerales?.estructuras_asignatura?.definicion?.properties || {}
return Object.keys(datosGenerales.datos).map((key) => ({
key,
label:
estructuraProps[key]?.title || key.replace(/_/g, ' ').toUpperCase(),
value: String(datosGenerales.datos[key] || ''),
}))
}, [datosGenerales])
const createNewChat = () => { const createNewChat = () => {
setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend setActiveChatId(undefined) // Al ser undefined, el próximo mensaje creará la charla en el backend
setInput('') setInput('')
@@ -253,11 +377,8 @@ export function IAAsignaturaTab({
<Button <Button
onClick={() => { onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined) setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true) setIsCreatingNewChat(true)
setInput('') setInput('')
setSelectedFields([]) setSelectedFields([])
@@ -271,47 +392,117 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat <MessageSquarePlus size={18} /> Nuevo Chat
</Button> </Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1"> <ScrollArea className="flex-1">
<div className="space-y-1 pr-3"> <div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => ( {(showArchived ? archivedChats : activeChats).map((chat: any) => (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div <div
key={chat.id} key={chat.id}
onClick={() => {
setActiveChatId(chat.id)
setIsCreatingNewChat(false) // <--- Volvemos al modo normal
}}
className={cn( className={cn(
'group relative flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all', // 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 activeChatId === chat.id
? 'bg-teal-50 font-medium text-teal-900' ? 'bg-teal-50 text-teal-900'
: 'text-slate-600 hover:bg-slate-100', : 'text-slate-600 hover:bg-slate-100',
)} )}
onDoubleClick={() => {
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || 'Conversacion')
}}
> >
<FileText size={14} className="shrink-0 opacity-50" /> {editingId === chat.id ? (
<span className="flex-1 truncate"> <div className="flex min-w-0 flex-1 items-center">
{chat.titulo || 'Conversación'} <input
autoFocus
className="w-full rounded border-none bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
}}
/>
</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"
title={chat.nombre || chat.titulo}
>
{chat.nombre || chat.titulo || 'Conversación'}
</span> </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',
)}
>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
updateStatus( setEditingId(chat.id)
{ setTempName(chat.nombre || chat.titulo || '')
id: chat.id,
estado: showArchived ? 'ACTIVA' : 'ARCHIVADA',
},
{
onSuccess: () =>
queryClient.invalidateQueries({
queryKey: ['conversation-by-subject'],
}),
},
)
}} }}
className="rounded p-1 opacity-0 group-hover:opacity-100 hover:bg-slate-200" className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
> >
<Archive size={12} /> <Edit2 size={14} />
</button> </button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation()
const nuevoEstado =
chat.estado === 'ACTIVA'
? 'ARCHIVADA'
: 'ACTIVA'
updateStatus({
id: chat.id,
estado: nuevoEstado,
})
}}
className={cn(
'rounded-md p-1 transition-colors hover:bg-slate-200',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={14} />
) : (
<History size={14} className="scale-x-[-1]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
{chat.estado === 'ACTIVA'
? 'Archivar'
: 'Desarchivar'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</>
)}
</div> </div>
))} ))}
</div> </div>
@@ -320,10 +511,22 @@ export function IAAsignaturaTab({
{/* PANEL CENTRAL */} {/* PANEL CENTRAL */}
<div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm"> <div className="relative flex min-w-0 flex-[3] flex-col overflow-hidden rounded-xl border border-slate-200 bg-slate-50/50 shadow-sm">
<div className="shrink-0 border-b bg-white p-3"> <div className="flex shrink-0 items-center justify-between border-b bg-white p-3">
<span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase"> <span className="text-[10px] font-bold tracking-wider text-slate-400 uppercase">
Asistente IA Asistente IA
</span> </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"
>
<FileText size={14} className="text-slate-500" />
Referencias
{totalReferencias > 0 && (
<span className="animate-in zoom-in flex h-4 min-w-[16px] items-center justify-center rounded-full bg-teal-600 px-1 text-[10px] text-white">
{totalReferencias}
</span>
)}
</button>
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
@@ -370,6 +573,7 @@ export function IAAsignaturaTab({
> >
{/* Texto del mensaje principal */} {/* Texto del mensaje principal */}
<div <div
style={{ whiteSpace: 'pre-line' }}
className={cn( className={cn(
'text-sm leading-relaxed', 'text-sm leading-relaxed',
msg.role === 'assistant' && 'p-4', msg.role === 'assistant' && 'p-4',
@@ -386,62 +590,52 @@ export function IAAsignaturaTab({
<p className="mb-1 text-[10px] font-bold text-slate-400 uppercase"> <p className="mb-1 text-[10px] font-bold text-slate-400 uppercase">
Mejoras disponibles: Mejoras disponibles:
</p> </p>
{msg.sugerencias.map((sug: any) => {msg.sugerencias.map((sug: any) => (
sug.aceptada ? ( <ImprovementCard
/* --- ESTADO: YA APLICADO (Basado en tu última imagen) --- */
<div
key={sug.id} key={sug.id}
className="group flex flex-col rounded-xl border border-slate-100 bg-white p-3 shadow-sm transition-all" sug={sug}
> asignaturaId={asignaturaId}
<div className="mb-3 flex items-center justify-between gap-4"> onApplied={(campoFinalizado) => {
<span className="text-sm font-bold text-slate-800"> // Filtramos el array para conservar todos MENOS el que se aplicó
{sug.campoNombre} console.log(campoFinalizado)
</span> console.log('campos:', selectedFields)
{/* Badge de Aplicado */} setSelectedFields((prev) =>
<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"> prev.filter((fieldObj) => {
<Check size={14} /> // Accedemos a .key porque fieldObj es { key: "...", label: "..." }
Aplicado return fieldObj.key !== campoFinalizado
</div> }),
</div> )
}}
<div className="rounded-lg border border-teal-100 bg-teal-50/30 p-3 text-xs leading-relaxed text-slate-500"> />
"{sug.valorSugerido}" ))}
</div>
</div>
) : (
/* --- ESTADO: PENDIENTE POR APLICAR --- */
<div
key={sug.id}
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="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">
{sug.campoNombre}
</span>
<Button
size="sm"
className="h-8 w-auto bg-teal-600 px-4 text-xs font-semibold shadow-sm transition-colors hover:bg-teal-700"
onClick={() => onAcceptSuggestion(sug)}
>
<Check size={14} className="mr-1.5" />
Aplicar mejora
</Button>
</div>
<div className="line-clamp-4 rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600 italic">
"{sug.valorSugerido}"
</div>
</div>
),
)}
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>
))} ))}
{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">
<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="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>
</div>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}
{/* Espacio extra al final para que el scroll no tape el último mensaje */} {/* Espacio extra al final para que el scroll no tape el último mensaje */}
<div className="h-4" /> <div className="h-4" />
</div> </div>
@@ -452,39 +646,53 @@ export function IAAsignaturaTab({
<div className="shrink-0 border-t bg-white p-4"> <div className="shrink-0 border-t bg-white p-4">
<div className="relative mx-auto max-w-4xl"> <div className="relative mx-auto max-w-4xl">
{showSuggestions && ( {showSuggestions && (
<div className="animate-in slide-in-from-bottom-2 absolute bottom-full z-50 mb-2 w-72 overflow-hidden rounded-xl border bg-white shadow-2xl"> <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="border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase"> <div className="flex justify-between border-b bg-slate-50 px-3 py-2 text-[10px] font-bold text-slate-500 uppercase">
Campos de Asignatura <span>Filtrando campos...</span>
<span className="rounded bg-slate-200 px-1 text-[9px] text-slate-400">
ESC para cerrar
</span>
</div> </div>
<div className="max-h-64 overflow-y-auto p-1"> <div className="max-h-60 overflow-y-auto p-1">
{availableFields.map((field) => ( {filteredFields.length > 0 ? (
filteredFields.map((field) => (
<button <button
key={field.key} key={field.key}
onClick={() => toggleField(field)} onClick={() => handleSelectField(field)}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-colors hover:bg-teal-50" className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-teal-50"
> >
<span className="text-slate-700">{field.label}</span> <div className="flex flex-col">
<span className="font-medium text-slate-700">
{field.label}
</span>
</div>
{selectedFields.find((f) => f.key === field.key) && ( {selectedFields.find((f) => f.key === field.key) && (
<Check size={14} className="text-teal-600" /> <Check size={14} className="text-teal-600" />
)} )}
</button> </button>
))} ))
) : (
<div className="p-4 text-center text-xs text-slate-400 italic">
No se encontraron coincidencias
</div>
)}
</div> </div>
</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"> <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 && ( {selectedFields.length > 0 && (
<div className="flex flex-wrap gap-2 px-2 pt-1"> <div className="flex flex-wrap gap-1.5 px-2 pt-1">
{selectedFields.map((field) => ( {selectedFields.map((field) => (
<div <div
key={field.key} 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 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} {field.label}
<button <button
onClick={() => toggleField(field)} onClick={() => toggleField(field)}
className="ml-1 rounded-full p-0.5 hover:bg-teal-200" className="ml-1 rounded-full p-0.5 transition-colors hover:bg-teal-200/50"
> >
<X size={10} /> <X size={10} />
</button> </button>
@@ -547,6 +755,41 @@ export function IAAsignaturaTab({
))} ))}
</div> </div>
</div> </div>
{/* --- DRAWER DE REFERENCIAS --- */}
<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">
Referencias para la IA
</h2>
<button
onClick={() => setOpenIA(false)}
className="text-slate-400 hover:text-slate-600"
>
<X size={18} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<ReferenciasParaIA
selectedArchivoIds={selectedArchivoIds}
selectedRepositorioIds={selectedRepositorioIds}
uploadedFiles={uploadedFiles}
onToggleArchivo={(id, checked) => {
setSelectedArchivoIds((prev) =>
checked ? [...prev, id] : prev.filter((a) => a !== id),
)
}}
onToggleRepositorio={(id, checked) => {
setSelectedRepositorioIds((prev) =>
checked ? [...prev, id] : prev.filter((r) => r !== id),
)
}}
onFilesChange={(files) => setUploadedFiles(files)}
/>
</div>
</DrawerContent>
</Drawer>
</div> </div>
) )
} }

View File

@@ -0,0 +1,208 @@
import { Check, Loader2, BookOpen, Clock, ListChecks } from 'lucide-react'
import { useState } from 'react'
import type { IASugerencia } from '@/types/asignatura'
import { Button } from '@/components/ui/button'
import {
useUpdateAsignatura,
useSubject,
useUpdateSubjectRecommendation,
} from '@/data'
import { cn } from '@/lib/utils'
interface ImprovementCardProps {
sug: IASugerencia
asignaturaId: string
onApplied: (campoKey: string) => void
}
export function ImprovementCard({
sug,
asignaturaId,
onApplied,
}: ImprovementCardProps) {
const { data: asignatura } = useSubject(asignaturaId)
const updateAsignatura = useUpdateAsignatura()
const updateRecommendation = useUpdateSubjectRecommendation()
const [isApplying, setIsApplying] = useState(false)
const handleApply = async () => {
if (!asignatura) return
setIsApplying(true)
try {
// 1. Identificar a qué columna debe ir el guardado
let patchData = {}
if (sug.campoKey === 'contenido_tematico') {
// Se guarda directamente en la columna contenido_tematico
patchData = { contenido_tematico: sug.valorSugerido }
} else if (sug.campoKey === 'criterios_de_evaluacion') {
// Se guarda directamente en la columna criterios_de_evaluacion
patchData = { criterios_de_evaluacion: sug.valorSugerido }
} else {
// Otros campos (ciclo, fines, etc.) se siguen guardando en el JSON de la columna 'datos'
patchData = {
datos: {
...asignatura.datos,
[sug.campoKey]: sug.valorSugerido,
},
}
}
// 2. Ejecutar la actualización con la estructura correcta
await updateAsignatura.mutateAsync({
asignaturaId: asignaturaId as any,
patch: patchData as any,
})
// 3. Marcar la recomendación como aplicada
await updateRecommendation.mutateAsync({
mensajeId: sug.messageId,
campoAfectado: sug.campoKey,
})
console.log(sug.campoKey)
onApplied(sug.campoKey)
} catch (error) {
console.error('Error al aplicar mejora:', error)
} finally {
setIsApplying(false)
}
}
// --- FUNCIÓN PARA RENDERIZAR EL CONTENIDO DE FORMA SEGURA ---
const renderContenido = (valor: any) => {
// Si no es un array, es texto simple
if (!Array.isArray(valor)) {
return <p className="italic">"{String(valor)}"</p>
}
// --- CASO 1: CONTENIDO TEMÁTICO (Detectamos si el primer objeto tiene 'unidad') ---
if (valor[0]?.hasOwnProperty('unidad')) {
return (
<div className="space-y-3">
{valor.map((u: any, idx: number) => (
<div
key={idx}
className="rounded-md border border-teal-100 bg-white 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">
<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"
>
<span className="leading-tight"> {t.nombre}</span>
<span className="flex shrink-0 items-center gap-0.5 font-mono text-slate-400">
<Clock size={10} /> {t.horasEstimadas}h
</span>
</li>
))}
</ul>
</div>
))}
</div>
)
}
// --- CASO 2: CRITERIOS DE EVALUACIÓN (Detectamos si tiene 'criterio') ---
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">
<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"
>
<span className="text-[11px] leading-tight text-slate-700">
{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">
{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">
Total:{' '}
{valor.reduce(
(acc: number, curr: any) => acc + (curr.porcentaje || 0),
0,
)}
%
</div>
</div>
)
}
// Caso por defecto (Array genérico)
return (
<pre className="text-[10px]">
{/* JSON.stringify(valor, null, 2)*/ 'hola'}
</pre>
)
}
// --- 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="mb-3 flex items-center justify-between gap-4">
<span className="text-sm font-bold text-slate-800">
{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">
<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">
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}
// --- 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="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">
{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"
onClick={handleApply}
>
{isApplying ? (
<Loader2 size={14} className="mr-1.5 animate-spin" />
) : (
<Check size={14} className="mr-1.5" />
)}
{isApplying ? 'Aplicando...' : 'Aplicar mejora'}
</Button>
</div>
<div
className={cn(
'rounded-lg border border-dashed border-slate-200 bg-slate-50/50 p-3 text-xs leading-relaxed text-slate-600',
!Array.isArray(sug.valorSugerido) && 'line-clamp-4 italic',
)}
>
{renderContenido(sug.valorSugerido)}
</div>
</div>
)
}

View File

@@ -0,0 +1,43 @@
import * as React from "react"
import { CircleIcon } from "lucide-react"
import { RadioGroup as RadioGroupPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"aspect-square size-4 shrink-0 rounded-full border border-input text-primary shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 fill-primary" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View File

@@ -5,16 +5,24 @@ export function WizardResponsiveHeader({
wizard, wizard,
methods, methods,
titleOverrides, titleOverrides,
hiddenStepIds,
}: { }: {
wizard: any wizard: any
methods: any methods: any
titleOverrides?: Record<string, string> titleOverrides?: Record<string, string>
hiddenStepIds?: Array<string>
}) { }) {
const idx = wizard.utils.getIndex(methods.current.id) const hidden = new Set(hiddenStepIds ?? [])
const totalSteps = wizard.steps.length const visibleSteps = (wizard.steps as Array<any>).filter(
const currentIndex = idx + 1 (s) => s && !hidden.has(s.id),
const hasNextStep = idx < totalSteps - 1 )
const nextStep = wizard.steps[currentIndex]
const idx = visibleSteps.findIndex((s) => s.id === methods.current.id)
const safeIdx = idx >= 0 ? idx : 0
const totalSteps = visibleSteps.length
const currentIndex = Math.min(safeIdx + 1, totalSteps)
const hasNextStep = safeIdx < totalSteps - 1
const nextStep = visibleSteps[safeIdx + 1]
const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title const resolveTitle = (step: any) => titleOverrides?.[step?.id] ?? step?.title
@@ -45,10 +53,11 @@ export function WizardResponsiveHeader({
<div className="hidden sm:block"> <div className="hidden sm:block">
<wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2"> <wizard.Stepper.Navigation className="border-border/60 rounded-xl border bg-slate-50 p-2">
{wizard.steps.map((step: any) => ( {visibleSteps.map((step: any, visibleIdx: number) => (
<wizard.Stepper.Step <wizard.Stepper.Step
key={step.id} key={step.id}
of={step.id} of={step.id}
icon={visibleIdx + 1}
className="whitespace-nowrap" className="whitespace-nowrap"
> >
<wizard.Stepper.Title> <wizard.Stepper.Title>

View File

@@ -359,3 +359,19 @@ export async function update_subject_conversation_status(
if (error) throw error if (error) throw error
return data return data
} }
export async function update_subject_conversation_name(
conversacionId: string,
nuevoNombre: string,
) {
const supabase = supabaseBrowser()
const { data, error } = await supabase
.from('conversaciones_asignatura')
.update({ nombre: nuevoNombre }) // Asumiendo que la columna es 'titulo' según tu código previo, o cambia a 'nombre'
.eq('id', conversacionId)
.select()
.single()
if (error) throw error
return data
}

View File

@@ -232,7 +232,7 @@ export async function subjects_bibliografia_list(
const { data, error } = await supabase const { data, error } = await supabase
.from('bibliografia_asignatura') .from('bibliografia_asignatura')
.select( .select(
'id,asignatura_id,tipo,cita,tipo_fuente,biblioteca_item_id,creado_por,creado_en,actualizado_en', 'id,asignatura_id,tipo,cita,referencia_biblioteca,referencia_en_linea,creado_por,creado_en,actualizado_en',
) )
.eq('asignatura_id', subjectId) .eq('asignatura_id', subjectId)
.order('tipo', { ascending: true }) .order('tipo', { ascending: true })

View File

@@ -19,6 +19,7 @@ import {
getConversationBySubject, getConversationBySubject,
ai_subject_chat_v2, ai_subject_chat_v2,
create_subject_conversation, create_subject_conversation,
update_subject_conversation_name,
} from '../api/ai.api' } from '../api/ai.api'
import { supabaseBrowser } from '../supabase/client' import { supabaseBrowser } from '../supabase/client'
@@ -320,3 +321,17 @@ export function useUpdateSubjectConversationStatus() {
}, },
}) })
} }
export function useUpdateSubjectConversationName() {
const qc = useQueryClient()
return useMutation({
mutationFn: (payload: { id: string; nombre: string }) =>
update_subject_conversation_name(payload.id, payload.nombre),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['conversation-by-subject'] })
// También invalidamos los mensajes si el título se muestra en la cabecera
qc.invalidateQueries({ queryKey: ['subject-messages'] })
},
})
}

View File

@@ -13,8 +13,8 @@ import {
X, X,
MessageSquarePlus, MessageSquarePlus,
Archive, Archive,
RotateCcw,
Loader2, Loader2,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, useRef, useMemo } from 'react' import { useState, useEffect, useRef, useMemo } from 'react'
@@ -22,10 +22,17 @@ import type { UploadedFile } from '@/components/planes/wizard/PasoDetallesPanel/
import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard' import { ImprovementCard } from '@/components/planes/detalle/Ia/ImprovementCard'
import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA' import ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer' import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area' import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { import {
useAIPlanChat, useAIPlanChat,
useConversationByPlan, useConversationByPlan,
@@ -227,7 +234,7 @@ function RouteComponent() {
scrollToBottom() scrollToBottom()
}, [chatMessages, isLoading]) }, [chatMessages, isLoading])
useEffect(() => { /* useEffect(() => {
// Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input // Verificamos cuáles campos de la lista "selectedFields" ya no están presentes en el texto del input
const camposActualizados = selectedFields.filter((field) => const camposActualizados = selectedFields.filter((field) =>
input.includes(field.label), input.includes(field.label),
@@ -237,7 +244,7 @@ function RouteComponent() {
if (camposActualizados.length !== selectedFields.length) { if (camposActualizados.length !== selectedFields.length) {
setSelectedFields(camposActualizados) setSelectedFields(camposActualizados)
} }
}, [input, selectedFields]) }, [input, selectedFields]) */
useEffect(() => { useEffect(() => {
if (isLoadingConv || isSending) return if (isLoadingConv || isSending) return
@@ -297,7 +304,7 @@ function RouteComponent() {
}, },
]) ])
setInput('') setInput('')
setSelectedFields([]) // setSelectedFields([])
} }
const archiveChat = (e: React.MouseEvent, id: string) => { const archiveChat = (e: React.MouseEvent, id: string) => {
@@ -405,7 +412,7 @@ function RouteComponent() {
setIsSending(true) setIsSending(true)
setOptimisticMessage(finalContent) setOptimisticMessage(finalContent)
setInput('') setInput('')
setSelectedFields([]) // setSelectedFields([])
try { try {
const payload = { const payload = {
@@ -507,21 +514,30 @@ function RouteComponent() {
<div <div
key={chat.id} key={chat.id}
onClick={() => setActiveChatId(chat.id)} onClick={() => setActiveChatId(chat.id)}
className={`group relative flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-3 text-sm transition-colors ${ className={`group relative flex w-full items-center justify-between overflow-hidden rounded-lg px-3 py-3 text-sm transition-colors ${
activeChatId === chat.id activeChatId === chat.id
? 'bg-slate-100 font-medium text-slate-900' ? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50' : 'text-slate-600 hover:bg-slate-50'
}`} }`}
> >
{/* LADO IZQUIERDO: Icono + Texto con Tooltip */}
<div className="flex min-w-0 flex-1 items-center gap-3">
<FileText size={16} className="shrink-0 opacity-40" /> <FileText size={16} className="shrink-0 opacity-40" />
<TooltipProvider delayDuration={400}>
<Tooltip>
<TooltipTrigger asChild>
{/* Este contenedor es el que obliga al span a truncarse */}
<div className="max-w-[calc(100%-48px)] min-w-0 flex-1">
<span <span
ref={editingChatId === chat.id ? editableRef : null} ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id} contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${ className={`block truncate outline-none ${
editingChatId === chat.id editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500' ? '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'
: 'cursor-pointer' : 'cursor-pointer'
}`} }`}
onDoubleClick={(e) => { onDoubleClick={(e) => {
@@ -531,52 +547,66 @@ function RouteComponent() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() e.preventDefault()
const newTitle = e.currentTarget.textContent || '' e.currentTarget.blur()
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
} }
if (e.key === 'Escape') { if (e.key === 'Escape') {
setEditingChatId(null) setEditingChatId(null)
e.currentTarget.textContent =
e.currentTarget.textContent = chat.nombre || '' chat.nombre || ''
} }
}} }}
onBlur={(e) => { onBlur={(e) => {
if (editingChatId === chat.id) { if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || '' const newTitle =
if (newTitle !== chat.nombre) { e.currentTarget.textContent?.trim() || ''
updateTitleMutation({ id: chat.id, nombre: newTitle }) if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
} }
setEditingChatId(null) setEditingChatId(null)
} }
}} }}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
> >
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`} {chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
</TooltipTrigger>
{/* ACCIONES */} {/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100"> {editingChatId !== chat.id && (
<TooltipContent
side="right"
className="max-w-[280px] break-all"
>
{chat.nombre || 'Conversación'}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</div>
{/* LADO DERECHO: Acciones con shrink-0 para que no se muevan */}
<div
className={`flex shrink-0 items-center gap-1 pl-2 opacity-0 transition-opacity group-hover:opacity-100 ${
activeChatId === chat.id ? 'bg-slate-100' : 'bg-slate-50'
}`}
>
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingChatId(chat.id) setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
setTimeout(() => editableRef.current?.focus(), 50) setTimeout(() => editableRef.current?.focus(), 50)
}} }}
className="p-1 text-slate-400 hover:text-teal-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-teal-600"
> >
<Send size={12} className="rotate-45" /> <Send size={12} className="rotate-45" />
</button> </button>
<button <button
onClick={(e) => archiveChat(e, chat.id)} onClick={(e) => archiveChat(e, chat.id)}
className="p-1 text-slate-400 hover:text-amber-600" className="rounded-md p-1 text-slate-400 transition-colors hover:text-amber-600"
> >
<Archive size={14} /> <Archive size={14} />
</button> </button>
@@ -584,24 +614,26 @@ function RouteComponent() {
</div> </div>
)) ))
) : ( ) : (
/* ... Resto del código de archivados (sin cambios) ... */ /* Sección de archivados */
<div className="animate-in fade-in slide-in-from-left-2"> <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="mb-2 px-2 text-[10px] font-bold text-slate-400 uppercase">
Archivados Archivados
</p> </p>
{archivedChats.map((chat) => ( {archivedChats.map((chat) => (
<div <div
key={chat.id} key={chat.id}
className="group relative mb-1 flex w-full items-center gap-3 rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400" className="group relative mb-1 flex w-full items-center justify-between overflow-hidden rounded-lg bg-slate-50/50 px-3 py-2 text-sm text-slate-400"
> >
<div className="flex min-w-0 flex-1 items-center gap-3">
<Archive size={14} className="shrink-0 opacity-30" /> <Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8"> <span className="block min-w-0 flex-1 truncate">
{chat.nombre || {chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`} `Archivado ${chat.creado_en.split('T')[0]}`}
</span> </span>
</div>
<button <button
onClick={(e) => unarchiveChat(e, chat.id)} onClick={(e) => unarchiveChat(e, chat.id)}
className="absolute right-2 p-1 opacity-0 group-hover:opacity-100 hover:text-teal-600" className="ml-2 shrink-0 rounded bg-slate-50/80 p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:text-teal-600"
> >
<RotateCcw size={14} /> <RotateCcw size={14} />
</button> </button>
@@ -721,35 +753,26 @@ function RouteComponent() {
) )
})} })}
{(isSending || isSyncing) &&
optimisticMessage &&
!chatMessages.some(
(m) => m.content === optimisticMessage,
) && (
<div className="animate-in fade-in slide-in-from-right-2 ml-auto flex max-w-[85%] flex-col items-end">
<div className="rounded-2xl rounded-tr-none bg-teal-600/70 p-3 text-sm whitespace-pre-wrap text-white shadow-sm">
{optimisticMessage}
</div>
</div>
)}
{(isSending || isSyncing) && ( {(isSending || isSyncing) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300"> <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">
<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="rounded-2xl rounded-tl-none border border-slate-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2">
<div className="flex gap-1"> <div className="flex gap-1">
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-teal-500 [animation-delay:-0.3s]" /> <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-teal-500 [animation-delay:-0.15s]" /> <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-teal-500" /> <span className="h-1.5 w-1.5 animate-bounce rounded-full bg-slate-400"></span>
</div> </div>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase"> </div>
{isSyncing <span className="text-[10px] font-medium text-slate-400 italic">
? 'Actualizando historial...' La IA está analizando tu solicitud...
: 'Esperando respuesta...'}
</span> </span>
</div> </div>
</div> </div>
</div>
)} )}
</> </>
)} )}

File diff suppressed because it is too large Load Diff