Mejora 3: Implementado el auto-guardado con debounce en el formulario de configuración.

This commit is contained in:
2025-10-29 12:41:14 -03:00
parent bca56e7722
commit c76a5681ac
2 changed files with 82 additions and 36 deletions

View File

@@ -1,7 +1,8 @@
import { Box, TextField, Button, Paper, CircularProgress, Typography } from '@mui/material';
import { Box, TextField, Paper, CircularProgress, Chip } from '@mui/material';
import type { Configuracion } from '../types';
import * as api from '../services/apiService';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useDebounce } from '../hooks/useDebounce';
interface Props {
config: Configuracion | null;
@@ -9,61 +10,79 @@ interface Props {
}
const FormularioConfiguracion = ({ config, setConfig }: Props) => {
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const debouncedConfig = useDebounce(config, 750);
const isInitialLoad = useRef(true);
useEffect(() => {
// Solo procedemos si tenemos un objeto de configuración "debounced"
if (debouncedConfig) {
// Si la 'ref' es true, significa que esta es la primera vez que recibimos
// un objeto de configuración válido. Lo ignoramos y marcamos la carga inicial como completada.
if (isInitialLoad.current) {
isInitialLoad.current = false;
return;
}
// Si la 'ref' ya es false, significa que cualquier cambio posterior
// es una modificación real del usuario, por lo que procedemos a guardar.
const saveConfig = async () => {
setSaveStatus('saving');
try {
await api.guardarConfiguracion(debouncedConfig);
setSaveStatus('saved');
setTimeout(() => {
setSaveStatus('idle');
}, 2000);
} catch (err) {
console.error("Error en el auto-guardado:", err);
setSaveStatus('idle');
}
};
saveConfig();
}
}, [debouncedConfig]); // La dependencia sigue siendo la misma
if (!config) {
return <CircularProgress />;
}
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSaveStatus('idle');
const { name, value } = event.target;
const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear'];
setConfig(prevConfig => prevConfig ? {
...prevConfig,
[name]: numericFields.includes(name) ? Number(value) : value
} : null);
};
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!config) return;
setSaving(true);
setSuccess(false);
try {
await api.guardarConfiguracion(config);
setSuccess(true);
setTimeout(() => setSuccess(false), 2000);
} catch (err) {
console.error("Error al guardar configuración", err);
} finally {
setSaving(false);
const getSaveStatusIndicator = () => {
if (saveStatus === 'saving') {
return <Chip size="small" label="Guardando..." icon={<CircularProgress size={16} />} />;
}
if (saveStatus === 'saved') {
return <Chip size="small" label="Guardado" color="success" />;
}
return null;
};
return (
<Paper elevation={0} sx={{ padding: 2 }}>
<Box component="form" onSubmit={handleSubmit}>
<TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} variant="outlined" sx={{ mb: 2 }} disabled={saving} />
<TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} />
<TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" variant="outlined" sx={{ mb: 2 }} disabled={saving} />
<Box component="form">
<TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} sx={{ mb: 2 }} />
<TextField fullWidth name="intervaloMinutos" label="Intervalo de Actualización (minutos)" value={config.intervaloMinutos} onChange={handleChange} type="number" sx={{ mb: 2 }} />
<TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" sx={{ mb: 2 }} />
<TextField
fullWidth
name="viñetaPorDefecto"
label="Viñeta por Defecto"
value={config.viñetaPorDefecto}
onChange={handleChange}
variant="outlined"
sx={{ mb: 2 }}
disabled={saving}
fullWidth name="viñetaPorDefecto" label="Viñeta por Defecto" value={config.viñetaPorDefecto}
onChange={handleChange} sx={{ mb: 2 }}
helperText="El símbolo a usar si un titular no tiene una viñeta específica."
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
{success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>}
<Button type="submit" variant="contained" disabled={saving}>
{saving ? <CircularProgress size={24} /> : 'Guardar Cambios'}
</Button>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '36.5px' }}>
{getSaveStatusIndicator()}
</Box>
</Box>
</Paper>

View File

@@ -0,0 +1,27 @@
// frontend/src/hooks/useDebounce.ts
import { useState, useEffect } from 'react';
// Este hook toma un valor y un retardo (delay) en milisegundos.
// Devuelve una nueva versión del valor que solo se actualiza
// después de que el valor original no haya cambiado durante el 'delay' especificado.
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Configura un temporizador para actualizar el valor "debounced"
// después de que pase el tiempo de 'delay'.
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Función de limpieza: Si el 'value' cambia (porque el usuario sigue escribiendo),
// este return se ejecuta primero, limpiando el temporizador anterior.
// Esto previene que el valor se actualice mientras el usuario sigue interactuando.
return () => {
clearTimeout(handler);
};
}, [value, delay]); // El efecto se vuelve a ejecutar solo si el valor o el retardo cambian.
return debouncedValue;
}