Guardado automático #53 #68

Merged
roberto.silva merged 3 commits from issue/53-guardado-automtico into main 2026-02-06 13:34:51 +00:00
2 changed files with 125 additions and 73 deletions

View File

@@ -5,12 +5,10 @@ import {
Clock, Clock,
Hash, Hash,
CalendarDays, CalendarDays,
Save,
} from 'lucide-react' } from 'lucide-react'
import { useState, useEffect, forwardRef } from 'react' import { useState, useEffect, forwardRef } from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -20,7 +18,7 @@ import {
import { NotFoundPage } from '@/components/ui/NotFoundPage' import { NotFoundPage } from '@/components/ui/NotFoundPage'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { plans_get } from '@/data/api/plans.api' import { plans_get } from '@/data/api/plans.api'
import { usePlan } from '@/data/hooks/usePlans' import { usePlan, useUpdatePlanFields } from '@/data/hooks/usePlans'
import { qk } from '@/data/query/keys' import { qk } from '@/data/query/keys'
export const Route = createFileRoute('/planes/$planId/_detalle')({ export const Route = createFileRoute('/planes/$planId/_detalle')({
@@ -56,6 +54,7 @@ export const Route = createFileRoute('/planes/$planId/_detalle')({
function RouteComponent() { function RouteComponent() {
const { planId } = Route.useParams() const { planId } = Route.useParams()
const { data, isLoading } = usePlan(planId) const { data, isLoading } = usePlan(planId)
const { mutate } = useUpdatePlanFields()
// Estados locales para manejar la edición "en vivo" antes de persistir // Estados locales para manejar la edición "en vivo" antes de persistir
const [nombrePlan, setNombrePlan] = useState('') const [nombrePlan, setNombrePlan] = useState('')
@@ -77,32 +76,37 @@ function RouteComponent() {
'Especialidad', 'Especialidad',
] ]
const handleKeyDown = (e: React.KeyboardEvent) => { const persistChange = (patch: any) => {
mutate({ planId, patch })
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault() // Evita el salto de línea e.preventDefault()
e.currentTarget.blur() // Quita el foco, lo que dispara el onBlur y "guarda" en el estado e.currentTarget.blur() // Esto dispara el onBlur automáticamente
} }
} }
const handleSave = () => { const handleBlurNombre = (e: React.FocusEvent<HTMLSpanElement>) => {
console.log('Guardando en DB...', { nombrePlan, nivelPlan }) const nuevoNombre = e.currentTarget.textContent || ''
// Aquí iría tu mutation setNombrePlan(nuevoNombre)
setIsDirty(false)
// Solo guardamos si el valor es realmente distinto al de la base de datos
if (nuevoNombre !== data?.nombre) {
persistChange({ nombre: nuevoNombre })
}
}
const handleSelectNivel = (n: string) => {
setNivelPlan(n)
// Guardamos inmediatamente al seleccionar
if (n !== data?.nivel) {
persistChange({ nivel: n })
}
} }
return ( return (
<div className="min-h-screen bg-white"> <div className="min-h-screen bg-white">
{/* Botón Flotante de Guardar */}
{isDirty && (
<div className="animate-in fade-in slide-in-from-bottom-4 fixed right-8 bottom-8 z-50 duration-300">
<Button
onClick={handleSave}
className="gap-2 rounded-full bg-teal-600 px-6 shadow-xl hover:bg-teal-700"
>
<Save size={16} /> Guardar cambios del Plan
</Button>
</div>
)}
{/* 1. Header Superior */} {/* 1. Header Superior */}
<div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm"> <div className="sticky top-0 z-20 border-b bg-white/50 shadow-sm backdrop-blur-sm">
<div className="px-6 py-2"> <div className="px-6 py-2">
@@ -116,62 +120,54 @@ function RouteComponent() {
</div> </div>
<div className="mx-auto max-w-400 space-y-8 p-8"> <div className="mx-auto max-w-400 space-y-8 p-8">
{/* Header del Plan */} {/* 2. Header del Plan */}
{isLoading ? ( {isLoading ? (
/* ===== SKELETON ===== */ /* ===== SKELETON ===== */
<div className="mx-auto max-w-400 p-8"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2"> {Array.from({ length: 6 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, i) => ( <DatosGeneralesSkeleton key={i} />
<DatosGeneralesSkeleton key={i} /> ))}
))}
</div>
</div> </div>
) : ( ) : (
<> <div className="flex flex-col items-start justify-between gap-4 md:flex-row">
<div className="flex flex-col items-start justify-between gap-4 md:flex-row"> <div>
<div> <h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900">
<h1 className="flex items-baseline gap-2 text-3xl font-bold tracking-tight text-slate-900"> <span>{nivelPlan} en</span>
<span>{nivelPlan} en</span> <span
<span role="textbox"
role="textbox" tabIndex={0}
tabIndex={0} contentEditable
contentEditable suppressContentEditableWarning
suppressContentEditableWarning spellCheck={false}
spellCheck={false} // Quita el subrayado rojo de error ortográfico onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} onBlur={(e) => {
onBlur={(e) => const nuevoNombre = e.currentTarget.textContent || ''
setNombrePlan(e.currentTarget.textContent || '') setNombrePlan(nuevoNombre)
if (nuevoNombre !== data?.nombre) {
mutate({ planId, patch: { nombre: nuevoNombre } })
} }
className="cursor-text border-b border-transparent decoration-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500" }}
style={{ className="cursor-text border-b border-transparent transition-colors outline-none select-text hover:border-slate-300 focus:border-teal-500"
WebkitTextDecoration: 'none', style={{ textDecoration: 'none' }}
textDecoration: 'none',
}} // Doble seguridad contra subrayados
>
{nombrePlan}
</span>
</h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div>
<div className="flex gap-2">
{/* <Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
<CheckCircle2 size={12} /> {data?.estados_plan?.etiqueta}
</Badge> */}
<Badge
className={`gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100`}
> >
{data?.estados_plan?.etiqueta} {nombrePlan}
</Badge> </span>
</div> </h1>
<p className="mt-1 text-lg font-medium text-slate-500">
{data?.carreras?.facultades?.nombre}{' '}
{data?.carreras?.nombre_corto}
</p>
</div> </div>
</>
<div className="flex gap-2">
<Badge className="gap-1 border-teal-200 bg-teal-50 px-3 text-teal-700 hover:bg-teal-100">
{data?.estados_plan?.etiqueta}
</Badge>
</div>
</div>
)} )}
{/* 3. Cards de Información con Context Menu */} {/* 3. Cards de Información */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -189,7 +185,9 @@ function RouteComponent() {
key={n} key={n}
onClick={() => { onClick={() => {
setNivelPlan(n) setNivelPlan(n)
setIsDirty(true) if (n !== data?.nivel) {
mutate({ planId, patch: { nivel: n } })
}
}} }}
> >
{n} {n}
@@ -211,7 +209,7 @@ function RouteComponent() {
<InfoCard <InfoCard
icon={<CalendarDays className="text-slate-400" />} icon={<CalendarDays className="text-slate-400" />}
label="Creación" label="Creación"
value={data?.creado_en?.split('T')[0]} // Cortamos la fecha para que no sea tan larga value={data?.creado_en?.split('T')[0]}
/> />
</div> </div>

View File

@@ -112,15 +112,68 @@ function DatosGeneralesPage() {
}, [data]) }, [data])
// 3. Manejadores de acciones (Ahora como funciones locales) // 3. Manejadores de acciones (Ahora como funciones locales)
const handleEdit = (campo: DatosGeneralesField) => { const handleEdit = (nuevoCampo: DatosGeneralesField) => {
setEditingId(campo.id) // 1. SI YA ESTÁBAMOS EDITANDO OTRO CAMPO, GUARDAMOS EL ANTERIOR PRIMERO
setEditValue(campo.value) if (editingId && editingId !== nuevoCampo.id) {
const campoAnterior = campos.find((c) => c.id === editingId)
if (campoAnterior && editValue !== campoAnterior.value) {
// Solo guardamos si el valor realmente cambió
ejecutarGuardadoSilencioso(campoAnterior, editValue)
}
}
// 2. ABRIMOS EL NUEVO CAMPO
setEditingId(nuevoCampo.id)
setEditValue(nuevoCampo.value)
} }
const handleCancel = () => { const handleCancel = () => {
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }
// Función auxiliar para procesar los datos (fuera o dentro del componente)
const prepararDatosActualizados = (
data: any,
campo: DatosGeneralesField,
valor: string,
) => {
const currentValue = data.datos[campo.clave]
let newValue: any
if (
typeof currentValue === 'object' &&
currentValue !== null &&
'description' in currentValue
) {
newValue = { ...currentValue, description: valor }
} else {
newValue = valor
}
return {
...data.datos,
[campo.clave]: newValue,
}
}
const ejecutarGuardadoSilencioso = (
campo: DatosGeneralesField,
valor: string,
) => {
if (!data?.datos) return
const datosActualizados = prepararDatosActualizados(data, campo, valor)
updatePlan.mutate({
planId,
patch: { datos: datosActualizados },
})
// Actualizar UI localmente
setCampos((prev) =>
prev.map((c) => (c.id === campo.id ? { ...c, value: valor } : c)),
)
}
const handleSave = (campo: DatosGeneralesField) => { const handleSave = (campo: DatosGeneralesField) => {
if (!data?.datos) return if (!data?.datos) return
@@ -161,6 +214,7 @@ function DatosGeneralesPage() {
prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)), prev.map((c) => (c.id === campo.id ? { ...c, value: editValue } : c)),
) )
ejecutarGuardadoSilencioso(campo, editValue)
setEditingId(null) setEditingId(null)
setEditValue('') setEditValue('')
} }