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/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}
+
+
+ Cancelar
+
+ Confirmar
+
+
+
+
+ );
+};
+
+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..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';
@@ -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([]);
const [config, setConfig] = useState(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(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) => {
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 (
<>
{
- setTitularAEditar(titular)}
- />
+ {isLoading ? (
+
+ ) : (
+ setTitularAEditar(titular)}
+ onSave={handleSaveEdit}
+ />
+ )}
setAddModalOpen(false)}
onAdd={handleAdd}
/>
-
+ 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."
+ />
setTitularAEditar(null)}
- onSave={handleSaveEdit}
+ onSave={(id, texto, viñeta) => handleSaveEdit(id, { texto, viñeta: viñeta || null })}
titular={titularAEditar}
/>
>
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! }
-
- {saving ? : 'Guardar Cambios'}
-
+
+ {getSaveStatusIndicator()}
diff --git a/frontend/src/components/TablaTitulares.tsx b/frontend/src/components/TablaTitulares.tsx
index f903278..3ffb112 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, 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" => {
@@ -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 {
@@ -42,15 +49,43 @@ const SortableRow = ({ titular, onDelete, onEdit }: SortableRowProps) => {
}
return (
-
-
-
+
+
+
+
+
- {titular.texto}
-
+ setIsEditing(true)}>
+ {isEditing ? (
+ setEditText(e.target.value)}
+ onBlur={handleSave}
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
+ onClick={(e) => e.stopPropagation()}
+ />
+ ) : (
+ {titular.texto}
+ )}
+
+
-
+
{titular.urlFuente ? (
{formatFuente(titular.fuente)}
@@ -59,13 +94,17 @@ const SortableRow = ({ titular, onDelete, 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' }}>
+
+
+
);
@@ -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
t.id)} strategy={verticalListSortingStrategy}>
-
+
Texto del Titular
Tipo
@@ -113,9 +153,9 @@ const TablaTitulares = ({ titulares, onReorder, onDelete, onEdit }: TablaTitular
Acciones
-
+
{titulares.map((titular) => (
-
+
))}
diff --git a/frontend/src/components/TableSkeleton.tsx b/frontend/src/components/TableSkeleton.tsx
new file mode 100644
index 0000000..89cdb60
--- /dev/null
+++ b/frontend/src/components/TableSkeleton.tsx
@@ -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 = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
+// 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
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/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
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