Fase 3: Refactorizado SignalR a un hook reutilizable (useSignalR) y conectado al Dashboard.

This commit is contained in:
2025-10-28 12:26:49 -03:00
parent 7eee798c99
commit 9be62937bd
8 changed files with 347 additions and 54 deletions

View File

@@ -1,7 +1,9 @@
// backend/src/Titulares.Api/Controllers/TitularesController.cs // backend/src/Titulares.Api/Controllers/TitularesController.cs
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Titulares.Api.Data; using Titulares.Api.Data;
using Titulares.Api.Hubs;
using Titulares.Api.Models; using Titulares.Api.Models;
namespace Titulares.Api.Controllers; namespace Titulares.Api.Controllers;
@@ -11,10 +13,20 @@ namespace Titulares.Api.Controllers;
public class TitularesController : ControllerBase public class TitularesController : ControllerBase
{ {
private readonly TitularRepositorio _repositorio; private readonly TitularRepositorio _repositorio;
private readonly IHubContext<TitularesHub> _hubContext;
public TitularesController(TitularRepositorio repositorio) public TitularesController(TitularRepositorio repositorio, IHubContext<TitularesHub> hubContext)
{ {
_repositorio = repositorio; _repositorio = repositorio;
_hubContext = hubContext;
}
private async Task NotificarCambios()
{
var titularesActualizados = await _repositorio.ObtenerTodosAsync();
// Enviamos un mensaje llamado "TitularesActualizados" a TODOS los clientes conectados
// y les pasamos la lista completa y actualizada.
await _hubContext.Clients.All.SendAsync("TitularesActualizados", titularesActualizados);
} }
[HttpGet] [HttpGet]
@@ -27,40 +39,35 @@ public class TitularesController : ControllerBase
[HttpPost] [HttpPost]
public async Task<IActionResult> CrearManual([FromBody] CrearTitularDto titularDto) public async Task<IActionResult> CrearManual([FromBody] CrearTitularDto titularDto)
{ {
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var nuevoId = await _repositorio.CrearManualAsync(titularDto); var nuevoId = await _repositorio.CrearManualAsync(titularDto);
await NotificarCambios(); // Notificamos después de crear
return CreatedAtAction(nameof(ObtenerTodos), new { id = nuevoId }, null); return CreatedAtAction(nameof(ObtenerTodos), new { id = nuevoId }, null);
} }
[HttpPut("{id}")] [HttpPut("{id}")]
public async Task<IActionResult> Actualizar(int id, [FromBody] ActualizarTitularDto titularDto) public async Task<IActionResult> Actualizar(int id, [FromBody] ActualizarTitularDto titularDto)
{ {
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var resultado = await _repositorio.ActualizarTextoAsync(id, titularDto); var resultado = await _repositorio.ActualizarTextoAsync(id, titularDto);
return resultado ? NoContent() : NotFound(); if (!resultado) return NotFound();
await NotificarCambios(); // Notificamos después de actualizar
return NoContent();
} }
[HttpPut("reordenar")] [HttpPut("reordenar")]
public async Task<IActionResult> Reordenar([FromBody] List<ReordenarTitularDto> ordenes) public async Task<IActionResult> Reordenar([FromBody] List<ReordenarTitularDto> ordenes)
{ {
if (ordenes == null || !ordenes.Any())
{
return BadRequest("La lista de órdenes no puede estar vacía.");
}
var resultado = await _repositorio.ActualizarOrdenAsync(ordenes); var resultado = await _repositorio.ActualizarOrdenAsync(ordenes);
return resultado ? Ok() : StatusCode(500, "Error al actualizar el orden."); if (!resultado) return StatusCode(500, "Error al actualizar el orden.");
await NotificarCambios(); // Notificamos después de reordenar
return Ok();
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
public async Task<IActionResult> Eliminar(int id) public async Task<IActionResult> Eliminar(int id)
{ {
var resultado = await _repositorio.EliminarAsync(id); var resultado = await _repositorio.EliminarAsync(id);
return resultado ? NoContent() : NotFound(); if (!resultado) return NotFound();
await NotificarCambios(); // Notificamos después de eliminar
return NoContent();
} }
} }

View File

@@ -0,0 +1,12 @@
// backend/src/Titulares.Api/Hubs/TitularesHub.cs
using Microsoft.AspNetCore.SignalR;
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
{
}

View File

@@ -1,35 +1,34 @@
// backend/src/Titulares.Api/Program.cs // backend/src/Titulares.Api/Program.cs
using Titulares.Api.Data; using Titulares.Api.Data;
using Titulares.Api.Hubs; // Añadir este using
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// 1. Añadir servicios al contenedor. // Add services to the container.
// ===================================
// Añadimos los servicios para los controladores API
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); builder.Services.AddSwaggerGen();
// Añadimos nuestro repositorio personalizado // Añadimos nuestro repositorio personalizado
builder.Services.AddSingleton<TitularRepositorio>(); builder.Services.AddSingleton<TitularRepositorio>();
// Añadimos los servicios de autorización (necesario para app.UseAuthorization)
builder.Services.AddAuthorization();
// Añadimos la política de CORS // Añadimos la política de CORS
builder.Services.AddCors(options => builder.Services.AddCors(options =>
{ {
options.AddPolicy("AllowReactApp", builder => options.AddPolicy("AllowReactApp", builder =>
{ {
builder.WithOrigins("http://localhost:5174") builder.WithOrigins("http://localhost:5173")
.AllowAnyHeader() .AllowAnyHeader()
.AllowAnyMethod() .AllowAnyMethod()
.AllowCredentials(); .AllowCredentials();
}); });
}); });
builder.Services.AddSignalR();
// Añadimos los servicios de autorización (necesario para app.UseAuthorization)
builder.Services.AddAuthorization();
// 2. Construir la aplicación. // 2. Construir la aplicación.
// ========================== // ==========================
var app = builder.Build(); var app = builder.Build();
@@ -43,15 +42,21 @@ if (app.Environment.IsDevelopment())
app.UseSwaggerUI(); app.UseSwaggerUI();
} }
app.UseHttpsRedirection(); // COMENTAMOS LA REDIRECCIÓN HTTPS PORQUE TRABAJAMOS CON HTTP
// app.UseHttpsRedirection();
// Usamos la política de CORS que definimos // 1. Activa el enrutamiento para que la app sepa a dónde va la petición.
app.UseRouting();
// 2. APLICA LA POLÍTICA DE CORS.
app.UseCors("AllowReactApp"); app.UseCors("AllowReactApp");
// Usamos la autorización // 3. Usamos la autorización.
app.UseAuthorization(); app.UseAuthorization();
// Mapeamos los controladores para que la API responda a las rutas // 4. Mapeamos los endpoints (Controladores y Hubs).
app.MapControllers(); app.MapControllers();
app.MapHub<TitularesHub>("/titularesHub");
app.Run(); app.Run();

View File

@@ -13,6 +13,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@microsoft/signalr": "^9.0.6",
"@mui/icons-material": "^7.3.4", "@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4", "@mui/material": "^7.3.4",
"axios": "^1.13.0", "axios": "^1.13.0",
@@ -1217,6 +1218,19 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@microsoft/signalr": {
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-9.0.6.tgz",
"integrity": "sha512-DrhgzFWI9JE4RPTsHYRxh4yr+OhnwKz8bnJe7eIi7mLLjqhJpEb62CiUy/YbFvLqLzcGzlzz1QWgVAW0zyipMQ==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"eventsource": "^2.0.2",
"fetch-cookie": "^2.0.3",
"node-fetch": "^2.6.7",
"ws": "^7.5.10"
}
},
"node_modules/@mui/core-downloads-tracker": { "node_modules/@mui/core-downloads-tracker": {
"version": "7.3.4", "version": "7.3.4",
"resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz",
@@ -2214,6 +2228,18 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -2904,6 +2930,24 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/eventsource": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz",
"integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==",
"license": "MIT",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2965,6 +3009,16 @@
"reusify": "^1.0.4" "reusify": "^1.0.4"
} }
}, },
"node_modules/fetch-cookie": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz",
"integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==",
"license": "Unlicense",
"dependencies": {
"set-cookie-parser": "^2.4.8",
"tough-cookie": "^4.0.0"
}
},
"node_modules/file-entry-cache": { "node_modules/file-entry-cache": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3593,6 +3647,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.26", "version": "2.0.26",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz",
@@ -3805,16 +3879,33 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"funding": {
"url": "https://github.com/sponsors/lupomontero"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
"license": "MIT"
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3889,6 +3980,12 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@@ -4011,6 +4108,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4158,6 +4261,27 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tough-cookie": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
"integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
"license": "BSD-3-Clause",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
@@ -4235,6 +4359,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"license": "MIT",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
@@ -4276,6 +4409,16 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"license": "MIT",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.12", "version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
@@ -4382,6 +4525,22 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4408,6 +4567,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ws": {
"version": "7.5.10",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz",
"integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==",
"license": "MIT",
"engines": {
"node": ">=8.3.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -15,6 +15,7 @@
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1", "@emotion/styled": "^11.14.1",
"@microsoft/signalr": "^9.0.6",
"@mui/icons-material": "^7.3.4", "@mui/icons-material": "^7.3.4",
"@mui/material": "^7.3.4", "@mui/material": "^7.3.4",
"axios": "^1.13.0", "axios": "^1.13.0",

View File

@@ -1,11 +1,13 @@
// frontend/src/components/Dashboard.tsx // frontend/src/components/Dashboard.tsx
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Box, Button, Typography, Stack } from '@mui/material'; import { Box, Button, Typography, Stack, Chip } from '@mui/material';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import SyncIcon from '@mui/icons-material/Sync'; import SyncIcon from '@mui/icons-material/Sync';
import type { Titular } from '../types'; import type { Titular } from '../types';
import * as api from '../services/apiService'; import * as api from '../services/apiService';
import { useSignalR } from '../hooks/useSignalR';
import FormularioConfiguracion from './FormularioConfiguracion'; import FormularioConfiguracion from './FormularioConfiguracion';
import TablaTitulares from './TablaTitulares'; import TablaTitulares from './TablaTitulares';
import AddTitularModal from './AddTitularModal'; import AddTitularModal from './AddTitularModal';
@@ -14,30 +16,36 @@ const Dashboard = () => {
const [titulares, setTitulares] = useState<Titular[]>([]); const [titulares, setTitulares] = useState<Titular[]>([]);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const cargarTitulares = async () => { // Usamos useCallback para que la función de callback no se recree en cada render,
try { // evitando que el useEffect del hook se ejecute innecesariamente.
const data = await api.obtenerTitulares(); const onTitularesActualizados = useCallback((titularesActualizados: Titular[]) => {
setTitulares(data); console.log("Datos recibidos desde SignalR:", titularesActualizados);
} catch (error) { setTitulares(titularesActualizados);
console.error("Error al cargar titulares:", error); }, []); // El array vacío significa que esta función nunca cambiará
}
};
// Usamos nuestro hook y le pasamos el evento que nos interesa escuchar
const { connectionStatus } = useSignalR([
{ eventName: 'TitularesActualizados', callback: onTitularesActualizados }
]);
// La carga inicial de datos sigue siendo necesaria por si el componente se monta
// antes de que llegue la primera notificación de SignalR.
useEffect(() => { useEffect(() => {
cargarTitulares(); api.obtenerTitulares()
.then(setTitulares)
.catch(error => console.error("Error al cargar titulares:", error));
}, []); }, []);
const handleReorder = async (titularesReordenados: Titular[]) => { const handleReorder = async (titularesReordenados: Titular[]) => {
setTitulares(titularesReordenados); // Actualización optimista de la UI setTitulares(titularesReordenados);
const payload = titularesReordenados.map((item, index) => ({ const payload = titularesReordenados.map((item, index) => ({ id: item.id, nuevoOrden: index }));
id: item.id,
nuevoOrden: index
}));
try { try {
await api.actualizarOrdenTitulares(payload); await api.actualizarOrdenTitulares(payload);
// Ya no necesitamos hacer nada más, SignalR notificará a todos los clientes.
} catch (err) { } catch (err) {
console.error("Error al reordenar:", err); console.error("Error al reordenar:", err);
cargarTitulares(); // Revertir en caso de error // En caso de error, volvemos a pedir los datos para no tener un estado inconsistente.
api.obtenerTitulares().then(setTitulares);
} }
}; };
@@ -45,7 +53,7 @@ const Dashboard = () => {
if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) { if (window.confirm('¿Estás seguro de que quieres eliminar este titular?')) {
try { try {
await api.eliminarTitular(id); await api.eliminarTitular(id);
setTitulares(titulares.filter(t => t.id !== id)); // Actualizar UI // SignalR se encargará de actualizar el estado.
} catch (err) { } catch (err) {
console.error("Error al eliminar:", err); console.error("Error al eliminar:", err);
} }
@@ -55,18 +63,33 @@ const Dashboard = () => {
const handleAdd = async (texto: string) => { const handleAdd = async (texto: string) => {
try { try {
await api.crearTitularManual(texto); await api.crearTitularManual(texto);
cargarTitulares(); // Recargar la lista para ver el nuevo titular // SignalR se encargará de actualizar el estado.
} catch (err) { } catch (err) {
console.error("Error al añadir titular:", err); console.error("Error al añadir titular:", err);
} }
}; };
const getStatusChip = () => {
switch (connectionStatus) {
case 'Connected':
return <Chip label="Conectado" color="success" size="small" />;
case 'Reconnecting':
case 'Connecting':
return <Chip label="Conectando..." color="warning" size="small" />;
default:
return <Chip label="Desconectado" color="error" size="small" />;
}
}
return ( return (
<> <>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Stack direction="row" spacing={2} alignItems="center">
<Typography variant="h4" component="h1"> <Typography variant="h4" component="h1">
Titulares Dashboard Titulares Dashboard
</Typography> </Typography>
{getStatusChip()}
</Stack>
<Stack direction="row" spacing={2}> <Stack direction="row" spacing={2}>
<Button variant="outlined" startIcon={<SyncIcon />}> <Button variant="outlined" startIcon={<SyncIcon />}>
Generate CSV Generate CSV
@@ -79,7 +102,6 @@ const Dashboard = () => {
<FormularioConfiguracion /> <FormularioConfiguracion />
<TablaTitulares titulares={titulares} onReorder={handleReorder} onDelete={handleDelete} /> <TablaTitulares titulares={titulares} onReorder={handleReorder} onDelete={handleDelete} />
<AddTitularModal open={modalOpen} onClose={() => setModalOpen(false)} onAdd={handleAdd} /> <AddTitularModal open={modalOpen} onClose={() => setModalOpen(false)} onAdd={handleAdd} />
</> </>
); );

View File

@@ -0,0 +1,66 @@
// frontend/src/hooks/useSignalR.ts
import { useEffect, useRef, useState } from 'react';
import * as signalR from '@microsoft/signalr';
const HUB_URL = 'http://localhost:5174/titularesHub';
// Definimos un tipo para el estado de la conexión para más claridad
export type ConnectionStatus = 'Connecting' | 'Connected' | 'Disconnected' | 'Reconnecting';
// El hook ahora acepta los listeners como argumentos
export const useSignalR = (listeners: { eventName: string; callback: (...args: any[]) => void }[]) => {
// Usamos useRef para mantener una única instancia de la conexión a través de los re-renders
const connectionRef = useRef<signalR.HubConnection | null>(null);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>('Disconnected');
useEffect(() => {
// Si no hay conexión, la creamos.
if (!connectionRef.current) {
connectionRef.current = new signalR.HubConnectionBuilder()
.withUrl(HUB_URL)
.withAutomaticReconnect()
.build();
}
const connection = connectionRef.current;
// Registramos los listeners que nos pasaron como argumento
listeners.forEach(listener => {
connection.on(listener.eventName, listener.callback);
});
// Manejamos los cambios de estado para feedback en la UI
connection.onreconnecting(() => setConnectionStatus('Reconnecting'));
connection.onreconnected(() => setConnectionStatus('Connected'));
connection.onclose(() => setConnectionStatus('Disconnected'));
// Solo iniciamos la conexión si está desconectada
if (connection.state === signalR.HubConnectionState.Disconnected) {
setConnectionStatus('Connecting');
connection.start()
.then(() => {
setConnectionStatus('Connected');
console.log('SignalR Conectado.');
})
.catch(err => {
console.error('Error de conexión con SignalR: ', err);
setConnectionStatus('Disconnected');
});
}
// --- FUNCIÓN DE LIMPIEZA ---
// Esto se ejecuta cuando el componente que usa el hook se desmonta
return () => {
// Quitamos los listeners para evitar fugas de memoria
listeners.forEach(listener => {
connection.off(listener.eventName, listener.callback);
});
// Opcional: podría detener la conexión aquí si solo un componente la usa.
// connection.stop();
};
}, [listeners]); // El efecto se re-ejecutará si la lista de listeners cambia
return { connectionStatus };
};

View File

@@ -3,7 +3,7 @@
import axios from 'axios'; import axios from 'axios';
import type { Titular } from '../types'; import type { Titular } from '../types';
const API_URL = 'https://localhost:5174/api'; const API_URL = 'http://localhost:5174/api';
const apiClient = axios.create({ const apiClient = axios.create({
baseURL: API_URL, baseURL: API_URL,