From f28804bb5b7a84b0e89926be74534a8365467524 Mon Sep 17 00:00:00 2001 From: Guillermo Arrieta Medina Date: Tue, 24 Feb 2026 14:28:52 -0600 Subject: [PATCH] =?UTF-8?q?closes=20#133:=20Mejoras=20de=20usabilidad=20en?= =?UTF-8?q?=20ContenidoTem=C3=A1tico=20=E2=80=94=20edici=C3=B3n=20inmediat?= =?UTF-8?q?a=20y=20foco?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mueve el botón "+ Nueva unidad" al final de la lista y lo centra. - Al crear una unidad: hace scrollIntoView, la unidad queda expandida, el título entra en modo edición y recibe focus. - Al crear un subtema: nombre y horas quedan editables y el input del nombre recibe focus. - Click en título de unidad o en un subtema inicia la edición y pone focus en el campo correspondiente. - Elimina el botón "Listo": los cambios se guardan al pulsar Enter o perder el foco (onBlur). - Presionar Esc cancela la edición y restaura el valor anterior. - Evita el bug donde pulsar Enter tras crear una unidad añadía unidades extra (se desenfoca el botón y se dirige el foco al input correspondiente). - Persistencia inmediata: las modificaciones se guardan vía useUpdateSubjectContenido en los puntos de commit. - Conserva el estado de unidades expandidas tras las actualizaciones para evitar colapsos inesperados. --- .../asignaturas/detalle/ContenidoTematico.tsx | 542 ++++++++++++------ 1 file changed, 355 insertions(+), 187 deletions(-) diff --git a/src/components/asignaturas/detalle/ContenidoTematico.tsx b/src/components/asignaturas/detalle/ContenidoTematico.tsx index 9297b1e..8ef2d1c 100644 --- a/src/components/asignaturas/detalle/ContenidoTematico.tsx +++ b/src/components/asignaturas/detalle/ContenidoTematico.tsx @@ -8,9 +8,10 @@ import { Trash2, Clock, } from 'lucide-react' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { ContenidoApi, ContenidoTemaApi } from '@/data/api/subjects.api' +import type { FocusEvent, KeyboardEvent } from 'react' import { AlertDialog, @@ -167,16 +168,29 @@ export function ContenidoTematico() { const { data: data, isLoading: isLoading } = useSubject(asignaturaId) const [unidades, setUnidades] = useState>([]) const [expandedUnits, setExpandedUnits] = useState>(new Set()) + const unitContainerRefs = useRef>(new Map()) + const unitTitleInputRef = useRef(null) + const temaNombreInputElRef = useRef(null) + const [pendingScrollUnitId, setPendingScrollUnitId] = useState( + null, + ) + const cancelNextBlurRef = useRef(false) const [deleteDialog, setDeleteDialog] = useState<{ type: 'unidad' | 'tema' id: string parentId?: string } | null>(null) const [editingUnit, setEditingUnit] = useState(null) + const [unitDraftNombre, setUnitDraftNombre] = useState('') + const [unitOriginalNombre, setUnitOriginalNombre] = useState('') const [editingTema, setEditingTema] = useState<{ unitId: string temaId: string } | null>(null) + const [temaDraftNombre, setTemaDraftNombre] = useState('') + const [temaOriginalNombre, setTemaOriginalNombre] = useState('') + const [temaDraftHoras, setTemaDraftHoras] = useState('') + const [temaOriginalHoras, setTemaOriginalHoras] = useState(0) const persistUnidades = async (nextUnidades: Array) => { const payload = serializeUnidadesToApi(nextUnidades) @@ -186,18 +200,116 @@ export function ContenidoTematico() { }) } + const beginEditUnit = (unitId: string) => { + const unit = unidades.find((u) => u.id === unitId) + const nombre = unit?.nombre ?? '' + setEditingUnit(unitId) + setUnitDraftNombre(nombre) + setUnitOriginalNombre(nombre) + setExpandedUnits((prev) => { + const next = new Set(prev) + next.add(unitId) + return next + }) + } + + const commitEditUnit = () => { + if (!editingUnit) return + const next = unidades.map((u) => + u.id === editingUnit ? { ...u, nombre: unitDraftNombre } : u, + ) + setUnidades(next) + setEditingUnit(null) + void persistUnidades(next) + } + + const cancelEditUnit = () => { + setEditingUnit(null) + setUnitDraftNombre(unitOriginalNombre) + } + + const beginEditTema = (unitId: string, temaId: string) => { + const unit = unidades.find((u) => u.id === unitId) + const tema = unit?.temas.find((t) => t.id === temaId) + const nombre = tema?.nombre ?? '' + const horas = tema?.horasEstimadas ?? 0 + + setEditingTema({ unitId, temaId }) + setTemaDraftNombre(nombre) + setTemaOriginalNombre(nombre) + setTemaDraftHoras(String(horas)) + setTemaOriginalHoras(horas) + setExpandedUnits((prev) => { + const next = new Set(prev) + next.add(unitId) + return next + }) + } + + const commitEditTema = () => { + if (!editingTema) return + const parsedHoras = Number.parseInt(temaDraftHoras, 10) + const horasEstimadas = Number.isFinite(parsedHoras) ? parsedHoras : 0 + + const next = unidades.map((u) => { + if (u.id !== editingTema.unitId) return u + return { + ...u, + temas: u.temas.map((t) => + t.id === editingTema.temaId + ? { ...t, nombre: temaDraftNombre, horasEstimadas } + : t, + ), + } + }) + + setUnidades(next) + setEditingTema(null) + void persistUnidades(next) + } + + const cancelEditTema = () => { + setEditingTema(null) + setTemaDraftNombre(temaOriginalNombre) + setTemaDraftHoras(String(temaOriginalHoras)) + } + + const handleTemaEditorBlurCapture = (e: FocusEvent) => { + if (cancelNextBlurRef.current) { + cancelNextBlurRef.current = false + return + } + const nextFocus = e.relatedTarget as Node | null + if (nextFocus && e.currentTarget.contains(nextFocus)) return + commitEditTema() + } + + const handleTemaEditorKeyDownCapture = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + if (e.target instanceof HTMLElement) e.target.blur() + return + } + if (e.key === 'Escape') { + e.preventDefault() + cancelNextBlurRef.current = true + cancelEditTema() + if (e.target instanceof HTMLElement) e.target.blur() + } + } + useEffect(() => { const contenido = mapContenidoTematicoFromDb( data ? data.contenido_tematico : undefined, ) const transformed = contenido.map((u, idx) => ({ - id: `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-${idx}-${tidx}`, + id: `t-${u.unidad || idx + 1}-${tidx + 1}`, nombre: typeof t === 'string' ? t : t?.nombre || 'Tema', horasEstimadas: t?.horasEstimadas || 0, })) @@ -216,6 +328,25 @@ export function ContenidoTematico() { }) }, [data]) + useEffect(() => { + if (!editingUnit) return + // Foco controlado (evitamos autoFocus por lint/a11y) + setTimeout(() => unitTitleInputRef.current?.focus(), 0) + }, [editingUnit]) + + useEffect(() => { + if (!editingTema) return + setTimeout(() => temaNombreInputElRef.current?.focus(), 0) + }, [editingTema]) + + useEffect(() => { + if (!pendingScrollUnitId) return + const el = unitContainerRefs.current.get(pendingScrollUnitId) + if (!el) return + el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + setPendingScrollUnitId(null) + }, [pendingScrollUnitId, unidades.length]) + if (isLoading) return
Cargando contenido...
@@ -234,60 +365,57 @@ export function ContenidoTematico() { } const addUnidad = () => { - const newId = `u-${Date.now()}` + const newNumero = unidades.length + 1 + const newId = `u-${newNumero}` const newUnidad: UnidadTematica = { id: newId, nombre: 'Nueva Unidad', - numero: unidades.length + 1, + numero: newNumero, temas: [], } const next = [...unidades, newUnidad] setUnidades(next) - setExpandedUnits(new Set([...expandedUnits, newId])) - setEditingUnit(newId) - } + setExpandedUnits((prev) => { + const n = new Set(prev) + n.add(newId) + return n + }) + setPendingScrollUnitId(newId) - const updateUnidadNombre = (id: string, nombre: string) => { - setUnidades(unidades.map((u) => (u.id === id ? { ...u, nombre } : u))) + // Abrir edición del título inmediatamente + setEditingUnit(newId) + setUnitDraftNombre(newUnidad.nombre) + setUnitOriginalNombre(newUnidad.nombre) } // --- Lógica de Temas --- const addTema = (unidadId: string) => { - setUnidades( - unidades.map((u) => { - if (u.id === unidadId) { - const newTemaId = `t-${Date.now()}` - const newTema: Tema = { - id: newTemaId, - nombre: 'Nuevo tema', - horasEstimadas: 2, - } - setEditingTema({ unitId: unidadId, temaId: newTemaId }) - return { ...u, temas: [...u.temas, newTema] } - } - return u - }), - ) - } + const unit = unidades.find((u) => u.id === unidadId) + const unitNumero = unit?.numero ?? 0 + const newTemaIndex = (unit?.temas.length ?? 0) + 1 + const newTemaId = `t-${unitNumero}-${newTemaIndex}` + const newTema: Tema = { + id: newTemaId, + nombre: 'Nuevo tema', + horasEstimadas: 2, + } - const updateTema = ( - unidadId: string, - temaId: string, - updates: Partial, - ) => { - setUnidades( - unidades.map((u) => { - if (u.id === unidadId) { - return { - ...u, - temas: u.temas.map((t) => - t.id === temaId ? { ...t, ...updates } : t, - ), - } - } - return u - }), + const next = unidades.map((u) => + u.id === unidadId ? { ...u, temas: [...u.temas, newTema] } : u, ) + setUnidades(next) + + // Expandir unidad y poner el subtema en edición con foco en el nombre + setExpandedUnits((prev) => { + const n = new Set(prev) + n.add(unidadId) + return n + }) + setEditingTema({ unitId: unidadId, temaId: newTemaId }) + setTemaDraftNombre(newTema.nombre) + setTemaOriginalNombre(newTema.nombre) + setTemaDraftHoras(String(newTema.horasEstimadas ?? 0)) + setTemaOriginalHoras(newTema.horasEstimadas ?? 0) } const handleDelete = () => { @@ -321,136 +449,161 @@ export function ContenidoTematico() { {unidades.length} unidades • {totalHoras} horas estimadas totales

-
- -
{unidades.map((unidad) => ( - { + if (el) unitContainerRefs.current.set(unidad.id, el) + else unitContainerRefs.current.delete(unidad.id) + }} > - toggleUnit(unidad.id)} - > - -
- - - - - - Unidad {unidad.numero} - + + toggleUnit(unidad.id)} + > + +
+ + + + + + Unidad {unidad.numero} + - {editingUnit === unidad.id ? ( - - updateUnidadNombre(unidad.id, e.target.value) - } - onBlur={() => { - setEditingUnit(null) - void persistUnidades(unidades) - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setEditingUnit(null) - void persistUnidades(unidades) - } - }} - className="h-8 max-w-md bg-white" - /> - ) : ( - setEditingUnit(unidad.id)} - > - {unidad.nombre} - - )} - -
- - {' '} - {unidad.temas.reduce( - (sum, t) => sum + (t.horasEstimadas || 0), - 0, - )} - h - - -
-
-
- - -
- {unidad.temas.map((tema, idx) => ( - - setEditingTema({ unitId: unidad.id, temaId: tema.id }) - } - onStopEditing={() => { - setEditingTema(null) - void persistUnidades(unidades) + {editingUnit === unidad.id ? ( + setUnitDraftNombre(e.target.value)} + onBlur={() => { + if (cancelNextBlurRef.current) { + cancelNextBlurRef.current = false + return + } + commitEditUnit() }} - onUpdate={(updates) => - updateTema(unidad.id, tema.id, updates) - } - onDelete={() => - setDeleteDialog({ - type: 'tema', - id: tema.id, - parentId: unidad.id, - }) - } + 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" /> - ))} - + ) : ( + beginEditUnit(unidad.id)} + > + {unidad.nombre} + + )} + +
+ + {' '} + {unidad.temas.reduce( + (sum, t) => sum + (t.horasEstimadas || 0), + 0, + )} + h + + +
-
-
-
-
+ + + +
+ {unidad.temas.map((tema, idx) => ( + 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, + }) + } + /> + ))} + +
+
+
+ + +
))}
+
+ +
+ void - onStopEditing: () => void - onUpdate: (updates: Partial) => void + draftNombre: string + draftHoras: string + onBeginEdit: () => void + onDraftNombreChange: (value: string) => void + onDraftHorasChange: (value: string) => void + onEditorBlurCapture: (e: FocusEvent) => void + onEditorKeyDownCapture: (e: KeyboardEvent) => void + onNombreInputRef: (el: HTMLInputElement | null) => void onDelete: () => void } @@ -475,9 +633,14 @@ function TemaRow({ tema, index, isEditing, - onEdit, - onStopEditing, - onUpdate, + draftNombre, + draftHoras, + onBeginEdit, + onDraftNombreChange, + onDraftHorasChange, + onEditorBlurCapture, + onEditorKeyDownCapture, + onNombreInputRef, onDelete, }: TemaRowProps) { return ( @@ -489,47 +652,49 @@ function TemaRow({ > {index}. {isEditing ? ( -
+
onUpdate({ nombre: e.target.value })} + ref={onNombreInputRef} + value={draftNombre} + onChange={(e) => onDraftNombreChange(e.target.value)} className="h-8 flex-1 bg-white" placeholder="Nombre" /> - onUpdate({ horasEstimadas: parseInt(e.target.value) || 0 }) - } + value={draftHoras} + onChange={(e) => onDraftHorasChange(e.target.value)} className="h-8 w-16 bg-white" /> -
) : ( <> - - {tema.horasEstimadas}h -
@@ -537,7 +702,10 @@ function TemaRow({ variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-red-500" - onClick={onDelete} + onClick={(e) => { + e.stopPropagation() + onDelete() + }} >