Fase 2 Completa: Implementada UI con formulario de configuración, modal de adición y funcionalidad CRUD completa en la tabla de titulares.
This commit is contained in:
@@ -1,14 +1,13 @@
|
|||||||
// frontend/src/App.tsx
|
// frontend/src/App.tsx
|
||||||
|
|
||||||
import { ThemeProvider, createTheme, CssBaseline, Box } from '@mui/material';
|
import { ThemeProvider, createTheme, CssBaseline, Container } from '@mui/material';
|
||||||
import TablaTitulares from './components/TablaTitulares'; // Lo crearemos ahora
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
// Definimos un tema oscuro similar al de la imagen de referencia
|
|
||||||
const darkTheme = createTheme({
|
const darkTheme = createTheme({
|
||||||
palette: {
|
palette: {
|
||||||
mode: 'dark',
|
mode: 'dark',
|
||||||
primary: {
|
primary: {
|
||||||
main: '#3f51b5',
|
main: '#90caf9', // Un azul más claro para mejor contraste en modo oscuro
|
||||||
},
|
},
|
||||||
background: {
|
background: {
|
||||||
default: '#121212',
|
default: '#121212',
|
||||||
@@ -20,12 +19,10 @@ const darkTheme = createTheme({
|
|||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={darkTheme}>
|
<ThemeProvider theme={darkTheme}>
|
||||||
<CssBaseline /> {/* Normaliza el CSS para que se vea bien en todos los navegadores */}
|
<CssBaseline />
|
||||||
<Box sx={{ padding: 3 }}>
|
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||||
<h1>Titulares Dashboard</h1>
|
<Dashboard />
|
||||||
{/* Aquí irán los demás componentes como el formulario de configuración */}
|
</Container>
|
||||||
<TablaTitulares />
|
|
||||||
</Box>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
60
frontend/src/components/AddTitularModal.tsx
Normal file
60
frontend/src/components/AddTitularModal.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
// frontend/src/components/AddTitularModal.tsx
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Modal, Box, Typography, TextField, Button } from '@mui/material';
|
||||||
|
|
||||||
|
// Estilo para el modal, centrado en la pantalla
|
||||||
|
const style = {
|
||||||
|
position: 'absolute' as 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
width: 400,
|
||||||
|
bgcolor: 'background.paper',
|
||||||
|
boxShadow: 24,
|
||||||
|
p: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onAdd: (texto: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddTitularModal = ({ open, onClose, onAdd }: Props) => {
|
||||||
|
const [texto, setTexto] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (texto.trim()) {
|
||||||
|
onAdd(texto.trim());
|
||||||
|
setTexto('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose}>
|
||||||
|
<Box sx={style}>
|
||||||
|
<Typography variant="h6" component="h2">
|
||||||
|
Añadir Titular Manual
|
||||||
|
</Typography>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
margin="normal"
|
||||||
|
label="Texto del titular"
|
||||||
|
variant="outlined"
|
||||||
|
value={texto}
|
||||||
|
onChange={(e) => setTexto(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||||
|
/>
|
||||||
|
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button onClick={onClose} sx={{ mr: 1 }}>Cancelar</Button>
|
||||||
|
<Button variant="contained" onClick={handleSubmit}>Añadir</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddTitularModal;
|
||||||
88
frontend/src/components/Dashboard.tsx
Normal file
88
frontend/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// frontend/src/components/Dashboard.tsx
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Box, Button, Typography, Stack } from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
import type { Titular } from '../types';
|
||||||
|
import * as api from '../services/apiService';
|
||||||
|
import FormularioConfiguracion from './FormularioConfiguracion';
|
||||||
|
import TablaTitulares from './TablaTitulares';
|
||||||
|
import AddTitularModal from './AddTitularModal';
|
||||||
|
|
||||||
|
const Dashboard = () => {
|
||||||
|
const [titulares, setTitulares] = useState<Titular[]>([]);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const cargarTitulares = async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.obtenerTitulares();
|
||||||
|
setTitulares(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error al cargar titulares:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
cargarTitulares();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReorder = async (titularesReordenados: Titular[]) => {
|
||||||
|
setTitulares(titularesReordenados); // Actualización optimista de la UI
|
||||||
|
const payload = titularesReordenados.map((item, index) => ({
|
||||||
|
id: item.id,
|
||||||
|
nuevoOrden: index
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await api.actualizarOrdenTitulares(payload);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al reordenar:", err);
|
||||||
|
cargarTitulares(); // Revertir en caso de error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) {
|
||||||
|
try {
|
||||||
|
await api.eliminarTitular(id);
|
||||||
|
setTitulares(titulares.filter(t => t.id !== id)); // Actualizar UI
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al eliminar:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = async (texto: string) => {
|
||||||
|
try {
|
||||||
|
await api.crearTitularManual(texto);
|
||||||
|
cargarTitulares(); // Recargar la lista para ver el nuevo titular
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error al añadir titular:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Titulares Dashboard
|
||||||
|
</Typography>
|
||||||
|
<Stack direction="row" spacing={2}>
|
||||||
|
<Button variant="outlined" startIcon={<SyncIcon />}>
|
||||||
|
Generate CSV
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setModalOpen(true)}>
|
||||||
|
Add Manual
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<FormularioConfiguracion />
|
||||||
|
<TablaTitulares titulares={titulares} onReorder={handleReorder} onDelete={handleDelete} />
|
||||||
|
|
||||||
|
<AddTitularModal open={modalOpen} onClose={() => setModalOpen(false)} onAdd={handleAdd} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
35
frontend/src/components/FormularioConfiguracion.tsx
Normal file
35
frontend/src/components/FormularioConfiguracion.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// frontend/src/components/FormularioConfiguracion.tsx
|
||||||
|
|
||||||
|
import { Box, TextField, Button, Paper, Typography } from '@mui/material';
|
||||||
|
|
||||||
|
const FormularioConfiguracion = () => {
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{ padding: 2, marginBottom: 3 }}>
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Configuración
|
||||||
|
</Typography>
|
||||||
|
<Box component="form" noValidate autoComplete="off">
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Ruta del archivo CSV"
|
||||||
|
defaultValue="/var/data/headlines.csv"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ marginBottom: 2 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Intervalo de Actualización (minutos)"
|
||||||
|
defaultValue={5}
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ marginBottom: 2 }}
|
||||||
|
/>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button variant="contained">Guardar Cambios</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormularioConfiguracion;
|
||||||
@@ -1,19 +1,22 @@
|
|||||||
// frontend/src/components/TablaTitulares.tsx
|
// frontend/src/components/TablaTitulares.tsx
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton
|
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Paper, Chip, IconButton, Typography
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import DragHandleIcon from '@mui/icons-material/DragHandle'; // Importar el ícono
|
||||||
|
import { DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
|
||||||
import type { Titular } from '../types';
|
import type { Titular } from '../types';
|
||||||
import * as api from '../services/apiService';
|
|
||||||
|
|
||||||
// Componente para una fila de tabla "arrastrable"
|
// La prop `onDelete` se añade para comunicar el evento al componente padre
|
||||||
const SortableRow = ({ titular }: { titular: Titular }) => {
|
interface SortableRowProps {
|
||||||
|
titular: Titular;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SortableRow = ({ titular, onDelete }: SortableRowProps) => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id });
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: titular.id });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
@@ -28,15 +31,19 @@ const SortableRow = ({ titular }: { titular: Titular }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<TableRow ref={setNodeRef} style={style} {...attributes} >
|
||||||
<TableCell>...</TableCell> {/* Handle para arrastrar */}
|
{/* El handle de arrastre ahora es un ícono */}
|
||||||
|
<TableCell sx={{ cursor: 'grab' }} {...listeners}>
|
||||||
|
<DragHandleIcon />
|
||||||
|
</TableCell>
|
||||||
<TableCell>{titular.texto}</TableCell>
|
<TableCell>{titular.texto}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" />
|
<Chip label={titular.tipo || 'Scraped'} color={getChipColor(titular.tipo)} size="small" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{titular.fuente}</TableCell>
|
<TableCell>{titular.fuente}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<IconButton size="small" onClick={() => console.log('Eliminar:', titular.id)}>
|
{/* Usamos un stopPropagation para que el clic no active el arrastre */}
|
||||||
|
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(titular.id); }}>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -44,75 +51,58 @@ const SortableRow = ({ titular }: { titular: Titular }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface TablaTitularesProps {
|
||||||
|
titulares: Titular[];
|
||||||
|
onReorder: (titulares: Titular[]) => void;
|
||||||
|
onDelete: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
// Componente principal de la tabla
|
const TablaTitulares = ({ titulares, onReorder, onDelete }: TablaTitularesProps) => {
|
||||||
const TablaTitulares = () => {
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } })); // Evita activar el drag con un simple clic
|
||||||
const [titulares, setTitulares] = useState<Titular[]>([]);
|
|
||||||
|
|
||||||
// Sensores para dnd-kit: reaccionar a clics de puntero
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
|
||||||
|
|
||||||
const cargarTitulares = async () => {
|
|
||||||
try {
|
|
||||||
const data = await api.obtenerTitulares();
|
|
||||||
setTitulares(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error al cargar titulares:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
cargarTitulares();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDragEnd = (event: any) => {
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (active.id !== over.id) {
|
if (over && active.id !== over.id) {
|
||||||
setTitulares((items) => {
|
const oldIndex = titulares.findIndex((item) => item.id === active.id);
|
||||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
const newIndex = titulares.findIndex((item) => item.id === over.id);
|
||||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
const newArray = arrayMove(titulares, oldIndex, newIndex);
|
||||||
const newArray = arrayMove(items, oldIndex, newIndex);
|
onReorder(newArray); // Pasamos el nuevo array al padre para que gestione el estado y la llamada a la API
|
||||||
|
|
||||||
// Creamos el payload para la API
|
|
||||||
const payload = newArray.map((item, index) => ({
|
|
||||||
id: item.id,
|
|
||||||
nuevoOrden: index
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Llamada a la API en segundo plano
|
|
||||||
api.actualizarOrdenTitulares(payload).catch(err => {
|
|
||||||
console.error("Error al reordenar:", err);
|
|
||||||
// Opcional: revertir el estado si la API falla
|
|
||||||
});
|
|
||||||
|
|
||||||
return newArray;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (titulares.length === 0) {
|
||||||
|
return (
|
||||||
|
<Paper elevation={3} sx={{ padding: 3, textAlign: 'center' }}>
|
||||||
|
<Typography>No hay titulares para mostrar.</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper}>
|
<Paper elevation={3}>
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<TableContainer>
|
||||||
<SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<Table>
|
<SortableContext items={titulares.map(t => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
<TableHead>
|
<Table>
|
||||||
<TableRow>
|
<TableHead>
|
||||||
<TableCell style={{ width: 50 }}></TableCell> {/* Celda para el drag handle */}
|
<TableRow>
|
||||||
<TableCell>Texto del Titular</TableCell>
|
<TableCell style={{ width: 50 }}></TableCell>
|
||||||
<TableCell>Tipo</TableCell>
|
<TableCell>Texto del Titular</TableCell>
|
||||||
<TableCell>Fuente</TableCell>
|
<TableCell>Tipo</TableCell>
|
||||||
<TableCell>Acciones</TableCell>
|
<TableCell>Fuente</TableCell>
|
||||||
</TableRow>
|
<TableCell>Acciones</TableCell>
|
||||||
</TableHead>
|
</TableRow>
|
||||||
<TableBody>
|
</TableHead>
|
||||||
{titulares.map((titular) => (
|
<TableBody>
|
||||||
<SortableRow key={titular.id} titular={titular} />
|
{titulares.map((titular) => (
|
||||||
))}
|
<SortableRow key={titular.id} titular={titular} onDelete={onDelete} />
|
||||||
</TableBody>
|
))}
|
||||||
</Table>
|
</TableBody>
|
||||||
</SortableContext>
|
</Table>
|
||||||
</DndContext>
|
</SortableContext>
|
||||||
</TableContainer>
|
</DndContext>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,11 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Titular } from '../types';
|
import type { Titular } from '../types';
|
||||||
|
|
||||||
// La URL base de nuestra API. Ajusta el puerto si es diferente.
|
|
||||||
const API_URL = 'https://localhost:5174/api';
|
const API_URL = 'https://localhost:5174/api';
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const obtenerTitulares = async (): Promise<Titular[]> => {
|
export const obtenerTitulares = async (): Promise<Titular[]> => {
|
||||||
@@ -18,11 +15,14 @@ export const obtenerTitulares = async (): Promise<Titular[]> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const crearTitularManual = async (texto: string): Promise<void> => {
|
||||||
|
await apiClient.post('/titulares', { texto });
|
||||||
|
};
|
||||||
|
|
||||||
export const eliminarTitular = async (id: number): Promise<void> => {
|
export const eliminarTitular = async (id: number): Promise<void> => {
|
||||||
await apiClient.delete(`/titulares/${id}`);
|
await apiClient.delete(`/titulares/${id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// DTO para el reordenamiento
|
|
||||||
interface ReordenarPayload {
|
interface ReordenarPayload {
|
||||||
id: number;
|
id: number;
|
||||||
nuevoOrden: number;
|
nuevoOrden: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user