Compare commits
	
		
			6 Commits
		
	
	
		
			3fbb254ac3
			...
			e354433cd6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| e354433cd6 | |||
| e41892ef2d | |||
| 66e3a0af99 | |||
| c76a5681ac | |||
| bca56e7722 | |||
| 267aaab91f | 
| @@ -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); | ||||
|   } | ||||
| } | ||||
| @@ -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(); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -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({ | ||||
| @@ -99,9 +100,11 @@ function App() { | ||||
|   return ( | ||||
|     <ThemeProvider theme={darkTheme}> | ||||
|       <CssBaseline /> | ||||
|       <NotificationProvider> | ||||
|         <Layout> | ||||
|           <Dashboard /> | ||||
|         </Layout> | ||||
|       </NotificationProvider> | ||||
|     </ThemeProvider> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										46
									
								
								frontend/src/components/ConfirmationModal.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								frontend/src/components/ConfirmationModal.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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 ( | ||||
|     <Modal open={open} onClose={onClose}> | ||||
|       <Box sx={style}> | ||||
|         <Typography variant="h6" component="h2" gutterBottom> | ||||
|           {title} | ||||
|         </Typography> | ||||
|         <Typography sx={{ mb: 3, color: 'text.secondary' }}> | ||||
|           {message} | ||||
|         </Typography> | ||||
|         <Stack direction="row" spacing={2} justifyContent="flex-end"> | ||||
|           <Button onClick={onClose}>Cancelar</Button> | ||||
|           <Button variant="contained" color="error" onClick={onConfirm}> | ||||
|             Confirmar | ||||
|           </Button> | ||||
|         </Stack> | ||||
|       </Box> | ||||
|     </Modal> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default ConfirmationModal; | ||||
| @@ -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'; | ||||
| @@ -10,19 +9,27 @@ 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'; | ||||
| 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'; | ||||
| import { TableSkeleton } from './TableSkeleton'; | ||||
|  | ||||
| const Dashboard = () => { | ||||
|   const [titulares, setTitulares] = useState<Titular[]>([]); | ||||
|   const [config, setConfig] = useState<Configuracion | null>(null); | ||||
|   const [addModalOpen, setAddModalOpen] = useState(false); | ||||
|   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<Titular | null>(null); | ||||
|  | ||||
|   const [isLoading, setIsLoading] = useState(true); | ||||
|  | ||||
|   const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => { | ||||
|     setTitulares(titularesActualizados); | ||||
|   }, []); | ||||
| @@ -32,14 +39,15 @@ const Dashboard = () => { | ||||
|   ]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     // Obtenemos la configuración persistente | ||||
|     // 1. Cargamos la configuración persistente | ||||
|     const fetchConfig = api.obtenerConfiguracion(); | ||||
|     // Obtenemos el estado inicial del switch (que siempre será 'false') | ||||
|  | ||||
|     // 2. Preguntamos al servidor por el estado ACTUAL del proceso | ||||
|     const fetchEstado = api.getEstadoProceso(); | ||||
|  | ||||
|     // Cuando ambas promesas se resuelvan, construimos el estado inicial | ||||
|     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 | ||||
| @@ -47,19 +55,47 @@ 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 () => { | ||||
|       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<HTMLInputElement>) => { | ||||
|     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 }); | ||||
|     } | ||||
|   }; | ||||
| @@ -75,20 +111,12 @@ const Dashboard = () => { | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleDelete = async (id: number) => { | ||||
|     if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) { | ||||
|       try { | ||||
|         await api.eliminarTitular(id); | ||||
|       } catch (err) { | ||||
|         console.error("Error al eliminar:", err); | ||||
|       } | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   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,21 +137,15 @@ 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); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   const handleSaveEdit = async (id: number, texto: string, viñeta: string) => { | ||||
|     try { | ||||
|       await api.actualizarTitular(id, { texto, viñeta: viñeta || null }); | ||||
|     } catch (err) { | ||||
|       console.error("Error al guardar cambios:", err); | ||||
|     } | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Box | ||||
| @@ -176,23 +198,34 @@ const Dashboard = () => { | ||||
|         </AccordionDetails> | ||||
|       </Accordion> | ||||
|  | ||||
|       {isLoading ? ( | ||||
|         <TableSkeleton /> | ||||
|       ) : ( | ||||
|         <TablaTitulares | ||||
|           titulares={titulares} | ||||
|           onReorder={handleReorder} | ||||
|           onDelete={handleDelete} | ||||
|           onEdit={(titular) => setTitularAEditar(titular)} | ||||
|           onSave={handleSaveEdit} | ||||
|         /> | ||||
|       )} | ||||
|  | ||||
|       <AddTitularModal | ||||
|         open={addModalOpen} | ||||
|         onClose={() => setAddModalOpen(false)} | ||||
|         onAdd={handleAdd} | ||||
|       /> | ||||
|  | ||||
|       <ConfirmationModal | ||||
|         open={confirmState.open} | ||||
|         onClose={() => 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." | ||||
|       /> | ||||
|       <EditarTitularModal | ||||
|         open={titularAEditar !== null} | ||||
|         onClose={() => setTitularAEditar(null)} | ||||
|         onSave={handleSaveEdit} | ||||
|         onSave={(id, texto, viñeta) => handleSaveEdit(id, { texto, viñeta: viñeta || null })} | ||||
|         titular={titularAEditar} | ||||
|       /> | ||||
|     </> | ||||
|   | ||||
| @@ -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> | ||||
|   | ||||
| @@ -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, 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'; | ||||
| @@ -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" => { | ||||
| @@ -42,15 +49,43 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <TableRow ref={setNodeRef} style={style} {...attributes} sx={{ '&:last-child td, &:last-child th': { border: 0 } }}> | ||||
|       <TableCell sx={{ cursor: 'grab', verticalAlign: 'middle' }} {...listeners}> | ||||
|     <TableRow | ||||
|       ref={setNodeRef} | ||||
|       style={style} | ||||
|       {...attributes} | ||||
|       sx={{ | ||||
|         '&:hover': { | ||||
|           backgroundColor: 'action.hover' | ||||
|         }, | ||||
|         // Oculta el borde de la última fila | ||||
|         '&:last-child td, &:last-child th': { border: 0 }, | ||||
|       }} | ||||
|     > | ||||
|       <TableCell sx={{ padding: '8px 16px', cursor: 'grab', verticalAlign: 'middle' }} {...listeners}> | ||||
|         <Tooltip title="Arrastrar para reordenar" placement="top"> | ||||
|           <DragHandleIcon sx={{ color: 'text.secondary' }} /> | ||||
|         </Tooltip> | ||||
|       </TableCell> | ||||
|       <TableCell sx={{ verticalAlign: 'middle' }}>{titular.texto}</TableCell> | ||||
|       <TableCell sx={{ verticalAlign: 'middle' }}> | ||||
|       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }} onClick={() => setIsEditing(true)}> | ||||
|         {isEditing ? ( | ||||
|           <TextField | ||||
|             fullWidth | ||||
|             autoFocus | ||||
|             variant="standard" | ||||
|             value={editText} | ||||
|             onChange={(e) => setEditText(e.target.value)} | ||||
|             onBlur={handleSave} | ||||
|             onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }} | ||||
|             onClick={(e) => e.stopPropagation()} | ||||
|           /> | ||||
|         ) : ( | ||||
|           <Typography variant="body2">{titular.texto}</Typography> | ||||
|         )} | ||||
|       </TableCell> | ||||
|       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }}> | ||||
|         <Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" /> | ||||
|       </TableCell> | ||||
|       <TableCell sx={{ verticalAlign: 'middle' }}> | ||||
|       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle' }}> | ||||
|         {titular.urlFuente ? ( | ||||
|           <Link href={titular.urlFuente} target="_blank" rel="noopener noreferrer" underline="hover" color="primary.light"> | ||||
|             {formatFuente(titular.fuente)} | ||||
| @@ -59,13 +94,17 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => { | ||||
|           formatFuente(titular.fuente) | ||||
|         )} | ||||
|       </TableCell> | ||||
|       <TableCell sx={{ verticalAlign: 'middle', textAlign: 'right' }}> | ||||
|       <TableCell sx={{ padding: '8px 16px', verticalAlign: 'middle', textAlign: 'right' }}> | ||||
|         <Tooltip title="Editar viñeta" placement="top"> | ||||
|           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(titular); }}> | ||||
|             <EditIcon fontSize="small" /> | ||||
|           </IconButton> | ||||
|         </Tooltip> | ||||
|         <Tooltip title="Eliminar titular" placement="top"> | ||||
|           <IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }} sx={{ color: '#ef4444' }}> | ||||
|             <DeleteIcon /> | ||||
|           </IconButton> | ||||
|         </Tooltip> | ||||
|       </TableCell> | ||||
|     </TableRow> | ||||
|   ); | ||||
| @@ -76,9 +115,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) => { | ||||
| @@ -105,7 +145,7 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular | ||||
|           <SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}> | ||||
|             <Table> | ||||
|               <TableHead> | ||||
|                 <TableRow sx={{ '& .MuiTableCell-root': { borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> | ||||
|                 <TableRow sx={{ '& .MuiTableCell-root': { padding: '6px 16px', borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> | ||||
|                   <TableCell sx={{ width: 50 }} /> | ||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell> | ||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell> | ||||
| @@ -113,9 +153,9 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular | ||||
|                   <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell> | ||||
|                 </TableRow> | ||||
|               </TableHead> | ||||
|               <TableBody> | ||||
|               <TableBody sx={{ '& .MuiTableRow-root:nth-of-type(odd)': { backgroundColor: 'action.focus' } }}> | ||||
|                 {titulares.map((titular) => ( | ||||
|                   <SortableRow key={titular.id} titular={titular} onDelete={onDelete} onEdit={onEdit} /> | ||||
|                   <SortableRow key={titular.id} titular={titular} onDelete={onDelete} onEdit={onEdit} onSave={onSave} /> | ||||
|                 ))} | ||||
|               </TableBody> | ||||
|             </Table> | ||||
|   | ||||
							
								
								
									
										51
									
								
								frontend/src/components/TableSkeleton.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/components/TableSkeleton.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,51 @@ | ||||
| // frontend/src/components/TableSkeleton.tsx | ||||
|  | ||||
| import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Skeleton } from '@mui/material'; | ||||
|  | ||||
| // Un componente para una única fila de esqueleto | ||||
| const SkeletonRow = () => ( | ||||
|   <TableRow> | ||||
|     <TableCell sx={{ padding: '8px 16px', width: 50 }}> | ||||
|       <Skeleton variant="circular" width={24} height={24} /> | ||||
|     </TableCell> | ||||
|     <TableCell sx={{ padding: '8px 16px' }}> | ||||
|       <Skeleton variant="text" sx={{ fontSize: '1rem' }} /> | ||||
|     </TableCell> | ||||
|     <TableCell sx={{ padding: '8px 16px' }}> | ||||
|       <Skeleton variant="rounded" width={60} height={22} /> | ||||
|     </TableCell> | ||||
|     <TableCell sx={{ padding: '8px 16px' }}> | ||||
|       <Skeleton variant="text" width={80} /> | ||||
|     </TableCell> | ||||
|     <TableCell sx={{ padding: '8px 16px' }} align="right"> | ||||
|       <Skeleton variant="text" width={60} /> | ||||
|     </TableCell> | ||||
|   </TableRow> | ||||
| ); | ||||
|  | ||||
| // El componente principal que renderiza la tabla fantasma | ||||
| export const TableSkeleton = () => { | ||||
|   return ( | ||||
|     <Paper elevation={0} sx={{ overflow: 'hidden' }}> | ||||
|       <TableContainer> | ||||
|         <Table> | ||||
|           <TableHead> | ||||
|             <TableRow sx={{ '& .MuiTableCell-root': { padding: '6px 16px', borderBottom: '1px solid rgba(255, 255, 255, 0.12)' } }}> | ||||
|               <TableCell sx={{ width: 50 }} /> | ||||
|               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Texto del Titular</TableCell> | ||||
|               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Tipo</TableCell> | ||||
|               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em' }}>Fuente</TableCell> | ||||
|               <TableCell sx={{ textTransform: 'uppercase', color: 'text.secondary', letterSpacing: '0.05em', textAlign: 'right' }}>Acciones</TableCell> | ||||
|             </TableRow> | ||||
|           </TableHead> | ||||
|           <TableBody> | ||||
|             {/* Creamos un array de 5 elementos para renderizar 5 filas de esqueleto */} | ||||
|             {[...Array(5)].map((_, index) => ( | ||||
|               <SkeletonRow key={index} /> | ||||
|             ))} | ||||
|           </TableBody> | ||||
|         </Table> | ||||
|       </TableContainer> | ||||
|     </Paper> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										51
									
								
								frontend/src/contexts/NotificationContext.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								frontend/src/contexts/NotificationContext.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -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<NotificationContextType>({ | ||||
|   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<AlertColor>('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 ( | ||||
|     <NotificationContext.Provider value={{ showNotification }}> | ||||
|       {children} | ||||
|       <Snackbar | ||||
|         open={open} | ||||
|         autoHideDuration={6000} // La notificación desaparece después de 6 segundos | ||||
|         onClose={handleClose} | ||||
|         anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} // Posición | ||||
|       > | ||||
|         <Alert onClose={handleClose} severity={severity} sx={{ width: '100%' }}> | ||||
|           {message} | ||||
|         </Alert> | ||||
|       </Snackbar> | ||||
|     </NotificationContext.Provider> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										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; | ||||
| } | ||||
							
								
								
									
										8
									
								
								frontend/src/hooks/useNotification.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								frontend/src/hooks/useNotification.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| // frontend/src/hooks/useNotification.ts | ||||
|  | ||||
| import { useContext } from 'react'; | ||||
| import { NotificationContext } from '../contexts/NotificationContext'; | ||||
|  | ||||
| export const useNotification = () => { | ||||
|   return useContext(NotificationContext); | ||||
| }; | ||||
		Reference in New Issue
	
	Block a user