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