Mejora 3: Implementado el auto-guardado con debounce en el formulario de configuración.
This commit is contained in:
		| @@ -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 type { Configuracion } from '../types'; | ||||||
| import * as api from '../services/apiService'; | import * as api from '../services/apiService'; | ||||||
| import { useState } from 'react'; | import { useState, useEffect, useRef } from 'react'; | ||||||
|  | import { useDebounce } from '../hooks/useDebounce'; | ||||||
|  |  | ||||||
| interface Props { | interface Props { | ||||||
|   config: Configuracion | null; |   config: Configuracion | null; | ||||||
| @@ -9,61 +10,79 @@ interface Props { | |||||||
| } | } | ||||||
|  |  | ||||||
| const FormularioConfiguracion = ({ config, setConfig }: Props) => { | const FormularioConfiguracion = ({ config, setConfig }: Props) => { | ||||||
|   const [saving, setSaving] = useState(false); |   const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); | ||||||
|   const [success, setSuccess] = useState(false); |   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) { |   if (!config) { | ||||||
|     return <CircularProgress />; |     return <CircularProgress />; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { |   const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => { | ||||||
|  |     setSaveStatus('idle');  | ||||||
|     const { name, value } = event.target; |     const { name, value } = event.target; | ||||||
|     const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear']; |     const numericFields = ['intervaloMinutos', 'cantidadTitularesAScrapear']; | ||||||
|  |  | ||||||
|     setConfig(prevConfig => prevConfig ? { |     setConfig(prevConfig => prevConfig ? { | ||||||
|       ...prevConfig, |       ...prevConfig, | ||||||
|       [name]: numericFields.includes(name) ? Number(value) : value |       [name]: numericFields.includes(name) ? Number(value) : value | ||||||
|     } : null); |     } : null); | ||||||
|   }; |   }; | ||||||
|    |    | ||||||
|   const handleSubmit = async (event: React.FormEvent) => { |   const getSaveStatusIndicator = () => { | ||||||
|     event.preventDefault(); |     if (saveStatus === 'saving') { | ||||||
|     if (!config) return; |       return <Chip size="small" label="Guardando..." icon={<CircularProgress size={16} />} />; | ||||||
|     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); |  | ||||||
|     } |     } | ||||||
|  |     if (saveStatus === 'saved') { | ||||||
|  |       return <Chip size="small" label="Guardado" color="success" />; | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|   }; |   }; | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Paper elevation={0} sx={{ padding: 2 }}> |     <Paper elevation={0} sx={{ padding: 2 }}> | ||||||
|       <Box component="form" onSubmit={handleSubmit}> |       <Box component="form"> | ||||||
|         <TextField fullWidth name="rutaCsv" label="Ruta del archivo CSV" value={config.rutaCsv} onChange={handleChange} variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <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" variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <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" variant="outlined" sx={{ mb: 2 }} disabled={saving} /> |         <TextField fullWidth name="cantidadTitularesAScrapear" label="Titulares a Capturar por Ciclo" value={config.cantidadTitularesAScrapear} onChange={handleChange} type="number" sx={{ mb: 2 }} /> | ||||||
|         <TextField |         <TextField | ||||||
|           fullWidth |           fullWidth name="viñetaPorDefecto" label="Viñeta por Defecto" value={config.viñetaPorDefecto} | ||||||
|           name="viñetaPorDefecto" |           onChange={handleChange} sx={{ mb: 2 }} | ||||||
|           label="Viñeta por Defecto" |  | ||||||
|           value={config.viñetaPorDefecto} |  | ||||||
|           onChange={handleChange} |  | ||||||
|           variant="outlined" |  | ||||||
|           sx={{ mb: 2 }} |  | ||||||
|           disabled={saving} |  | ||||||
|           helperText="El símbolo a usar si un titular no tiene una viñeta específica." |           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' }}> |         <Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', height: '36.5px' }}> | ||||||
|           {success && <Typography color="success.main" sx={{ mr: 2 }}>¡Guardado!</Typography>} |           {getSaveStatusIndicator()} | ||||||
|           <Button type="submit" variant="contained" disabled={saving}> |  | ||||||
|             {saving ? <CircularProgress size={24} /> : 'Guardar Cambios'} |  | ||||||
|           </Button> |  | ||||||
|         </Box> |         </Box> | ||||||
|       </Box> |       </Box> | ||||||
|     </Paper> |     </Paper> | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								frontend/src/hooks/useDebounce.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								frontend/src/hooks/useDebounce.ts
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
		Reference in New Issue
	
	Block a user