6 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
6 changed files with 646 additions and 322 deletions

View File

@@ -4,6 +4,7 @@
"": {
"name": "acad-ia-2",
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
@@ -138,6 +139,18 @@
"@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/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=="],
"@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/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"
},
"dependencies": {
"@dnd-kit/react": "^0.3.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@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 {
Plus,
@@ -11,7 +13,7 @@ import {
import { useEffect, useRef, useState } from 'react'
import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api'
import type { FocusEvent, KeyboardEvent } from 'react'
import type { FocusEvent, KeyboardEvent, ReactNode } from 'react'
import {
AlertDialog,
@@ -50,6 +52,95 @@ export interface UnidadTematica {
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> {
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)) {
temas = value.temas
.map(mapTemaValue)
.filter((t): t is ContenidoTemaApi => t !== null)
} else if (typeof value.temas === 'string' && value.temas.trim()) {
temas = value.temas
.split(/\r?\n|,/)
.map((t) => t.trim())
.filter(Boolean)
.filter((x): x is ContenidoTemaApi => x !== null)
}
return { unidad, titulo, temas }
return {
...value,
unidad,
titulo,
temas,
}
}
function mapContenidoTematicoFromDb(value: unknown): Array<ContenidoApi> {
if (value == null) return []
if (typeof value === 'string') {
try {
return mapContenidoTematicoFromDb(JSON.parse(value))
@@ -192,7 +281,16 @@ export function ContenidoTematico() {
const [temaDraftHoras, setTemaDraftHoras] = useState('')
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>) => {
// A partir del primer guardado, ya respetamos lo que el usuario deje expandido.
didInitExpandedUnitsRef.current = true
const payload = serializeUnidadesToApi(nextUnidades)
await updateContenido.mutateAsync({
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 = () => {
if (!editingTema) return
const parsedHoras = Number.parseInt(temaDraftHoras, 10)
const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0
const horasEstimadas = parseHorasEstimadas(temaDraftHoras)
const next = unidades.map((u) => {
if (u.id !== editingTema.unitId) return u
@@ -303,28 +408,110 @@ export function ContenidoTematico() {
data ? data.contenido_tematico : undefined,
)
const transformed = contenido.map((u, idx) => ({
id: `u-${u.unidad || idx + 1}`,
numero: u.unidad || idx + 1,
nombre: u.titulo || 'Sin título',
temas: Array.isArray(u.temas)
? u.temas.map((t: any, tidx: number) => ({
id: `t-${u.unidad || idx + 1}-${tidx + 1}`,
nombre: typeof t === 'string' ? t : t?.nombre || 'Tema',
horasEstimadas: t?.horasEstimadas || 0,
}))
: [],
}))
// 1. EL ESCUDO: Comparamos si nuestro estado local ya tiene esta info exacta
// (Esto ocurre justo después de arrastrar, ya que actualizamos la UI antes que la BD)
const currentPayload = JSON.stringify(
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)
? u.temas.map((t) => {
if (typeof t === 'string') {
return {
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)
// Mantener las unidades ya expandidas si existen; si no, expandir la primera.
setExpandedUnits((prev) => {
const validIds = new Set(transformed.map((u) => u.id))
const filtered = new Set(
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])
@@ -353,7 +540,7 @@ export function ContenidoTematico() {
// 3. Cálculo de horas (ahora dinámico basado en los nuevos datos)
const totalHoras = unidades.reduce(
(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,
)
@@ -364,16 +551,22 @@ export function ContenidoTematico() {
setExpandedUnits(newExpanded)
}
const addUnidad = () => {
const newNumero = unidades.length + 1
const newId = `u-${newNumero}`
const insertUnidadAt = (insertIndex: number) => {
const newId = createClientId('u')
const newUnidad: UnidadTematica = {
id: newId,
nombre: 'Nueva Unidad',
numero: newNumero,
numero: 0,
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)
setExpandedUnits((prev) => {
const n = new Set(prev)
@@ -382,10 +575,40 @@ export function ContenidoTematico() {
})
setPendingScrollUnitId(newId)
// Abrir edición del título inmediatamente
setEditingUnit(newId)
setUnitDraftNombre(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 ---
@@ -451,158 +674,176 @@ export function ContenidoTematico() {
</div>
</div>
<div className="space-y-4">
{unidades.map((unidad) => (
<div
key={unidad.id}
ref={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
>
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 cursor-grab text-slate-300" />
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-auto p-0">
{expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
<DragDropProvider onDragEnd={handleReorderEnd}>
<div className="space-y-4">
{unidades.map((unidad, index) => (
<SortableUnidad
key={unidad.id}
id={unidad.id}
index={index}
registerContainer={(el) => {
if (el) unitContainerRefs.current.set(unidad.id, el)
else unitContainerRefs.current.delete(unidad.id)
}}
>
{({ handleRef }) => (
<>
<InsertUnidadOverlay
position="bottom"
onInsert={() => insertUnidadAt(index + 1)}
/>
{editingUnit === unidad.id ? (
<Input
ref={unitTitleInputRef}
value={unitDraftNombre}
onChange={(e) => setUnitDraftNombre(e.target.value)}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
commitEditUnit()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white"
/>
) : (
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<Collapsible
open={expandedUnits.has(unidad.id)}
onOpenChange={() => toggleUnit(unidad.id)}
>
<CardHeader className="border-b border-slate-100 bg-slate-50/50 py-3">
<div className="flex items-center gap-3">
<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>
<Button
variant="ghost"
size="sm"
className="h-auto cursor-pointer p-0"
>
{expandedUnits.has(unidad.id) ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<Badge className="bg-blue-600 font-mono">
Unidad {unidad.numero}
</Badge>
<div className="ml-auto flex items-center gap-3">
<span className="flex items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-500"
onClick={() =>
setDeleteDialog({ type: 'unidad', id: unidad.id })
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={
!!editingTema &&
editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
}
draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras}
onBeginEdit={() => beginEditTema(unidad.id, tema.id)}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={handleTemaEditorBlurCapture}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</div>
))}
</div>
{editingUnit === unidad.id ? (
<Input
ref={unitTitleInputRef}
value={unitDraftNombre}
onChange={(e) =>
setUnitDraftNombre(e.target.value)
}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false
return
}
commitEditUnit()
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
return
}
if (e.key === 'Escape') {
e.preventDefault()
cancelNextBlurRef.current = true
cancelEditUnit()
e.currentTarget.blur()
}
}}
className="h-8 max-w-md bg-white"
/>
) : (
<CardTitle
className="cursor-pointer text-base font-semibold transition-colors hover:text-blue-600"
onClick={() => beginEditUnit(unidad.id)}
>
{unidad.nombre}
</CardTitle>
)}
<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>
<div className="ml-auto flex items-center gap-3">
<span className="flex cursor-default items-center gap-1 text-xs font-medium text-slate-400">
<Clock className="h-3 w-3" />{' '}
{unidad.temas.reduce(
(sum, t) => sum + (t.horasEstimadas || 0),
0,
)}
h
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer text-slate-400 hover:text-red-500"
onClick={() =>
setDeleteDialog({
type: 'unidad',
id: unidad.id,
})
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CollapsibleContent>
<CardContent className="bg-white pt-4">
<div className="ml-10 space-y-1 border-l-2 border-slate-50 pl-4">
{unidad.temas.map((tema, idx) => (
<TemaRow
key={tema.id}
tema={tema}
index={idx + 1}
isEditing={
!!editingTema &&
editingTema.unitId === unidad.id &&
editingTema.temaId === tema.id
}
draftNombre={temaDraftNombre}
draftHoras={temaDraftHoras}
onBeginEdit={() =>
beginEditTema(unidad.id, tema.id)
}
onDraftNombreChange={setTemaDraftNombre}
onDraftHorasChange={setTemaDraftHoras}
onEditorBlurCapture={
handleTemaEditorBlurCapture
}
onEditorKeyDownCapture={
handleTemaEditorKeyDownCapture
}
onNombreInputRef={(el) => {
temaNombreInputElRef.current = el
}}
onDelete={() =>
setDeleteDialog({
type: 'tema',
id: tema.id,
parentId: unidad.id,
})
}
/>
))}
<Button
variant="ghost"
size="sm"
className="mt-2 w-full cursor-pointer justify-start text-blue-600 hover:bg-blue-50 hover:text-blue-700"
onClick={() => addTema(unidad.id)}
>
<Plus className="mr-2 h-3 w-3" /> Añadir subtema
</Button>
</div>
</CardContent>
</CollapsibleContent>
</Collapsible>
</Card>
</>
)}
</SortableUnidad>
))}
</div>
</DragDropProvider>
<DeleteConfirmDialog
dialog={deleteDialog}
@@ -667,6 +908,9 @@ function TemaRow({
<Input
type="number"
value={draftHoras}
min={0}
max={200}
step={0.5}
onChange={(e) => onDraftHorasChange(e.target.value)}
className="h-8 w-16 bg-white"
/>
@@ -675,7 +919,7 @@ function TemaRow({
<>
<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) => {
e.stopPropagation()
onBeginEdit()
@@ -690,7 +934,7 @@ function TemaRow({
<Button
variant="ghost"
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) => {
e.stopPropagation()
onBeginEdit()
@@ -701,7 +945,7 @@ function TemaRow({
<Button
variant="ghost"
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) => {
e.stopPropagation()
onDelete()

View File

@@ -28,6 +28,12 @@ import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAISubjectChat,
useConversationBySubject,
@@ -371,11 +377,8 @@ export function IAAsignaturaTab({
<Button
onClick={() => {
// 1. Limpiamos el ID
setActiveChatId(undefined)
// 2. Marcamos que ya hubo una "interacción inicial" para que el useEffect no actúe
hasInitialSelected.current = true
// 3. Limpiamos estados visuales
setIsCreatingNewChat(true)
setInput('')
setSelectedFields([])
@@ -389,29 +392,34 @@ export function IAAsignaturaTab({
<MessageSquarePlus size={18} /> Nuevo Chat
</Button>
{/* PANEL IZQUIERDO - Cambios en ScrollArea y contenedor */}
<ScrollArea className="flex-1">
<div className="space-y-1 pr-3">
{/* CORRECCIÓN: Mapear ambos casos */}
<div className="flex flex-col gap-1 pr-3">
{' '}
{/* Eliminado space-y-1 para mejor control con gap */}
{(showArchived ? archivedChats : activeChats).map((chat: any) => (
<div
key={chat.id}
className={cn(
'group relative flex items-center gap-2 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
? 'bg-teal-50 text-teal-900'
: '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 ? (
<div className="flex flex-1 items-center gap-1">
<div className="flex min-w-0 flex-1 items-center">
<input
autoFocus
className="w-full rounded bg-white px-1 text-xs ring-1 ring-teal-400 outline-none"
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)} // Guardar al hacer clic fuera
onBlur={() => handleSaveName(chat.id)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveName(chat.id)
if (e.key === 'Escape') setEditingId(null)
@@ -420,54 +428,78 @@ 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="flex-1 cursor-pointer truncate"
className="block max-w-[140px] min-w-0 flex-1 cursor-pointer truncate pr-1"
title={chat.nombre || chat.titulo}
>
{/* CORRECCIÓN: Usar 'nombre' si así se llama en tu DB */}
{chat.nombre || chat.titulo || 'Conversación'}
</span>
<div className="flex opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="p-1 hover:text-teal-600"
>
<Edit2 size={12} />
</button>
{/* 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
onClick={(e) => {
e.stopPropagation()
setEditingId(chat.id)
setTempName(chat.nombre || chat.titulo || '')
}}
className="rounded-md p-1 transition-colors hover:bg-slate-200 hover:text-teal-600"
>
<Edit2 size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="text-[10px]">
Editar nombre
</TooltipContent>
</Tooltip>
{/* Botón para Archivar/Desarchivar dinámico */}
<button
onClick={(e) => {
e.stopPropagation()
// Si el estado actual es ACTIVA, mandamos ARCHIVADA. Si no, viceversa.
const nuevoEstado =
chat.estado === 'ACTIVA' ? 'ARCHIVADA' : 'ACTIVA'
updateStatus({ id: chat.id, estado: nuevoEstado })
}}
className={cn(
'p-1 transition-colors',
chat.estado === 'ACTIVA'
? 'hover:text-red-500'
: 'hover:text-teal-600',
)}
title={
chat.estado === 'ACTIVA'
? 'Archivar chat'
: 'Desarchivar chat'
}
>
{chat.estado === 'ACTIVA' ? (
<Archive size={12} />
) : (
/* Icono de Desarchivar */
<History size={12} className="scale-x-[-1]" />
)}
</button>
<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>
</>
)}

View File

@@ -1237,6 +1237,15 @@ export function NuevaBibliografiaModalContainer({
const ok =
formatoStepRef.current?.validateBeforeNext() ?? true
if (!ok) return
if (wizard.metodo === 'EN_LINEA' && wizard.formato) {
void generateCitasForFormato(
wizard.formato,
wizard.refs,
{
force: true,
},
)
}
}
methods.next()
}}

View File

@@ -13,8 +13,8 @@ import {
X,
MessageSquarePlus,
Archive,
RotateCcw,
Loader2,
Sparkles,
} from 'lucide-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 ReferenciasParaIA from '@/components/planes/wizard/PasoDetallesPanel/ReferenciasParaIA'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Drawer, DrawerContent } from '@/components/ui/drawer'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Textarea } from '@/components/ui/textarea'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import {
useAIPlanChat,
useConversationByPlan,
@@ -507,76 +514,99 @@ function RouteComponent() {
<div
key={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
? 'bg-slate-100 font-medium text-slate-900'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<FileText size={16} className="shrink-0 opacity-40" />
{/* 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" />
<span
ref={editingChatId === chat.id ? editableRef : null}
contentEditable={editingChatId === chat.id}
suppressContentEditableWarning={true}
className={`truncate pr-14 transition-all outline-none ${
editingChatId === chat.id
? 'min-w-[50px] cursor-text rounded bg-white px-1 ring-1 ring-teal-500'
: 'cursor-pointer'
<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
ref={
editingChatId === chat.id ? editableRef : null
}
contentEditable={editingChatId === chat.id}
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'
: 'cursor-pointer'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.currentTarget.blur()
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent =
chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle =
e.currentTarget.textContent?.trim() || ''
if (newTitle && newTitle !== chat.nombre) {
updateTitleMutation({
id: chat.id,
nombre: newTitle,
})
}
setEditingChatId(null)
}
}}
>
{chat.nombre ||
`Chat ${chat.creado_en.split('T')[0]}`}
</span>
</div>
</TooltipTrigger>
{/* Tooltip: Solo aparece si no estás editando y el texto es largo */}
{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'
}`}
onDoubleClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
const newTitle = e.currentTarget.textContent || ''
updateTitleMutation(
{ id: chat.id, nombre: newTitle },
{
onSuccess: () => setEditingChatId(null),
},
)
}
if (e.key === 'Escape') {
setEditingChatId(null)
e.currentTarget.textContent = chat.nombre || ''
}
}}
onBlur={(e) => {
if (editingChatId === chat.id) {
const newTitle = e.currentTarget.textContent || ''
if (newTitle !== chat.nombre) {
updateTitleMutation({ id: chat.id, nombre: newTitle })
}
setEditingChatId(null)
}
}}
onClick={(e) => {
if (editingChatId === chat.id) e.stopPropagation()
}}
>
{chat.nombre || `Chat ${chat.creado_en.split('T')[0]}`}
</span>
{/* ACCIONES */}
<div className="absolute right-2 flex items-center gap-1 opacity-0 group-hover:opacity-100">
<button
onClick={(e) => {
e.stopPropagation()
setEditingChatId(chat.id)
// Pequeño timeout para asegurar que el DOM se actualice antes de enfocar
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" />
</button>
<button
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} />
</button>
@@ -584,24 +614,26 @@ function RouteComponent() {
</div>
))
) : (
/* ... Resto del código de archivados (sin cambios) ... */
<div className="animate-in fade-in slide-in-from-left-2">
/* Sección de archivados */
<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">
Archivados
</p>
{archivedChats.map((chat) => (
<div
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"
>
<Archive size={14} className="shrink-0 opacity-30" />
<span className="truncate pr-8">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
<div className="flex min-w-0 flex-1 items-center gap-3">
<Archive size={14} className="shrink-0 opacity-30" />
<span className="block min-w-0 flex-1 truncate">
{chat.nombre ||
`Archivado ${chat.creado_en.split('T')[0]}`}
</span>
</div>
<button
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} />
</button>
@@ -721,33 +753,24 @@ 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) && (
<div className="animate-in fade-in slide-in-from-left-2 flex flex-col items-start duration-300">
<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="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-teal-500 [animation-delay:-0.3s]" />
<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" />
<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>
<span className="text-[10px] font-medium tracking-tight text-slate-400 uppercase">
{isSyncing
? 'Actualizando historial...'
: 'Esperando respuesta...'}
</span>
</div>
<span className="text-[10px] font-medium text-slate-400 italic">
La IA está analizando tu solicitud...
</span>
</div>
</div>
)}