From 267aaab91f798016e1c5b9e35cac9df1399fea3a Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 29 Oct 2025 11:47:47 -0300 Subject: [PATCH 1/5] =?UTF-8?q?Mejora=201:=20Implementado=20sistema=20de?= =?UTF-8?q?=20notificaciones=20global=20con=20Snackbar=20y=20modal=20de=20?= =?UTF-8?q?confirmaci=C3=B3n.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.tsx | 11 ++-- frontend/src/components/ConfirmationModal.tsx | 46 +++++++++++++++++ frontend/src/components/Dashboard.tsx | 38 +++++++++++--- frontend/src/contexts/NotificationContext.tsx | 51 +++++++++++++++++++ frontend/src/hooks/useNotification.ts | 8 +++ 5 files changed, 142 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/ConfirmationModal.tsx create mode 100644 frontend/src/contexts/NotificationContext.tsx create mode 100644 frontend/src/hooks/useNotification.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c029535..8d4c73a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { ThemeProvider, createTheme, CssBaseline, AppBar, Toolbar, Typography, Container, Box } from '@mui/material'; import Dashboard from './components/Dashboard'; +import { NotificationProvider } from './contexts/NotificationContext'; // Paleta de colores ajustada para coincidir con la nueva imagen (inspirada en Tailwind) const darkTheme = createTheme({ @@ -66,7 +67,7 @@ const darkTheme = createTheme({ color: '#f59e0b', }, // Chip 'Manual' - colorInfo: { + colorInfo: { backgroundColor: 'rgba(59, 130, 246, 0.2)', // blue-500 con opacidad color: '#60a5fa', }, @@ -99,9 +100,11 @@ function App() { return ( - - - + + + + + ); } diff --git a/frontend/src/components/ConfirmationModal.tsx b/frontend/src/components/ConfirmationModal.tsx new file mode 100644 index 0000000..45b2d09 --- /dev/null +++ b/frontend/src/components/ConfirmationModal.tsx @@ -0,0 +1,46 @@ +// frontend/src/components/ConfirmationModal.tsx + +import { Modal, Box, Typography, Button, Stack } from '@mui/material'; + +const style = { + position: 'absolute' as 'absolute', + top: '40%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, + borderRadius: 1, +}; + +interface Props { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + message: string; +} + +const ConfirmationModal = ({ open, onClose, onConfirm, title, message }: Props) => { + return ( + + + + {title} + + + {message} + + + + + + + + ); +}; + +export default ConfirmationModal; \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 0c1270c..601590d 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,8 +1,5 @@ import { useEffect, useState, useCallback } from 'react'; -import { - Box, Button, Stack, Chip, CircularProgress, - Accordion, AccordionSummary, AccordionDetails, Typography -} from '@mui/material'; +import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import SyncIcon from '@mui/icons-material/Sync'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -10,11 +7,13 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import type { Titular, Configuracion } from '../types'; import * as api from '../services/apiService'; import { useSignalR } from '../hooks/useSignalR'; +import { useNotification } from '../hooks/useNotification'; // <-- Importar hook de notificación import FormularioConfiguracion from './FormularioConfiguracion'; import TablaTitulares from './TablaTitulares'; import AddTitularModal from './AddTitularModal'; import EditarTitularModal from './EditarTitularModal'; import { PowerSwitch } from './PowerSwitch'; +import ConfirmationModal from './ConfirmationModal'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); @@ -23,6 +22,11 @@ const Dashboard = () => { const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); const [titularAEditar, setTitularAEditar] = useState(null); + // Estado para el modal de confirmación + const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null }); + + const { showNotification } = useNotification(); + const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { setTitulares(titularesActualizados); }, []); @@ -75,20 +79,27 @@ const Dashboard = () => { } }; - const handleDelete = async (id: number) => { - if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) { + const handleDelete = (id: number) => { + const onConfirm = async () => { try { await api.eliminarTitular(id); + showNotification('Titular eliminado correctamente', 'success'); } catch (err) { + showNotification('Error al eliminar el titular', 'error'); console.error("Error al eliminar:", err); + } finally { + setConfirmState({ open: false, onConfirm: null }); // Cierra el modal } - } + }; + setConfirmState({ open: true, onConfirm }); }; const handleAdd = async (texto: string) => { try { await api.crearTitularManual(texto); + showNotification('Titular manual añadido', 'success'); } catch (err) { + showNotification('Error al añadir el titular', 'error'); console.error("Error al añadir titular:", err); } }; @@ -109,8 +120,10 @@ const Dashboard = () => { setIsGeneratingCsv(true); try { await api.generarCsvManual(); + showNotification('CSV generado manualmente', 'success'); } catch (error) { - console.error("Error al generar CSV manually", error); + showNotification('Error al generar el CSV', 'error'); + console.error("Error al generar CSV manualmente", error); } finally { setIsGeneratingCsv(false); } @@ -119,7 +132,9 @@ const Dashboard = () => { const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { try { await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); + showNotification('Titular actualizado', 'success'); } catch (err) { + showNotification('Error al guardar los cambios', 'error'); console.error("Error al guardar cambios:", err); } }; @@ -195,6 +210,13 @@ const Dashboard = () => { onSave={handleSaveEdit} titular={titularAEditar} /> + setConfirmState({ open: false, onConfirm: null })} + onConfirm={() => confirmState.onConfirm?.()} + title="Confirmar Eliminación" + message="¿Estás seguro de que quieres eliminar este titular? Esta acción no se puede deshacer." + /> ); }; diff --git a/frontend/src/contexts/NotificationContext.tsx b/frontend/src/contexts/NotificationContext.tsx new file mode 100644 index 0000000..560621e --- /dev/null +++ b/frontend/src/contexts/NotificationContext.tsx @@ -0,0 +1,51 @@ +// frontend/src/contexts/NotificationContext.tsx + +import { createContext, useState, useCallback, type ReactNode } from 'react'; +import { Snackbar, Alert, type AlertColor } from '@mui/material'; + +// Definimos la forma de la función que nuestro contexto expondrá +interface NotificationContextType { + showNotification: (message: string, severity?: AlertColor) => void; +} + +// Creamos el contexto con un valor por defecto (una función vacía para evitar errores) +export const NotificationContext = createContext({ + showNotification: () => { }, +}); + +// Este es el componente Proveedor que envolverá nuestra aplicación +export const NotificationProvider = ({ children }: { children: ReactNode }) => { + const [open, setOpen] = useState(false); + const [message, setMessage] = useState(''); + const [severity, setSeverity] = useState('info'); + + const handleClose = (_event?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === 'clickaway') { + return; + } + setOpen(false); + }; + + // Usamos useCallback para que la referencia a esta función no cambie en cada render + const showNotification = useCallback((msg: string, sev: AlertColor = 'info') => { + setMessage(msg); + setSeverity(sev); + setOpen(true); + }, []); + + return ( + + {children} + + + {message} + + + + ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useNotification.ts b/frontend/src/hooks/useNotification.ts new file mode 100644 index 0000000..8295edb --- /dev/null +++ b/frontend/src/hooks/useNotification.ts @@ -0,0 +1,8 @@ +// frontend/src/hooks/useNotification.ts + +import { useContext } from 'react'; +import { NotificationContext } from '../contexts/NotificationContext'; + +export const useNotification = () => { + return useContext(NotificationContext); +}; \ No newline at end of file From bca56e7722b502e7023d5f7a7f6f8098a4c40ed0 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 29 Oct 2025 12:26:38 -0300 Subject: [PATCH 2/5] =?UTF-8?q?Mejora=202:=20Implementada=20edici=C3=B3n?= =?UTF-8?q?=20en=20l=C3=ADnea=20para=20el=20texto=20de=20los=20titulares.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Dashboard.tsx | 83 +++++++++++----------- frontend/src/components/TablaTitulares.tsx | 45 +++++++++--- 2 files changed, 74 insertions(+), 54 deletions(-) diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 601590d..17b87f4 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,5 +1,8 @@ import { useEffect, useState, useCallback } from 'react'; -import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; +import { + Box, Button, Stack, Chip, CircularProgress, + Accordion, AccordionSummary, AccordionDetails, Typography +} from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import SyncIcon from '@mui/icons-material/Sync'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -7,26 +10,25 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import type { Titular, Configuracion } from '../types'; import * as api from '../services/apiService'; import { useSignalR } from '../hooks/useSignalR'; -import { useNotification } from '../hooks/useNotification'; // <-- Importar hook de notificación +import { useNotification } from '../hooks/useNotification'; import FormularioConfiguracion from './FormularioConfiguracion'; import TablaTitulares from './TablaTitulares'; import AddTitularModal from './AddTitularModal'; import EditarTitularModal from './EditarTitularModal'; import { PowerSwitch } from './PowerSwitch'; import ConfirmationModal from './ConfirmationModal'; +import type { ActualizarTitularPayload } from '../services/apiService'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); const [config, setConfig] = useState(null); const [addModalOpen, setAddModalOpen] = useState(false); const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); - const [titularAEditar, setTitularAEditar] = useState(null); - - // Estado para el modal de confirmación const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null }); - const { showNotification } = useNotification(); + const [titularAEditar, setTitularAEditar] = useState(null); + const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { setTitulares(titularesActualizados); }, []); @@ -36,12 +38,9 @@ const Dashboard = () => { ]); useEffect(() => { - // Obtenemos la configuración persistente const fetchConfig = api.obtenerConfiguracion(); - // Obtenemos el estado inicial del switch (que siempre será 'false') const fetchEstado = api.getEstadoProceso(); - // Cuando ambas promesas se resuelvan, construimos el estado inicial Promise.all([fetchConfig, fetchEstado]) .then(([configData, estadoData]) => { setConfig({ @@ -54,16 +53,39 @@ const Dashboard = () => { api.obtenerTitulares().then(setTitulares); }, []); + const handleDelete = (id: number) => { + const onConfirm = async () => { + try { + await api.eliminarTitular(id); + showNotification('Titular eliminado correctamente', 'success'); + } catch (err) { + showNotification('Error al eliminar el titular', 'error'); + console.error("Error al eliminar:", err); + } finally { + setConfirmState({ open: false, onConfirm: null }); + } + }; + setConfirmState({ open: true, onConfirm }); + }; + + const handleSaveEdit = async (id: number, payload: ActualizarTitularPayload) => { + try { + await api.actualizarTitular(id, payload); + showNotification('Titular actualizado', 'success'); + } catch (err) { + showNotification('Error al guardar los cambios', 'error'); + console.error("Error al guardar cambios:", err); + } + }; + const handleSwitchChange = async (event: React.ChangeEvent) => { if (!config) return; const isChecked = event.target.checked; setConfig({ ...config, scrapingActivo: isChecked }); try { - // Llamamos al nuevo endpoint para cambiar solo el estado await api.setEstadoProceso(isChecked); } catch (err) { console.error("Error al cambiar estado del proceso", err); - // Revertir en caso de error setConfig({ ...config, scrapingActivo: !isChecked }); } }; @@ -79,21 +101,6 @@ const Dashboard = () => { } }; - const handleDelete = (id: number) => { - const onConfirm = async () => { - try { - await api.eliminarTitular(id); - showNotification('Titular eliminado correctamente', 'success'); - } catch (err) { - showNotification('Error al eliminar el titular', 'error'); - console.error("Error al eliminar:", err); - } finally { - setConfirmState({ open: false, onConfirm: null }); // Cierra el modal - } - }; - setConfirmState({ open: true, onConfirm }); - }; - const handleAdd = async (texto: string) => { try { await api.crearTitularManual(texto); @@ -129,16 +136,6 @@ const Dashboard = () => { } }; - const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { - try { - await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); - showNotification('Titular actualizado', 'success'); - } catch (err) { - showNotification('Error al guardar los cambios', 'error'); - console.error("Error al guardar cambios:", err); - } - }; - return ( <> { onReorder={handleReorder} onDelete={handleDelete} onEdit={(titular) => setTitularAEditar(titular)} + onSave={handleSaveEdit} /> { onClose={() => setAddModalOpen(false)} onAdd={handleAdd} /> - - setTitularAEditar(null)} - onSave={handleSaveEdit} - titular={titularAEditar} - /> setConfirmState({ open: false, onConfirm: null })} @@ -217,6 +208,12 @@ const Dashboard = () => { title="Confirmar Eliminación" message="¿Estás seguro de que quieres eliminar este titular? Esta acción no se puede deshacer." /> + setTitularAEditar(null)} + onSave={(id, texto, viñeta) => handleSaveEdit(id, { texto, viñeta: viñeta || null })} + titular={titularAEditar} + /> ); }; diff --git a/frontend/src/components/TablaTitulares.tsx b/frontend/src/components/TablaTitulares.tsx index f903278..42e84fb 100644 --- a/frontend/src/components/TablaTitulares.tsx +++ b/frontend/src/components/TablaTitulares.tsx @@ -1,8 +1,7 @@ // frontend/src/components/TablaTitulares.tsx -import { - Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link -} from '@mui/material'; +import { useState } from 'react'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link, TextField } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; import DragHandleIcon from '@mui/icons-material/DragHandle'; import EditIcon from '@mui/icons-material/Edit'; @@ -10,19 +9,27 @@ import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type D import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import type { Titular } from '../types'; +import type { ActualizarTitularPayload } from '../services/apiService'; interface SortableRowProps { titular: Titular; onDelete: (id: number) => void; + onSave: (id: number, payload: ActualizarTitularPayload) => void; onEdit: (titular: Titular) => void; } -const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { +const SortableRow = ({ titular, onDelete, onSave, onEdit }: SortableRowProps) => { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id }); + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(titular.texto); - const style = { - transform: CSS.Transform.toString(transform), - transition, + const style = { transform: CSS.Transform.toString(transform), transition }; + + const handleSave = () => { + if (editText.trim() && editText.trim() !== titular.texto) { + onSave(titular.id, { texto: editText.trim(), viñeta: titular.viñeta }); + } + setIsEditing(false); }; const getChipColor = (tipo: Titular['tipo']): "success" | "warning" | "info" => { @@ -30,7 +37,7 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { if (tipo === 'Manual') return 'info'; return 'success'; }; - + const formatFuente = (fuente: string | null) => { if (!fuente) return 'N/A'; try { @@ -46,7 +53,22 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { - {titular.texto} + setIsEditing(true)}> + {isEditing ? ( + setEditText(e.target.value)} + onBlur={handleSave} + onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {titular.texto} + )} + @@ -76,9 +98,10 @@ interface TablaTitularesProps { onReorder: (titulares: Titular[]) => void; onDelete: (id: number) => void; onEdit: (titular: Titular) => void; + onSave: (id: number, payload: ActualizarTitularPayload) => void; } -const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitularesProps) => { +const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit, onSave }: TablaTitularesProps) => { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })); const handleDragEnd = (event: DragEndEvent) => { @@ -115,7 +138,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular {titulares.map((titular) => ( - + ))} From c76a5681acfc1acc68d6fffb60273eba5bf9bc5e Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 29 Oct 2025 12:41:14 -0300 Subject: [PATCH 3/5] =?UTF-8?q?Mejora=203:=20Implementado=20el=20auto-guar?= =?UTF-8?q?dado=20con=20debounce=20en=20el=20formulario=20de=20configuraci?= =?UTF-8?q?=C3=B3n.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/FormularioConfiguracion.tsx | 91 +++++++++++-------- frontend/src/hooks/useDebounce.ts | 27 ++++++ 2 files changed, 82 insertions(+), 36 deletions(-) create mode 100644 frontend/src/hooks/useDebounce.ts diff --git a/frontend/src/components/FormularioConfiguracion.tsx b/frontend/src/components/FormularioConfiguracion.tsx index 92022fa..fc8bcd3 100644 --- a/frontend/src/components/FormularioConfiguracion.tsx +++ b/frontend/src/components/FormularioConfiguracion.tsx @@ -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 ; } const handleChange = (event: React.ChangeEvent) => { + 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 } />; } + if (saveStatus === 'saved') { + return ; + } + return null; }; return ( - - - - + + + + - - {success && ¡Guardado!} - + + {getSaveStatusIndicator()} diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 0000000..5f7f831 --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -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(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(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; +} \ No newline at end of file From 66e3a0af99269cbf239f6d95a57b8c165cd210f3 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 29 Oct 2025 13:18:39 -0300 Subject: [PATCH 4/5] Fix Mejora 3: Cambio en alto de filas --- frontend/src/components/TablaTitulares.tsx | 49 +++++++++++++++------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/TablaTitulares.tsx b/frontend/src/components/TablaTitulares.tsx index 42e84fb..3ffb112 100644 --- a/frontend/src/components/TablaTitulares.tsx +++ b/frontend/src/components/TablaTitulares.tsx @@ -1,7 +1,7 @@ // frontend/src/components/TablaTitulares.tsx import { useState } from 'react'; -import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link, TextField } from '@mui/material'; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography, Link, TextField, Tooltip } from '@mui/material'; // <-- Añadir Tooltip import DeleteIcon from '@mui/icons-material/Delete'; import DragHandleIcon from '@mui/icons-material/DragHandle'; import EditIcon from '@mui/icons-material/Edit'; @@ -49,11 +49,24 @@ const SortableRow = ({ titular, onDelete, onSave, onEdit }: SortableRowProps) => } return ( - - - + + + + + - setIsEditing(true)}> + setIsEditing(true)}> {isEditing ? ( {titular.texto} )} - + - + {titular.urlFuente ? ( {formatFuente(titular.fuente)} @@ -81,13 +94,17 @@ const SortableRow = ({ titular, onDelete, onSave, onEdit }: SortableRowProps) => formatFuente(titular.fuente) )} - - { e.stopPropagation(); onEdit(titular); }}> - - - { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> - - + + + { e.stopPropagation(); onEdit(titular); }}> + + + + + { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> + + + ); @@ -128,7 +145,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit, onSave }: Tabl t.id)} strategy={verticalListSortingStrategy}> - + Texto del Titular Tipo @@ -136,7 +153,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit, onSave }: Tabl Acciones - + {titulares.map((titular) => ( ))} From e41892ef2d4056c57ac154c92ff68a7cdf68103f Mon Sep 17 00:00:00 2001 From: dmolinari Date: Wed, 29 Oct 2025 14:16:57 -0300 Subject: [PATCH 5/5] Feat Skeleton Table de Carga - Fix Freno del Proceso al Cerrar Web --- .../src/Titulares.Api/Hubs/TitularesHub.cs | 24 +++++++-- .../Services/EstadoProcesoService.cs | 33 ++++++++++-- frontend/src/components/Dashboard.tsx | 42 ++++++++++----- frontend/src/components/TableSkeleton.tsx | 51 +++++++++++++++++++ 4 files changed, 128 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/TableSkeleton.tsx diff --git a/backend/src/Titulares.Api/Hubs/TitularesHub.cs b/backend/src/Titulares.Api/Hubs/TitularesHub.cs index d16dea6..261f187 100644 --- a/backend/src/Titulares.Api/Hubs/TitularesHub.cs +++ b/backend/src/Titulares.Api/Hubs/TitularesHub.cs @@ -1,12 +1,30 @@ // backend/src/Titulares.Api/Hubs/TitularesHub.cs using Microsoft.AspNetCore.SignalR; +using Titulares.Api.Services; namespace Titulares.Api.Hubs; -// Esta clase es el punto de conexión para los clientes de SignalR. -// No necesitamos añadirle métodos personalizados porque solo enviaremos -// mensajes desde el servidor hacia los clientes. public class TitularesHub : Hub { + private readonly EstadoProcesoService _estadoService; + + public TitularesHub(EstadoProcesoService estadoService) + { + _estadoService = estadoService; + } + + // Este método se ejecuta CADA VEZ que un nuevo cliente (pestaña) se conecta. + public override Task OnConnectedAsync() + { + _estadoService.RegistrarConexion(); + return base.OnConnectedAsync(); + } + + // Este método se ejecuta CADA VEZ que un cliente (pestaña) se desconecta. + public override Task OnDisconnectedAsync(Exception? exception) + { + _estadoService.RegistrarDesconexionYApagarSiEsElUltimo(); + return base.OnDisconnectedAsync(exception); + } } \ No newline at end of file diff --git a/backend/src/Titulares.Api/Services/EstadoProcesoService.cs b/backend/src/Titulares.Api/Services/EstadoProcesoService.cs index 1a5152a..b68e479 100644 --- a/backend/src/Titulares.Api/Services/EstadoProcesoService.cs +++ b/backend/src/Titulares.Api/Services/EstadoProcesoService.cs @@ -1,12 +1,11 @@ -// backend/src/Titulares.Api/Services/EstadoProcesoService.cs - namespace Titulares.Api.Services; public class EstadoProcesoService { private volatile bool _estaActivo = false; + private volatile int _connectionCount = 0; + private readonly object _lock = new object(); - // 1. Definimos un evento público al que otros servicios pueden suscribirse. public event Action? OnStateChanged; public bool EstaActivo() => _estaActivo; @@ -14,14 +13,38 @@ public class EstadoProcesoService public void Activar() { _estaActivo = true; - // 2. Disparamos el evento para notificar a los suscriptores. OnStateChanged?.Invoke(); } public void Desactivar() { _estaActivo = false; - // 3. Disparamos el evento también al desactivar. OnStateChanged?.Invoke(); } + + public void RegistrarConexion() + { + lock (_lock) + { + _connectionCount++; + } + } + + public void RegistrarDesconexionYApagarSiEsElUltimo() + { + lock (_lock) + { + _connectionCount--; + // Si el contador llega a 0, significa que no hay más clientes conectados. + // Apagamos el proceso de forma segura. + if (_connectionCount <= 0) + { + _connectionCount = 0; // Prevenir números negativos + if (_estaActivo) + { + Desactivar(); + } + } + } + } } \ No newline at end of file diff --git a/frontend/src/components/Dashboard.tsx b/frontend/src/components/Dashboard.tsx index 17b87f4..9590ae8 100644 --- a/frontend/src/components/Dashboard.tsx +++ b/frontend/src/components/Dashboard.tsx @@ -1,8 +1,7 @@ +// frontend/src/components/Dashboard.tsx + import { useEffect, useState, useCallback } from 'react'; -import { - Box, Button, Stack, Chip, CircularProgress, - Accordion, AccordionSummary, AccordionDetails, Typography -} from '@mui/material'; +import { Box, Button, Stack, Chip, CircularProgress, Accordion, AccordionSummary, AccordionDetails, Typography } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import SyncIcon from '@mui/icons-material/Sync'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -18,6 +17,7 @@ import EditarTitularModal from './EditarTitularModal'; import { PowerSwitch } from './PowerSwitch'; import ConfirmationModal from './ConfirmationModal'; import type { ActualizarTitularPayload } from '../services/apiService'; +import { TableSkeleton } from './TableSkeleton'; const Dashboard = () => { const [titulares, setTitulares] = useState([]); @@ -26,9 +26,10 @@ const Dashboard = () => { const [isGeneratingCsv, setIsGeneratingCsv] = useState(false); const [confirmState, setConfirmState] = useState<{ open: boolean; onConfirm: (() => void) | null }>({ open: false, onConfirm: null }); const { showNotification } = useNotification(); - const [titularAEditar, setTitularAEditar] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { setTitulares(titularesActualizados); }, []); @@ -38,11 +39,15 @@ const Dashboard = () => { ]); useEffect(() => { + // 1. Cargamos la configuración persistente const fetchConfig = api.obtenerConfiguracion(); + + // 2. Preguntamos al servidor por el estado ACTUAL del proceso const fetchEstado = api.getEstadoProceso(); Promise.all([fetchConfig, fetchEstado]) .then(([configData, estadoData]) => { + // Construimos el estado de la UI para que REFLEJE el estado real del servidor setConfig({ ...configData, scrapingActivo: estadoData.activo @@ -50,8 +55,13 @@ const Dashboard = () => { }) .catch(error => console.error("Error al cargar datos iniciales:", error)); - api.obtenerTitulares().then(setTitulares); - }, []); + // La carga de titulares sigue igual + api.obtenerTitulares() + .then(setTitulares) + .catch(error => console.error("Error al cargar titulares:", error)) + .finally(() => setIsLoading(false)); + + }, []); // El array vacío asegura que esto solo se ejecute una vez const handleDelete = (id: number) => { const onConfirm = async () => { @@ -188,13 +198,17 @@ const Dashboard = () => { - setTitularAEditar(titular)} - onSave={handleSaveEdit} - /> + {isLoading ? ( + + ) : ( + setTitularAEditar(titular)} + onSave={handleSaveEdit} + /> + )} ( + + + + + + + + + + + + + + + + + +); + +// El componente principal que renderiza la tabla fantasma +export const TableSkeleton = () => { + return ( + + +
+ + + + Texto del Titular + Tipo + Fuente + Acciones + + + + {/* Creamos un array de 5 elementos para renderizar 5 filas de esqueleto */} + {[...Array(5)].map((_, index) => ( + + ))} + +
+ + + ); +}; \ No newline at end of file