prueba tecnica
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
BIN
Próxima Energía_Prueba tecnica full stack .pdf
Normal file
BIN
Próxima Energía_Prueba tecnica full stack .pdf
Normal file
Binary file not shown.
108
README.md
108
README.md
@@ -1,2 +1,108 @@
|
|||||||
# prueba_tecnica_proxima
|
# 🚀 Guía rápida para poner el proyecto en marcha
|
||||||
|
## 🔧 Stack requerido
|
||||||
|
|
||||||
|
Docker Opcional. https://www.docker.com/
|
||||||
|
|
||||||
|
(Este proyecto ha sido desarrollado con Docker para la base de datos en local
|
||||||
|
por lo que es muy recomendable usarlo)
|
||||||
|
|
||||||
|
Node.js & npm Cualquier versión LTS reciente (18 +)
|
||||||
|
https://nodejs.org/es
|
||||||
|
|
||||||
|
.NET 8 SDK Necesario para el backend
|
||||||
|
https://dotnet.microsoft.com/es-es/download
|
||||||
|
|
||||||
|
Si no consigues levantar el proyecto, puedes verlo funcionando en https://quediaempiezo.asarmientotest.es/
|
||||||
|
|
||||||
|
# Levantar proyecto en local
|
||||||
|
|
||||||
|
## 1. Clona el repo:
|
||||||
|
|
||||||
|
git clone https://gitea.alexdev.es/alex/prueba_tecnica_proxima.git
|
||||||
|
cd prueba_tecnica_proxima
|
||||||
|
|
||||||
|
## 1. Arrancar la base de datos 📦
|
||||||
|
|
||||||
|
### desde la raíz del repo
|
||||||
|
|
||||||
|
cd levantarDBSola
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
La imagen de Postgres 16 se inicializa y ejecuta automáticamente los scripts de ../../db
|
||||||
|
(creación de tablas, datos y funciones).
|
||||||
|
|
||||||
|
Cuando el contenedor esté listo puedes cerrar esta terminal.
|
||||||
|
|
||||||
|
### OJO!!! Si no vas a usar docker:
|
||||||
|
Crea la base de datos ejecutando en orden todos los scripts de la carpeta
|
||||||
|
|
||||||
|
## 2. Levantar el backend ⚙️
|
||||||
|
|
||||||
|
### desde la raíz del repo
|
||||||
|
cd backend/ProximaContracts/ProximaContracts.API
|
||||||
|
|
||||||
|
dotnet run # puerto 5009 por defecto
|
||||||
|
|
||||||
|
### Si prefieres un IDE:
|
||||||
|
|
||||||
|
abre la solución backend/ProximaContracts/ProximaContracts.sln en Visual Studio o Rider y ejecuta la API.
|
||||||
|
|
||||||
|
|
||||||
|
Swagger: http://localhost:5009/swagger/index.html
|
||||||
|
|
||||||
|
si ejecutas con Visual Studio irás directo al swagger
|
||||||
|
|
||||||
|
## 3. Iniciar el frontend 🎨
|
||||||
|
|
||||||
|
|
||||||
|
cd front/contracts-frontend
|
||||||
|
|
||||||
|
npm install
|
||||||
|
|
||||||
|
npm run dev # puerto 5173
|
||||||
|
|
||||||
|
Visita: http://localhost:5173/ y ¡listo!
|
||||||
|
|
||||||
|
|
||||||
|
## 🌐 Despliegue en producción
|
||||||
|
Clona el repo:
|
||||||
|
|
||||||
|
git clone https://gitea.alexdev.es/alex/prueba_tecnica_proxima.git
|
||||||
|
cd prueba_tecnica_proxima
|
||||||
|
|
||||||
|
### Configura CORS
|
||||||
|
Edita backend/ProximaContracts/ProximaContracts.API/appsettings.json
|
||||||
|
y pon la URL de tu dominio.
|
||||||
|
|
||||||
|
### Convigura .env.production
|
||||||
|
Edita prueba_tecnica_proxima/front/contracts-frontend/.env.production
|
||||||
|
usando la misma url acabada en /api
|
||||||
|
|
||||||
|
Compila y arranca:
|
||||||
|
|
||||||
|
chmod +x deploy.sh # (solo la primera vez)
|
||||||
|
./deploy.sh
|
||||||
|
|
||||||
|
### DNS + HTTPS
|
||||||
|
|
||||||
|
Asegúrate de que el dominio apunta al servidor.
|
||||||
|
|
||||||
|
Caddy gestionará automáticamente los certificados Let’s Encrypt.
|
||||||
|
|
||||||
|
Ejemplo desplegado: https://quediaempiezo.asarmientotest.es/
|
||||||
|
|
||||||
|
## 🎨 Imágenes
|
||||||
|
|
||||||
|
### Todas las imágenes se encuentran en la carpeta images en la raíz del proyecto
|
||||||
|
|
||||||
|
|  |  |  |
|
||||||
|
|:--:|:--:|:--:|
|
||||||
|
|  |  |  |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# 🤝 Contacto
|
||||||
|
¿Dudas o sugerencias?
|
||||||
|
📧 contact@asarmiento.es | 📞 Llámame cuando quieras.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ProximaContracts.Application.Contracts.Services;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Request;
|
||||||
|
|
||||||
|
namespace ProximaContracts.API.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class ContractsController(IContractService service) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IContractService _service = service;
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("GetAll")]
|
||||||
|
public async Task<IActionResult> GetContracts()
|
||||||
|
{
|
||||||
|
var response = await _service.GetContracts();
|
||||||
|
return response.Any() ? Ok(response) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("GetById")]
|
||||||
|
public async Task<IActionResult> GetContractsById([FromQuery] ContractByIdRequestDto dto)
|
||||||
|
{
|
||||||
|
var response = await _service.GetContractById(dto);
|
||||||
|
return response != null ? Ok(response) : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("CreateContract")]
|
||||||
|
public async Task<IActionResult> CreateContract([FromBody] CreateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = await _service.CreateContract(dto);
|
||||||
|
if(result.IsCreated)
|
||||||
|
{
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequest(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("UpdateContract")]
|
||||||
|
public async Task<IActionResult> UpdateContract([FromBody] UpdateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = await _service.UpdateContract(dto);
|
||||||
|
|
||||||
|
if (result.IsUpdated)
|
||||||
|
{
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return BadRequest(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using ProximaContracts.Application.Rates.Services;
|
||||||
|
|
||||||
|
namespace ProximaContracts.API.Controllers
|
||||||
|
{
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class RatesController(IRateService service) : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IRateService _service = service;
|
||||||
|
[HttpGet("GetAllRates")]
|
||||||
|
public async Task<IActionResult> GetAllRates()
|
||||||
|
{
|
||||||
|
return Ok(await _service.GetRates());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Contract;
|
||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Rates;
|
||||||
|
using System.Diagnostics.Contracts;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||||
|
|
||||||
|
namespace ProximaContracts.API.Middleware
|
||||||
|
{
|
||||||
|
public sealed class ExceptionHandlingMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
||||||
|
|
||||||
|
public ExceptionHandlingMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ILogger<ExceptionHandlingMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
|
||||||
|
_logger.LogError(ex, "Unhandled exception");
|
||||||
|
await HandleExceptionAsync(context, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task HandleExceptionAsync(HttpContext ctx, Exception ex)
|
||||||
|
{
|
||||||
|
|
||||||
|
(HttpStatusCode code, string title) mapping = ex switch
|
||||||
|
{
|
||||||
|
GetContractByIdException => (HttpStatusCode.InternalServerError, "Error getting contract by Id."),
|
||||||
|
GetContractsException => (HttpStatusCode.InternalServerError, "Error getting all contracts."),
|
||||||
|
CreateContractException => (HttpStatusCode.InternalServerError, "Error creating contract."),
|
||||||
|
CreateContractRateNotFoundException => (HttpStatusCode.BadRequest, "Error creating contract =>Rate not found."),
|
||||||
|
UpdateContractException => (HttpStatusCode.InternalServerError, "Error updating contract."),
|
||||||
|
CheckIfRateIdExistsException =>(HttpStatusCode.InternalServerError, "Error checking rates."),
|
||||||
|
GetAllRatesException => (HttpStatusCode.InternalServerError, "Error getting all rates."),
|
||||||
|
GetAllRates404Exception =>(HttpStatusCode.NotFound, "No Rates found."),
|
||||||
|
_ => (HttpStatusCode.InternalServerError, $"Internal server error.")
|
||||||
|
};
|
||||||
|
|
||||||
|
var problem = new
|
||||||
|
{
|
||||||
|
type = $"https://httpstatuses.com/{(int)mapping.code}",
|
||||||
|
title = mapping.title,
|
||||||
|
status = (int)mapping.code,
|
||||||
|
detail = ex.Message,
|
||||||
|
instance = ctx.Request.Path
|
||||||
|
};
|
||||||
|
|
||||||
|
string json = JsonSerializer.Serialize(problem);
|
||||||
|
ctx.Response.ContentType = "application/problem+json";
|
||||||
|
ctx.Response.StatusCode = (int)mapping.code;
|
||||||
|
return ctx.Response.WriteAsync(json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
51
backend/ProximaContracts/ProximaContracts.API/Program.cs
Normal file
51
backend/ProximaContracts/ProximaContracts.API/Program.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
using ProximaContracts.API.Middleware;
|
||||||
|
using ProximaContracts.Application;
|
||||||
|
|
||||||
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("Loopback", p =>
|
||||||
|
p.SetIsOriginAllowed(o => new Uri(o).IsLoopback)
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials());
|
||||||
|
|
||||||
|
options.AddPolicy("ProdDomains", p =>
|
||||||
|
p.WithOrigins(builder.Configuration
|
||||||
|
.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>())
|
||||||
|
.AllowAnyHeader()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowCredentials());
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.AddApplicationDependencies();
|
||||||
|
|
||||||
|
// Add services to the container.
|
||||||
|
|
||||||
|
builder.Services.AddControllers();
|
||||||
|
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var app = builder.Build();
|
||||||
|
|
||||||
|
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||||
|
|
||||||
|
// Configure the HTTP request pipeline.
|
||||||
|
if (app.Environment.IsDevelopment())
|
||||||
|
{
|
||||||
|
app.UseSwagger();
|
||||||
|
app.UseSwaggerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
app.UseCors(app.Environment.IsDevelopment() ? "Loopback" : "ProdDomains");
|
||||||
|
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.Run();
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||||
|
"iisSettings": {
|
||||||
|
"windowsAuthentication": false,
|
||||||
|
"anonymousAuthentication": true,
|
||||||
|
"iisExpress": {
|
||||||
|
"applicationUrl": "http://localhost:27614",
|
||||||
|
"sslPort": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"profiles": {
|
||||||
|
"http": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"applicationUrl": "http://localhost:5009",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"IIS Express": {
|
||||||
|
"commandName": "IISExpress",
|
||||||
|
"launchBrowser": true,
|
||||||
|
"launchUrl": "swagger",
|
||||||
|
"environmentVariables": {
|
||||||
|
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AutoMapper" Version="14.0.0" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Application\ProximaContracts.Application.csproj" />
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Shared\ProximaContracts.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
@ProximaContracts.API_HostAddress = http://localhost:5009
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ConnectionStrings": {
|
||||||
|
"PostgreSQL": "Host=localhost;Port=5432;Database=db.ProximaContracts;Username=proxima_user;Password=Proxima_Password"
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": [
|
||||||
|
"https://quediaempiezo.asarmientotest.es"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"AllowedHosts": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using ProximaContracts.Application.Rates.Services;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Request;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Response;
|
||||||
|
using ProximaContracts.Infrastructure.Rpositories.Contracts;
|
||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Contract;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Application.Contracts.Services
|
||||||
|
{
|
||||||
|
public class ContractService(IContractRepository repository, IMapper mapper, IRateService rateService) : IContractService
|
||||||
|
{
|
||||||
|
private readonly IContractRepository _repository = repository;
|
||||||
|
private readonly IMapper _mapper = mapper;
|
||||||
|
private readonly IRateService _rateService = rateService;
|
||||||
|
public async Task<ContractByIdResponseDto> GetContractById(ContractByIdRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = await _repository.GetContractById(dto);
|
||||||
|
return _mapper.Map< ContractByIdResponseDto>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<GetContractsResponseDto>> GetContracts()
|
||||||
|
{
|
||||||
|
var result = await _repository.GetContracts();
|
||||||
|
return _mapper.Map<List<GetContractsResponseDto>>(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CreateContractResponseDto> CreateContract(CreateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
var rateExists = await _rateService.CheckIfExists(dto.RateId);
|
||||||
|
if (!rateExists)
|
||||||
|
{
|
||||||
|
throw new CreateContractRateNotFoundException($"No rate found with id: {dto.RateId}, Contract can't be created.");
|
||||||
|
}
|
||||||
|
var result = await _repository.CreateContract(dto);
|
||||||
|
|
||||||
|
return new CreateContractResponseDto()
|
||||||
|
{
|
||||||
|
IsCreated = result.HasValue ? true : false,
|
||||||
|
Message = result.HasValue ? "Created" : "Error creating contract",
|
||||||
|
NewContractId = result.HasValue ? result.Value : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<UpdateContractResponseDto> UpdateContract(UpdateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
var result = await _repository.UpdateContract(dto);
|
||||||
|
return new UpdateContractResponseDto()
|
||||||
|
{
|
||||||
|
IsUpdated = result.HasValue ? true : false,
|
||||||
|
Message = result.HasValue ? "Updated" : "Error updating contract",
|
||||||
|
ContractId = result.HasValue ? result.Value : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Request;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Response;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Application.Contracts.Services
|
||||||
|
{
|
||||||
|
public interface IContractService
|
||||||
|
{
|
||||||
|
|
||||||
|
Task<ContractByIdResponseDto> GetContractById(ContractByIdRequestDto dto);
|
||||||
|
Task<IEnumerable<GetContractsResponseDto>> GetContracts();
|
||||||
|
Task<CreateContractResponseDto> CreateContract(CreateContractRequestDto dto);
|
||||||
|
Task<UpdateContractResponseDto> UpdateContract(UpdateContractRequestDto dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ProximaContracts.Application.Contracts.Services;
|
||||||
|
using ProximaContracts.Application.Rates.Services;
|
||||||
|
using ProximaContracts.Domain.Contracts.Mappings;
|
||||||
|
using ProximaContracts.Infrastructure.Rpositories.Contracts;
|
||||||
|
using ProximaContracts.Infrastructure.Rpositories.Rates;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Application
|
||||||
|
{
|
||||||
|
public static class IoCConfiguration
|
||||||
|
{
|
||||||
|
public static IServiceCollection AddApplicationDependencies(this IServiceCollection services)
|
||||||
|
{
|
||||||
|
AddServices(services);
|
||||||
|
AddRepositories(services);
|
||||||
|
AddAutommaperProfiles(services);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddServices(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IContractService, ContractService>();
|
||||||
|
services.AddScoped<IRateService, RateService>();
|
||||||
|
}
|
||||||
|
private static void AddRepositories(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddScoped<IContractRepository, ContractRepository>();
|
||||||
|
services.AddScoped<IRateRepository, RateRepository>();
|
||||||
|
}
|
||||||
|
private static void AddAutommaperProfiles(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddAutoMapper(typeof(ContractProfile).Assembly);
|
||||||
|
services.AddAutoMapper(typeof(RateProfile).Assembly);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Domain\ProximaContracts.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Infrastructure\ProximaContracts.Infrastructure.csproj" />
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Shared\ProximaContracts.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ProximaContracts.Domain.Rates.DTOs.Responses;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Application.Rates.Services
|
||||||
|
{
|
||||||
|
public interface IRateService
|
||||||
|
{
|
||||||
|
Task<bool> CheckIfExists(int id);
|
||||||
|
Task<IEnumerable<GetAllRatesDto>> GetRates();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using ProximaContracts.Domain.Rates.DTOs.Responses;
|
||||||
|
using ProximaContracts.Infrastructure.Rpositories.Rates;
|
||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Rates;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Application.Rates.Services
|
||||||
|
{
|
||||||
|
public class RateService(IRateRepository repository, IMapper mapper) : IRateService
|
||||||
|
{
|
||||||
|
private readonly IRateRepository _repository = repository;
|
||||||
|
private readonly IMapper _mapper = mapper;
|
||||||
|
|
||||||
|
public async Task<bool> CheckIfExists(int id)
|
||||||
|
{
|
||||||
|
return await _repository.CheckIfExists(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<GetAllRatesDto>> GetRates()
|
||||||
|
{
|
||||||
|
var response = await _repository.GetRates();
|
||||||
|
if (!response.Any())
|
||||||
|
{
|
||||||
|
throw new GetAllRates404Exception("No Rates found");
|
||||||
|
}
|
||||||
|
return _mapper.Map<List<GetAllRatesDto>>(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Request
|
||||||
|
{
|
||||||
|
public class ContractByIdRequestDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Request
|
||||||
|
{
|
||||||
|
public class CreateContractRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[MaxLength(20)]
|
||||||
|
public string ContractorIdNumber { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[MaxLength(50)]
|
||||||
|
public string ContractorName { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[MaxLength(100)]
|
||||||
|
public string ContractorSurname { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Range(1, int.MaxValue, ErrorMessage = "RateId must be greater than 0.")]
|
||||||
|
public int RateId { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Request
|
||||||
|
{
|
||||||
|
public class UpdateContractRequestDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public int ContractId { get; set; }
|
||||||
|
[Required]
|
||||||
|
public int RateId { get; set; }
|
||||||
|
public string? ContractorIdNumber { get; set; }
|
||||||
|
public string? ContractorName { get; set; }
|
||||||
|
public string? ContractorSurname { get; set; }
|
||||||
|
public DateTime? ContractInitDate { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Response
|
||||||
|
{
|
||||||
|
public class ContractByIdResponseDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ContractorIdNumber { get; set; }
|
||||||
|
public string ContractorName { get; set; }
|
||||||
|
public string ContractorSurname { get; set; }
|
||||||
|
public DateTime ContractInitDate { get; set; }
|
||||||
|
public int RateId { get; set; }
|
||||||
|
public string RateName { get; set; }
|
||||||
|
public decimal RatePrice { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Response
|
||||||
|
{
|
||||||
|
public class CreateContractResponseDto
|
||||||
|
{
|
||||||
|
public bool IsCreated { get; set; }
|
||||||
|
public int NewContractId { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Response
|
||||||
|
{
|
||||||
|
public class GetContractsResponseDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ContractorName { get; set; }
|
||||||
|
public string ContractorSurname { get; set; }
|
||||||
|
public DateTime ContractInitDate { get; set; }
|
||||||
|
public string RateName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.DTOs.Response
|
||||||
|
{
|
||||||
|
public class UpdateContractResponseDto
|
||||||
|
{
|
||||||
|
public bool IsUpdated { get; set; }
|
||||||
|
public int ContractId { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.Entities
|
||||||
|
{
|
||||||
|
public class ContractByIdEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ContractorIdNumber { get; set; }
|
||||||
|
public string ContractorName { get; set; }
|
||||||
|
public string ContractorSurname { get; set; }
|
||||||
|
public DateTime ContractInitDate { get; set; }
|
||||||
|
public int RateId { get; set; }
|
||||||
|
public string RateName { get; set; }
|
||||||
|
public decimal RatePrice { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace ProximaContracts.Domain.Contracts.Entities
|
||||||
|
{
|
||||||
|
public class GetContractsEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string ContractorName { get; set; }
|
||||||
|
public string ContractorSurname { get; set; }
|
||||||
|
public DateTime ContractInitDate { get; set; }
|
||||||
|
public string RateName { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Npgsql;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Response;
|
||||||
|
using ProximaContracts.Domain.Contracts.Entities;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Contracts.Mappings
|
||||||
|
{
|
||||||
|
public class ContractProfile : Profile
|
||||||
|
{
|
||||||
|
public ContractProfile()
|
||||||
|
{
|
||||||
|
#region Contract By ID
|
||||||
|
CreateMap<NpgsqlDataReader, ContractByIdEntity>()
|
||||||
|
.ForMember(d => d.Id, o => o.MapFrom(s => s.GetInt32(s.GetOrdinal("Id"))))
|
||||||
|
.ForMember(d => d.ContractorIdNumber, o => o.MapFrom(s => s.GetString(s.GetOrdinal("ContractorIdNumber"))))
|
||||||
|
.ForMember(d => d.ContractorName, o => o.MapFrom(s => s.GetString(s.GetOrdinal("ContractorName"))))
|
||||||
|
.ForMember(d => d.ContractorSurname, o => o.MapFrom(s => s.GetString(s.GetOrdinal("ContractorSurname"))))
|
||||||
|
.ForMember(d => d.ContractInitDate, o => o.MapFrom(s => s.GetDateTime(s.GetOrdinal("ContractInitDate"))))
|
||||||
|
.ForMember(d => d.RateId, o => o.MapFrom(s => s.GetInt32(s.GetOrdinal("RateId"))))
|
||||||
|
.ForMember(d => d.RateName, o => o.MapFrom(s => s.GetString(s.GetOrdinal("RateName"))))
|
||||||
|
.ForMember(d => d.RatePrice, o => o.MapFrom(s => s.GetFieldValue<decimal>(s.GetOrdinal("RatePrice"))))
|
||||||
|
;
|
||||||
|
|
||||||
|
CreateMap<ContractByIdEntity, ContractByIdResponseDto>();
|
||||||
|
#endregion ContractByID
|
||||||
|
|
||||||
|
#region Contracts All
|
||||||
|
CreateMap<NpgsqlDataReader, GetContractsEntity>()
|
||||||
|
.ForMember(d => d.Id, o => o.MapFrom(s => s.GetInt32(s.GetOrdinal("Id"))))
|
||||||
|
.ForMember(d => d.ContractorName, o => o.MapFrom(s => s.GetString(s.GetOrdinal("ContractorName"))))
|
||||||
|
.ForMember(d => d.ContractorSurname, o => o.MapFrom(s => s.GetString(s.GetOrdinal("ContractorSurname"))))
|
||||||
|
.ForMember(d => d.ContractInitDate, o => o.MapFrom(s => s.GetDateTime(s.GetOrdinal("ContractInitDate"))))
|
||||||
|
.ForMember(d => d.RateName, o => o.MapFrom(s => s.GetString(s.GetOrdinal("RateName"))))
|
||||||
|
;
|
||||||
|
|
||||||
|
CreateMap<GetContractsEntity, GetContractsResponseDto>();
|
||||||
|
#endregion Contracts All
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AutoMapper" Version="14.0.0" />
|
||||||
|
<PackageReference Include="Npgsql" Version="8.0.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Shared\ProximaContracts.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Data.SqlTypes;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Rates.DTOs.Responses
|
||||||
|
{
|
||||||
|
public class GetAllRatesDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using System.Data.SqlTypes;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Rates.Entities
|
||||||
|
{
|
||||||
|
public class RateEntity
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Npgsql;
|
||||||
|
using ProximaContracts.Domain.Rates.DTOs.Responses;
|
||||||
|
using ProximaContracts.Domain.Rates.Entities;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Domain.Contracts.Mappings
|
||||||
|
{
|
||||||
|
public class RateProfile : Profile
|
||||||
|
{
|
||||||
|
public RateProfile()
|
||||||
|
{
|
||||||
|
#region Rates All
|
||||||
|
CreateMap<NpgsqlDataReader, RateEntity>()
|
||||||
|
.ForMember(d => d.Id, o => o.MapFrom(s => s.GetInt32(s.GetOrdinal("Id"))))
|
||||||
|
.ForMember(d => d.Name, o => o.MapFrom(s => s.GetString(s.GetOrdinal("Name"))))
|
||||||
|
.ForMember(d => d.Price, o => o.MapFrom(r => r.GetDecimal(r.GetOrdinal("Price"))))
|
||||||
|
;
|
||||||
|
|
||||||
|
CreateMap<RateEntity, GetAllRatesDto>();
|
||||||
|
#endregion Rates All
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
|
||||||
|
<PackageReference Include="Npgsql" Version="8.0.7" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Domain\ProximaContracts.Domain.csproj" />
|
||||||
|
<ProjectReference Include="..\ProximaContracts.Shared\ProximaContracts.Shared.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
public static class ContractFS
|
||||||
|
{
|
||||||
|
public static string GetContractById = "SELECT * FROM public.get_contract_by_id(@p_contract_id);";
|
||||||
|
public static string GetContracts = "SELECT * FROM public.get_contracts();";
|
||||||
|
public static string CreateContract = "SELECT * FROM public.create_contract(@p_contractor_id_number, @p_contractor_name, @p_contractor_surname, @p_contract_init_date, @p_rate_id);";
|
||||||
|
public static string UpdateContract = """
|
||||||
|
SELECT * FROM public.update_contract(@p_contract_id, @p_rate_id, @p_contractor_id_number,
|
||||||
|
@p_contractor_name, @p_contractor_surname, @p_contract_init_date)
|
||||||
|
""";
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Request;
|
||||||
|
using ProximaContracts.Domain.Contracts.Entities;
|
||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Contract;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Infrastructure.Rpositories.Contracts
|
||||||
|
{
|
||||||
|
public class ContractRepository(IConfiguration config, ILogger<ContractRepository> logger, IMapper mapper) : IContractRepository
|
||||||
|
{
|
||||||
|
private readonly string _connStr = config.GetConnectionString("PostgreSQL")!;
|
||||||
|
private readonly ILogger<ContractRepository> _log = logger;
|
||||||
|
private readonly IMapper _mapper = mapper;
|
||||||
|
|
||||||
|
public async Task<ContractByIdEntity?> GetContractById(ContractByIdRequestDto dto)
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(ContractFS.GetContractById, conn);
|
||||||
|
cmd.Parameters.AddWithValue("p_contract_id", NpgsqlDbType.Integer, dto.Id);
|
||||||
|
|
||||||
|
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
if (!await reader.ReadAsync()) return null;
|
||||||
|
|
||||||
|
return _mapper.Map<ContractByIdEntity>(reader);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new GetContractByIdException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<GetContractsEntity>> GetContracts()
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(ContractFS.GetContracts, conn);
|
||||||
|
|
||||||
|
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
var results = new List<GetContractsEntity>();
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
results.Add(_mapper.Map<GetContractsEntity>(reader));
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new GetContractsException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> CreateContract(CreateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(ContractFS.CreateContract, conn);
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_id_number", NpgsqlDbType.Varchar, dto.ContractorIdNumber);
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_name", NpgsqlDbType.Varchar, dto.ContractorName);
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_surname", NpgsqlDbType.Varchar, dto.ContractorSurname);
|
||||||
|
cmd.Parameters.AddWithValue("p_contract_init_date", NpgsqlDbType.Timestamp, DateTime.Now);
|
||||||
|
cmd.Parameters.AddWithValue("p_rate_id", NpgsqlDbType.Integer, dto.RateId);
|
||||||
|
|
||||||
|
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
if (!await reader.ReadAsync()) return null;
|
||||||
|
|
||||||
|
var result = reader.GetInt32(reader.GetOrdinal("create_contract"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new CreateContractException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> UpdateContract(UpdateContractRequestDto dto)
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(ContractFS.UpdateContract, conn);
|
||||||
|
cmd.Parameters.AddWithValue("p_contract_id", NpgsqlDbType.Integer, dto.ContractId);
|
||||||
|
cmd.Parameters.AddWithValue("p_rate_id", NpgsqlDbType.Integer, dto.RateId);
|
||||||
|
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_id_number", NpgsqlDbType.Varchar, dto.ContractorIdNumber != null ? dto.ContractorIdNumber : DBNull.Value);
|
||||||
|
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_name", NpgsqlDbType.Varchar, dto.ContractorName != null ? dto.ContractorName : DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("p_contractor_surname", NpgsqlDbType.Varchar, dto.ContractorSurname != null ? dto.ContractorSurname : DBNull.Value);
|
||||||
|
cmd.Parameters.AddWithValue("p_contract_init_date", NpgsqlDbType.Timestamp, dto.ContractInitDate != null ? dto.ContractInitDate : DBNull.Value);
|
||||||
|
|
||||||
|
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
if (!await reader.ReadAsync()) return null;
|
||||||
|
|
||||||
|
var result = reader.GetInt32(reader.GetOrdinal("update_contract"));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new UpdateContractException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using ProximaContracts.Domain.Contracts.DTOs.Request;
|
||||||
|
using ProximaContracts.Domain.Contracts.Entities;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Infrastructure.Rpositories.Contracts
|
||||||
|
{
|
||||||
|
public interface IContractRepository
|
||||||
|
{
|
||||||
|
Task<ContractByIdEntity?> GetContractById(ContractByIdRequestDto dto);
|
||||||
|
Task<IEnumerable<GetContractsEntity>> GetContracts();
|
||||||
|
Task<int?> CreateContract(CreateContractRequestDto dto);
|
||||||
|
Task<int?> UpdateContract(UpdateContractRequestDto dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using ProximaContracts.Domain.Rates.Entities;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Infrastructure.Rpositories.Rates
|
||||||
|
{
|
||||||
|
public interface IRateRepository
|
||||||
|
{
|
||||||
|
Task<bool> CheckIfExists(int Id);
|
||||||
|
Task<IEnumerable<RateEntity>> GetRates();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
public static class RateFS
|
||||||
|
{
|
||||||
|
public static string CheckIfExists = "SELECT public.check_rate_exists(@p_id);";
|
||||||
|
public static string GetRates = "SELECT * FROM public.get_rates();";
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Npgsql;
|
||||||
|
using NpgsqlTypes;
|
||||||
|
using ProximaContracts.Domain.Rates.Entities;
|
||||||
|
using ProximaContracts.Shared.Exceptions.Repositories.Rates;
|
||||||
|
|
||||||
|
namespace ProximaContracts.Infrastructure.Rpositories.Rates
|
||||||
|
{
|
||||||
|
public class RateRepository(IConfiguration config, ILogger<RateRepository> logger, IMapper mapper) : IRateRepository
|
||||||
|
{
|
||||||
|
private readonly string _connStr = config.GetConnectionString("PostgreSQL")!;
|
||||||
|
private readonly ILogger<RateRepository> _log = logger;
|
||||||
|
private readonly IMapper _mapper = mapper;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<bool> CheckIfExists(int Id)
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(RateFS.CheckIfExists, conn);
|
||||||
|
cmd.Parameters.AddWithValue("p_id", NpgsqlDbType.Integer, Id);
|
||||||
|
|
||||||
|
var result = await cmd.ExecuteScalarAsync();
|
||||||
|
|
||||||
|
return Convert.ToInt32(result) == 1;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new CheckIfRateIdExistsException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<IEnumerable<RateEntity>> GetRates()
|
||||||
|
{
|
||||||
|
await using var conn = new NpgsqlConnection(_connStr);
|
||||||
|
await conn.OpenAsync();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var cmd = new NpgsqlCommand(RateFS.GetRates, conn);
|
||||||
|
|
||||||
|
await using NpgsqlDataReader reader = await cmd.ExecuteReaderAsync();
|
||||||
|
var results = new List<RateEntity>();
|
||||||
|
|
||||||
|
while (await reader.ReadAsync())
|
||||||
|
{
|
||||||
|
results.Add(_mapper.Map<RateEntity>(reader));
|
||||||
|
}
|
||||||
|
await conn.CloseAsync();
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_log.LogError(ex.Message);
|
||||||
|
throw new GetAllRatesException(ex.Message);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await conn.CloseAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
|
||||||
|
{
|
||||||
|
public sealed class CreateContractException : Exception
|
||||||
|
{
|
||||||
|
public CreateContractException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
|
||||||
|
{
|
||||||
|
public sealed class CreateContractRateNotFoundException : Exception
|
||||||
|
{
|
||||||
|
public CreateContractRateNotFoundException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
|
||||||
|
{
|
||||||
|
public sealed class GetContractByIdException : Exception
|
||||||
|
{
|
||||||
|
public GetContractByIdException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
|
||||||
|
{
|
||||||
|
public sealed class GetContractsException : Exception
|
||||||
|
{
|
||||||
|
public GetContractsException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
|
||||||
|
{
|
||||||
|
public sealed class UpdateContractException : Exception
|
||||||
|
{
|
||||||
|
public UpdateContractException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
|
||||||
|
{
|
||||||
|
public sealed class CheckIfRateIdExistsException : Exception
|
||||||
|
{
|
||||||
|
public CheckIfRateIdExistsException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
|
||||||
|
{
|
||||||
|
public sealed class GetAllRates404Exception : Exception
|
||||||
|
{
|
||||||
|
public GetAllRates404Exception(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
|
||||||
|
{
|
||||||
|
public sealed class GetAllRatesException : Exception
|
||||||
|
{
|
||||||
|
public GetAllRatesException(string message) : base(message) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
49
backend/ProximaContracts/ProximaContracts.sln
Normal file
49
backend/ProximaContracts/ProximaContracts.sln
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
# Visual Studio Version 17
|
||||||
|
VisualStudioVersion = 17.14.36127.28 d17.14
|
||||||
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProximaContracts.API", "ProximaContracts.API\ProximaContracts.API.csproj", "{2FCFBFDD-95E3-4D27-8DB5-37B59321EF11}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProximaContracts.Application", "ProximaContracts.Application\ProximaContracts.Application.csproj", "{0B573051-EAD0-48D4-ABDF-A4E0A6247EFF}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProximaContracts.Domain", "ProximaContracts.Domain\ProximaContracts.Domain.csproj", "{67CA7F33-0D6F-46A0-A7C9-58929E153C2A}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProximaContracts.Infrastructure", "ProximaContracts.Infrastructure\ProximaContracts.Infrastructure.csproj", "{4F8BB408-0111-4DCF-B9CD-EC1D9D7B3EE4}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ProximaContracts.Shared", "ProximaContracts.Shared\ProximaContracts.Shared.csproj", "{726F71DA-CADE-2203-4F7D-F88480F80337}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{2FCFBFDD-95E3-4D27-8DB5-37B59321EF11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{2FCFBFDD-95E3-4D27-8DB5-37B59321EF11}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{2FCFBFDD-95E3-4D27-8DB5-37B59321EF11}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{2FCFBFDD-95E3-4D27-8DB5-37B59321EF11}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{0B573051-EAD0-48D4-ABDF-A4E0A6247EFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{0B573051-EAD0-48D4-ABDF-A4E0A6247EFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{0B573051-EAD0-48D4-ABDF-A4E0A6247EFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{0B573051-EAD0-48D4-ABDF-A4E0A6247EFF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{67CA7F33-0D6F-46A0-A7C9-58929E153C2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{67CA7F33-0D6F-46A0-A7C9-58929E153C2A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{67CA7F33-0D6F-46A0-A7C9-58929E153C2A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{67CA7F33-0D6F-46A0-A7C9-58929E153C2A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4F8BB408-0111-4DCF-B9CD-EC1D9D7B3EE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4F8BB408-0111-4DCF-B9CD-EC1D9D7B3EE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4F8BB408-0111-4DCF-B9CD-EC1D9D7B3EE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4F8BB408-0111-4DCF-B9CD-EC1D9D7B3EE4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{726F71DA-CADE-2203-4F7D-F88480F80337}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{726F71DA-CADE-2203-4F7D-F88480F80337}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{726F71DA-CADE-2203-4F7D-F88480F80337}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{726F71DA-CADE-2203-4F7D-F88480F80337}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
|
HideSolutionNode = FALSE
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
|
SolutionGuid = {F9B629D6-70F9-456E-802E-0A7C534C00A8}
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
60
db/01_CreateDataBaseAndTables.sql
Normal file
60
db/01_CreateDataBaseAndTables.sql
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
DO
|
||||||
|
$$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_database WHERE datname = 'db.ProximaContracts') THEN
|
||||||
|
CREATE DATABASE "db.ProximaContracts";
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
\c "db.ProximaContracts";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.Rates (
|
||||||
|
Id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
Name VARCHAR(100) NOT NULL,
|
||||||
|
Price MONEY NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.Contracts (
|
||||||
|
Id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
ContractorIdNumber VARCHAR(20) NOT NULL,
|
||||||
|
ContractorName VARCHAR(50) NOT NULL,
|
||||||
|
ContractorSurname VARCHAR(100) NOT NULL,
|
||||||
|
ContractInitDate TIMESTAMP NOT NULL,
|
||||||
|
RateId INTEGER NOT NULL,
|
||||||
|
CONSTRAINT fk_contracts_rates
|
||||||
|
FOREIGN KEY (RateId)
|
||||||
|
REFERENCES public.Rates (Id)
|
||||||
|
ON UPDATE CASCADE
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_contracts_rateid
|
||||||
|
ON public.Contracts (RateId);
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO public.Rates
|
||||||
|
(Name, Price)
|
||||||
|
VALUES
|
||||||
|
('Basic', 0.10),
|
||||||
|
('Economy', 0.12),
|
||||||
|
('Standard', 0.15),
|
||||||
|
('Premium', 0.20),
|
||||||
|
('Ultra', 0.25);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
INSERT INTO public.Contracts
|
||||||
|
(ContractorIdNumber,
|
||||||
|
ContractorName,
|
||||||
|
ContractorSurname,
|
||||||
|
ContractInitDate,
|
||||||
|
RateId)
|
||||||
|
VALUES
|
||||||
|
('A12345678', 'John', 'Smith', '2025-01-15 10:00:00', 1),
|
||||||
|
('B23456789', 'Emily', 'Johnson', '2025-02-20 11:30:00', 2),
|
||||||
|
('C34567890', 'Carlos', 'García', '2025-03-05 09:00:00', 3),
|
||||||
|
('D45678901', 'Sofía', 'Martínez', '2025-04-12 14:45:00', 4),
|
||||||
|
('E56789012', 'Liam', 'Brown', '2025-05-01 08:20:00', 5);
|
||||||
48
db/02_CreateContract.sql
Normal file
48
db/02_CreateContract.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
DROP FUNCTION IF EXISTS public.create_contract (
|
||||||
|
VARCHAR(20), -- p_contractor_id_number
|
||||||
|
VARCHAR(50), -- p_contractor_name
|
||||||
|
VARCHAR(100), -- p_contractor_surname
|
||||||
|
TIMESTAMP, -- p_contract_init_date
|
||||||
|
INTEGER -- p_rate_id
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_contract (
|
||||||
|
p_contractor_id_number VARCHAR(20),
|
||||||
|
p_contractor_name VARCHAR(50),
|
||||||
|
p_contractor_surname VARCHAR(100),
|
||||||
|
p_contract_init_date TIMESTAMP,
|
||||||
|
p_rate_id INTEGER
|
||||||
|
)
|
||||||
|
RETURNS INTEGER
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_new_id INTEGER;
|
||||||
|
BEGIN
|
||||||
|
PERFORM 1
|
||||||
|
FROM public.rates
|
||||||
|
WHERE id = p_rate_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'No reate found with id %', p_rate_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.contracts (
|
||||||
|
contractoridnumber,
|
||||||
|
contractorname,
|
||||||
|
contractorsurname,
|
||||||
|
contractinitdate,
|
||||||
|
rateid
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
p_contractor_id_number,
|
||||||
|
p_contractor_name,
|
||||||
|
p_contractor_surname,
|
||||||
|
p_contract_init_date,
|
||||||
|
p_rate_id
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_new_id;
|
||||||
|
|
||||||
|
RETURN v_new_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql VOLATILE;
|
||||||
23
db/03_GetAllContracts.sql
Normal file
23
db/03_GetAllContracts.sql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.get_contracts();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_contracts()
|
||||||
|
RETURNS TABLE (
|
||||||
|
Id INTEGER,
|
||||||
|
ContractorName VARCHAR,
|
||||||
|
ContractorSurname VARCHAR,
|
||||||
|
ContractInitDate TIMESTAMP,
|
||||||
|
RateName VARCHAR
|
||||||
|
)
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.contractorname,
|
||||||
|
c.contractorsurname,
|
||||||
|
c.contractinitdate,
|
||||||
|
r.name AS RateName
|
||||||
|
FROM public.contracts AS c
|
||||||
|
JOIN public.rates AS r
|
||||||
|
ON r.id = c.rateid
|
||||||
|
ORDER BY c.id;
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
25
db/04_GetContractById.sql
Normal file
25
db/04_GetContractById.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
DROP FUNCTION IF EXISTS public.get_contract_by_id(integer);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_contract_by_id(p_contract_id INTEGER)
|
||||||
|
RETURNS TABLE (
|
||||||
|
Id INTEGER,
|
||||||
|
ContractorIdNumber VARCHAR(20),
|
||||||
|
ContractorName VARCHAR(50),
|
||||||
|
ContractorSurname VARCHAR(100),
|
||||||
|
ContractInitDate TIMESTAMP,
|
||||||
|
RateId INTEGER,
|
||||||
|
RateName VARCHAR(100),
|
||||||
|
RatePrice MONEY
|
||||||
|
) AS $$
|
||||||
|
SELECT c.Id,
|
||||||
|
c.ContractorIdNumber,
|
||||||
|
c.ContractorName,
|
||||||
|
c.ContractorSurname,
|
||||||
|
c.ContractInitDate,
|
||||||
|
c.RateId,
|
||||||
|
r.Name AS RateName,
|
||||||
|
r.Price AS RatePrice
|
||||||
|
FROM public.Contracts c
|
||||||
|
JOIN public.Rates r ON r.Id = c.RateId
|
||||||
|
WHERE c.Id = p_contract_id;
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
48
db/05_UpdateContract.sql
Normal file
48
db/05_UpdateContract.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
DROP FUNCTION IF EXISTS public.update_contract (
|
||||||
|
INTEGER,
|
||||||
|
INTEGER,
|
||||||
|
VARCHAR(20),
|
||||||
|
VARCHAR(50),
|
||||||
|
VARCHAR(100),
|
||||||
|
TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.update_contract (
|
||||||
|
p_contract_id INTEGER,
|
||||||
|
p_rate_id INTEGER,
|
||||||
|
p_contractor_id_number VARCHAR(20) DEFAULT NULL,
|
||||||
|
p_contractor_name VARCHAR(50) DEFAULT NULL,
|
||||||
|
p_contractor_surname VARCHAR(100) DEFAULT NULL,
|
||||||
|
p_contract_init_date TIMESTAMP DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS INTEGER
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM 1
|
||||||
|
FROM public.contracts
|
||||||
|
WHERE id = p_contract_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'No existe ningún contrato con id %', p_contract_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
PERFORM 1
|
||||||
|
FROM public.rates
|
||||||
|
WHERE id = p_rate_id;
|
||||||
|
IF NOT FOUND THEN
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'No existe ninguna tarifa con id %', p_rate_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.contracts
|
||||||
|
SET
|
||||||
|
contractoridnumber = COALESCE(p_contractor_id_number, contractoridnumber),
|
||||||
|
contractorname = COALESCE(p_contractor_name, contractorname),
|
||||||
|
contractorsurname = COALESCE(p_contractor_surname, contractorsurname),
|
||||||
|
contractinitdate = COALESCE(p_contract_init_date, contractinitdate),
|
||||||
|
rateid = p_rate_id
|
||||||
|
WHERE id = p_contract_id;
|
||||||
|
|
||||||
|
RETURN p_contract_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql VOLATILE;
|
||||||
15
db/06_CheckRateExists.sql
Normal file
15
db/06_CheckRateExists.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
DROP FUNCTION IF EXISTS public.check_rate_exists(INTEGER);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_rate_exists(p_id INTEGER)
|
||||||
|
RETURNS INTEGER
|
||||||
|
AS $$
|
||||||
|
SELECT CASE
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.rates
|
||||||
|
WHERE id = p_id
|
||||||
|
)
|
||||||
|
THEN 1
|
||||||
|
ELSE 0
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
19
db/07_GetAllRates.sql
Normal file
19
db/07_GetAllRates.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS public.get_rates();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_rates()
|
||||||
|
RETURNS TABLE (
|
||||||
|
Id INTEGER,
|
||||||
|
Name VARCHAR,
|
||||||
|
Price MONEY
|
||||||
|
)
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
c.id,
|
||||||
|
c.name,
|
||||||
|
c.price
|
||||||
|
FROM public.rates AS c
|
||||||
|
ORDER BY c.id;
|
||||||
|
$$ LANGUAGE SQL STABLE;
|
||||||
|
|
||||||
|
|
||||||
21
deploy.sh
Normal file
21
deploy.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
IFS=$'\n\t'
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
echo "Deteniendo contenedores"
|
||||||
|
docker compose down -v || true
|
||||||
|
|
||||||
|
echo "Descargando últimos cambios del repo"
|
||||||
|
git pull --ff-only
|
||||||
|
|
||||||
|
echo "Construyendo imágenes sin usar caché"
|
||||||
|
export COMPOSE_BAKE=true
|
||||||
|
docker compose build --no-cache
|
||||||
|
|
||||||
|
echo "Levantando stack en segundo plano"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "Despliegue completado con éxito"
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: proxima_user
|
||||||
|
POSTGRES_PASSWORD: Proxima_Password
|
||||||
|
POSTGRES_DB: db.ProximaContracts
|
||||||
|
volumes:
|
||||||
|
- ./db:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
ConnectionStrings__PostgreSQL: >
|
||||||
|
Host=db;Port=5432;Database=db.ProximaContracts;
|
||||||
|
Username=proxima_user;Password=Proxima_Password
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
front:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/frontend.Dockerfile
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "5173:80"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
networks:
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
12
docker/Caddyfile
Normal file
12
docker/Caddyfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
:80
|
||||||
|
|
||||||
|
root * /usr/share/caddy
|
||||||
|
file_server
|
||||||
|
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8080
|
||||||
|
}
|
||||||
|
|
||||||
|
handle {
|
||||||
|
try_files {path} /index.html
|
||||||
|
}
|
||||||
13
docker/backend.Dockerfile
Normal file
13
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY backend/ProximaContracts/ ./
|
||||||
|
RUN dotnet restore
|
||||||
|
WORKDIR /src/ProximaContracts.API
|
||||||
|
RUN dotnet publish -c Release -o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["dotnet","ProximaContracts.API.dll"]
|
||||||
13
docker/frontend.Dockerfile
Normal file
13
docker/frontend.Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# ---------- build ----------
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY front/contracts-frontend/package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY front/contracts-frontend .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- runtime ----------
|
||||||
|
FROM caddy:2-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/caddy
|
||||||
|
COPY docker/Caddyfile /etc/caddy/Caddyfile
|
||||||
|
EXPOSE 80
|
||||||
1
front/contracts-frontend/.env
Normal file
1
front/contracts-frontend/.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=http://localhost:5009/api
|
||||||
1
front/contracts-frontend/.env.production
Normal file
1
front/contracts-frontend/.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://quediaempiezo.asarmientotest.es/api
|
||||||
24
front/contracts-frontend/.gitignore
vendored
Normal file
24
front/contracts-frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
54
front/contracts-frontend/README.md
Normal file
54
front/contracts-frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default tseslint.config({
|
||||||
|
extends: [
|
||||||
|
// Remove ...tseslint.configs.recommended and replace with this
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
...tseslint.configs.stylisticTypeChecked,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
// other options...
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default tseslint.config({
|
||||||
|
plugins: {
|
||||||
|
// Add the react-x and react-dom plugins
|
||||||
|
'react-x': reactX,
|
||||||
|
'react-dom': reactDom,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
// other rules...
|
||||||
|
// Enable its recommended typescript rules
|
||||||
|
...reactX.configs['recommended-typescript'].rules,
|
||||||
|
...reactDom.configs.recommended.rules,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
28
front/contracts-frontend/eslint.config.js
Normal file
28
front/contracts-frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
13
front/contracts-frontend/index.html
Normal file
13
front/contracts-frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/proximaenergia.com.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Prueba técnica proxima contracts</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3786
front/contracts-frontend/package-lock.json
generated
Normal file
3786
front/contracts-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
front/contracts-frontend/package.json
Normal file
33
front/contracts-frontend/package.json
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "contracts-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.6",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-hook-form": "^7.57.0",
|
||||||
|
"react-router-dom": "^7.6.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@types/node": "^24.0.1",
|
||||||
|
"@types/react": "^19.1.2",
|
||||||
|
"@types/react-dom": "^19.1.2",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.30.1",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
front/contracts-frontend/public/proximaenergia.com.ico
Normal file
BIN
front/contracts-frontend/public/proximaenergia.com.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 889 B |
59
front/contracts-frontend/src/components/forms/FormField.tsx
Normal file
59
front/contracts-frontend/src/components/forms/FormField.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
FieldError,
|
||||||
|
FieldValues,
|
||||||
|
Path,
|
||||||
|
RegisterOptions,
|
||||||
|
UseFormRegister,
|
||||||
|
} from 'react-hook-form';
|
||||||
|
|
||||||
|
type FormFieldProps<T extends FieldValues> = {
|
||||||
|
label: string;
|
||||||
|
name: Path<T>;
|
||||||
|
register: UseFormRegister<T>;
|
||||||
|
error?: FieldError;
|
||||||
|
rules?: RegisterOptions<T>;
|
||||||
|
type?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FormField<T extends FieldValues>({
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
register,
|
||||||
|
error,
|
||||||
|
rules,
|
||||||
|
type = 'text',
|
||||||
|
className = '',
|
||||||
|
}: FormFieldProps<T>) {
|
||||||
|
const buildValidationRules = (): RegisterOptions<T, Path<T>> => {
|
||||||
|
const base: RegisterOptions<T, Path<T>> = { required: 'Obligatorio' };
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
(base as any).valueAsNumber = true;
|
||||||
|
}
|
||||||
|
if (rules) Object.assign(base, rules);
|
||||||
|
|
||||||
|
return base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationRules = buildValidationRules();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`col-12 col-md-6 col-lg-4 ${className}`}>
|
||||||
|
<label htmlFor={name} className="form-label">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
id={name}
|
||||||
|
type={type}
|
||||||
|
className={`form-control ${error ? 'is-invalid' : ''}`}
|
||||||
|
{...register(name, validationRules)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<small className="text-danger">{error.message ?? 'Obligatorio'}</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
front/contracts-frontend/src/env.d.ts
vendored
Normal file
7
front/contracts-frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
import { get, post, put } from '../../../utils/http/Http';
|
||||||
|
|
||||||
|
import type { ContractSummary } from '../types/ContractSummary';
|
||||||
|
import type { ContractDetail } from '../types/ContractDetail';
|
||||||
|
import type { UpdateContractDto } from '../types/UpdateContractDto';
|
||||||
|
import type { CreateContractDto } from '../types/CreateContractDto';
|
||||||
|
|
||||||
|
|
||||||
|
export const getAllContracts = () =>
|
||||||
|
get<ContractSummary[]>('/Contracts/GetAll');
|
||||||
|
|
||||||
|
|
||||||
|
export const getContractById = (id: number) =>
|
||||||
|
get<ContractDetail>('/Contracts/GetById', { params: { id } });
|
||||||
|
|
||||||
|
|
||||||
|
export const updateContract = (dto: UpdateContractDto) =>
|
||||||
|
put<void, UpdateContractDto>('/Contracts/UpdateContract', dto);
|
||||||
|
|
||||||
|
|
||||||
|
export const createContract = (dto: CreateContractDto) =>
|
||||||
|
post<{ newContractId: number }, CreateContractDto>(
|
||||||
|
'/Contracts/CreateContract',
|
||||||
|
dto
|
||||||
|
);
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { CreateContractDto } from '@/features/contracts/types/CreateContractDto';
|
||||||
|
import FormField from '@/components/forms/FormField';
|
||||||
|
import { useRates } from '@/features/rates/hooks/useRates';
|
||||||
|
import { useCreateContract } from '@/features/contracts/hooks/useCreateContracts';
|
||||||
|
import { validateNifNie } from '@/utils/validations/ValidateNifNie';
|
||||||
|
import { maxLengthNonEmpty } from '@/utils/validations/MaxLengthNonEmpty';
|
||||||
|
|
||||||
|
|
||||||
|
export default function ContractCreateForm() {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CreateContractDto>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { rates, loading: loadingRates, error: rateError } = useRates();
|
||||||
|
|
||||||
|
const { create, loading: creating, error: submitError } = useCreateContract();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(create)} className="row g-3">
|
||||||
|
|
||||||
|
<FormField<CreateContractDto>
|
||||||
|
label="Nombre"
|
||||||
|
name="contractorName"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractorName}
|
||||||
|
rules={{validate: maxLengthNonEmpty(50)}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField<CreateContractDto>
|
||||||
|
label="Apellidos"
|
||||||
|
name="contractorSurname"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractorSurname}
|
||||||
|
rules={{validate: maxLengthNonEmpty(100)}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField<CreateContractDto>
|
||||||
|
label="DNI / NIE"
|
||||||
|
name="contractorIdNumber"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractorIdNumber}
|
||||||
|
rules={{
|
||||||
|
validate: {
|
||||||
|
nifNie : validateNifNie,
|
||||||
|
maxLengthNonEmpty: maxLengthNonEmpty(9)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="col-12 col-md-6 col-lg-4">
|
||||||
|
<label htmlFor="rateId" className="form-label">
|
||||||
|
Tarifa
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="rateId"
|
||||||
|
className="form-select"
|
||||||
|
{...register('rateId', { required: true, valueAsNumber: true })}
|
||||||
|
disabled={loadingRates || !!rateError}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{loadingRates ? 'Cargando…' : '— Selecciona —'}
|
||||||
|
</option>
|
||||||
|
{rates.map((rate) => (
|
||||||
|
<option key={rate.id} value={rate.id}>
|
||||||
|
{rate.name} ({rate.price.toFixed(2)} €/kWh)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.rateId && <small className="text-danger">Obligatorio</small>}
|
||||||
|
{rateError && <small className="text-danger">{rateError}</small>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="col-12">
|
||||||
|
<small className="text-danger">{submitError}</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="col-12 mt-2">
|
||||||
|
<button type="submit" className="btn btn-primary me-2" disabled={creating}>
|
||||||
|
{creating ? 'Guardando…' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
disabled={creating}
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ContractDetail } from '@/features/contracts/types/ContractDetail';
|
||||||
|
|
||||||
|
|
||||||
|
export default function ContractDetailComponent({
|
||||||
|
contract,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
contract: ContractDetail;
|
||||||
|
onEdit: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h4 className="card-title mb-3">Contrato #{contract.id}</h4>
|
||||||
|
<p><strong>Nif/Nie:</strong> {contract.contractorIdNumber}</p>
|
||||||
|
<p><strong>Nombre:</strong> {contract.contractorName}</p>
|
||||||
|
<p><strong>Apellidos:</strong> {contract.contractorSurname}</p>
|
||||||
|
<p><strong>Inicio:</strong> {new Date(contract.contractInitDate).toLocaleDateString()}</p>
|
||||||
|
<p><strong>Tarifa:</strong> {contract.rateId}, {contract.rateName} </p>
|
||||||
|
|
||||||
|
|
||||||
|
<button onClick={onEdit} className="btn btn-primary mt-3">
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import type { ContractDetail } from '@/features/contracts/types/ContractDetail';
|
||||||
|
import type { UpdateContractDto } from '@/features/contracts/types/UpdateContractDto';
|
||||||
|
import { updateContract } from '@/features/contracts/api/ContractsApi';
|
||||||
|
import { getProblemMessage } from '@/utils/http/ErrorHelper';
|
||||||
|
|
||||||
|
import FormField from '@/components/forms/FormField';
|
||||||
|
import { maxLengthNonEmpty } from '@/utils/validations/MaxLengthNonEmpty';
|
||||||
|
import { maxDate, minDate } from '@/utils/validations/dateValidators';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
contract: ContractDetail;
|
||||||
|
onSaved: (updated: ContractDetail) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
contractorName: string;
|
||||||
|
contractorSurname: string;
|
||||||
|
contractInitDate: string;
|
||||||
|
rateId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContractUpdateComponent({
|
||||||
|
contract,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
reset,
|
||||||
|
} = useForm<FormData>();
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset({
|
||||||
|
contractorName: contract.contractorName,
|
||||||
|
contractorSurname: contract.contractorSurname,
|
||||||
|
contractInitDate: contract.contractInitDate.slice(0, 10),
|
||||||
|
rateId: contract.rateId,
|
||||||
|
});
|
||||||
|
}, [contract, reset]);
|
||||||
|
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
const dto: UpdateContractDto = { contractId: contract.id, ...data };
|
||||||
|
try {
|
||||||
|
await updateContract(dto);
|
||||||
|
onSaved({ ...contract, ...data });
|
||||||
|
} catch (err) {
|
||||||
|
alert(getProblemMessage(err, 'Error al actualizar.'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="card shadow-sm p-4">
|
||||||
|
<h4 className="mb-4">Editar contrato #{contract.id}</h4>
|
||||||
|
|
||||||
|
<div className="row gy-3">
|
||||||
|
<FormField<FormData>
|
||||||
|
label="Nombre"
|
||||||
|
name="contractorName"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractorName}
|
||||||
|
rules={{
|
||||||
|
validate: maxLengthNonEmpty(50),
|
||||||
|
}}
|
||||||
|
className="col-12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField<FormData>
|
||||||
|
label="Apellidos"
|
||||||
|
name="contractorSurname"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractorSurname}
|
||||||
|
rules={{
|
||||||
|
validate: maxLengthNonEmpty(100),
|
||||||
|
}}
|
||||||
|
className="col-12"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField<FormData>
|
||||||
|
label="Inicio"
|
||||||
|
name="contractInitDate"
|
||||||
|
type="date"
|
||||||
|
register={register}
|
||||||
|
error={errors.contractInitDate}
|
||||||
|
rules={{
|
||||||
|
required: 'Obligatorio',
|
||||||
|
validate: {
|
||||||
|
minDate: minDate("01/01/1999"),
|
||||||
|
maxDate: maxDate(todayIso)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="col-12 col-md-6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField<FormData>
|
||||||
|
label="Rate ID"
|
||||||
|
name="rateId"
|
||||||
|
type="number"
|
||||||
|
register={register}
|
||||||
|
error={errors.rateId}
|
||||||
|
rules={{
|
||||||
|
required: 'Obligatorio',
|
||||||
|
valueAsNumber: true,
|
||||||
|
min: { value: 1, message: 'Debe ser ≥ 1' },
|
||||||
|
}}
|
||||||
|
className="col-12 col-md-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="d-flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="btn btn-success"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Guardando…' : 'Guardar'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onClick={onCancel} className="btn btn-secondary">
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useContractsAll } from '../hooks/useContractsAll';
|
||||||
|
|
||||||
|
export default function ContractsTableComponent() {
|
||||||
|
const { contracts, loading, error } = useContractsAll();
|
||||||
|
|
||||||
|
if (loading) return <p>Cargando…</p>;
|
||||||
|
if (error) return <p className="text-danger">{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<table className="table table-hover table-contracts">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Apellidos</th>
|
||||||
|
<th>Inicio</th>
|
||||||
|
<th>Tarifa</th>
|
||||||
|
<th className="text-center">Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{contracts.map(c => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.id}</td>
|
||||||
|
<td>{c.contractorName}</td>
|
||||||
|
<td>{c.contractorSurname}</td>
|
||||||
|
<td>{new Date(c.contractInitDate).toLocaleDateString()}</td>
|
||||||
|
<td>{c.rateName}</td>
|
||||||
|
<td className="text-center">
|
||||||
|
<Link to={`/contracts/${c.id}`} className="btn btn-sm btn-primary">
|
||||||
|
Detalle
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { getContractById } from '../api/ContractsApi';
|
||||||
|
import type { ContractDetail } from '../types/ContractDetail';
|
||||||
|
|
||||||
|
export function useContractDetail(contractId: number) {
|
||||||
|
const [contract, setContract] = useState<ContractDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchContract = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getContractById(contractId);
|
||||||
|
setContract(data);
|
||||||
|
setError(null);
|
||||||
|
} catch {
|
||||||
|
setError('Contrato no encontrado');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [contractId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContract();
|
||||||
|
}, [fetchContract]);
|
||||||
|
|
||||||
|
return { contract, loading, error, refresh: fetchContract };
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAllContracts } from '@/features/contracts/api/ContractsApi';
|
||||||
|
import type { ContractSummary } from '@/features/contracts/types/ContractSummary';
|
||||||
|
import { getProblemMessage } from '@/utils/http/ErrorHelper';
|
||||||
|
import { HttpError } from '@/utils/http/HttpError';
|
||||||
|
|
||||||
|
export function useContractsAll() {
|
||||||
|
const [contracts, setContractsAll] = useState<ContractSummary[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setContractsAll(await getAllContracts());
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof HttpError && err.status === 404
|
||||||
|
? 'No se encontraron contratos.'
|
||||||
|
: getProblemMessage(err, 'Error al cargar los contratos.')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { contracts, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { createContract as apiCreate } from '../api/ContractsApi';
|
||||||
|
import type { CreateContractDto } from '../types/CreateContractDto';
|
||||||
|
import { getProblemMessage } from '@/utils/http/ErrorHelper';
|
||||||
|
|
||||||
|
export function useCreateContract() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const create = async (dto: CreateContractDto) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { newContractId } = await apiCreate(dto);
|
||||||
|
navigate(`/contracts/${newContractId}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(getProblemMessage(err, 'No se pudo crear el contrato'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { create, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import ContractCreateForm from '@/features/contracts/components/ContractCreateForm';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
export default function ContractCreate() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container-fluid py-4 px-4 px-lg-5">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
|
||||||
|
{/* Cabecera */}
|
||||||
|
<div className="d-flex align-items-center mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="btn btn-outline-primary btn-sm me-3"
|
||||||
|
>
|
||||||
|
← Atrás
|
||||||
|
</button>
|
||||||
|
<h2 className="h4 m-0">Crear nuevo contrato</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Formulario */}
|
||||||
|
<ContractCreateForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// src/features/contracts/components/ContractDetail.tsx
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useContractDetail } from '@/features/contracts/hooks/useContractDetail';
|
||||||
|
import ContractDetailComponent from '@/features/contracts/components/ContractDetailComponent';
|
||||||
|
import ContractUpdateComponent from '@/features/contracts/components/ContractUpdateComponent';
|
||||||
|
|
||||||
|
export default function ContractDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const numericId = Number(id);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { contract, loading, error, refresh } = useContractDetail(numericId);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
if (loading) return <p>Cargando…</p>;
|
||||||
|
if (error || !contract) return <p className="text-red-600">{error}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-xl space-y-6 p-4">
|
||||||
|
{editing ? (
|
||||||
|
<div className="container my-4">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center my-4">
|
||||||
|
<h2 className="h4 m-0">Modificar contrato</h2>
|
||||||
|
</div>
|
||||||
|
<ContractUpdateComponent
|
||||||
|
contract={contract}
|
||||||
|
onSaved={async () => {
|
||||||
|
setEditing(false);
|
||||||
|
await refresh();
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditing(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="container my-4">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="d-flex align-items-center my-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/`)}
|
||||||
|
className="btn btn-outline-primary btn-sm me-3"
|
||||||
|
>
|
||||||
|
← Atrás
|
||||||
|
</button>
|
||||||
|
<h2 className="h4 m-0">Detalle de contrato</h2>
|
||||||
|
</div>
|
||||||
|
<ContractDetailComponent
|
||||||
|
contract={contract}
|
||||||
|
onEdit={() => setEditing(true)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import ContractsTableComponent from '@/features/contracts/components/ContractsTableComponent';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
|
||||||
|
export default function ContractsList() {
|
||||||
|
return (
|
||||||
|
<div className="container my-4">
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="h4 my-4">Listado de contratos</h2>
|
||||||
|
<Link to="/contracts/new" className="btn btn-primary mb-3">
|
||||||
|
+ Nuevo contrato
|
||||||
|
</Link>
|
||||||
|
<ContractsTableComponent />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { ContractSummary } from "./ContractSummary";
|
||||||
|
|
||||||
|
export interface ContractDetail extends ContractSummary {
|
||||||
|
contractorIdNumber: string;
|
||||||
|
rateId: number;
|
||||||
|
ratePrice: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ContractSummary {
|
||||||
|
id: number;
|
||||||
|
contractorName: string;
|
||||||
|
contractorSurname: string;
|
||||||
|
contractInitDate: string;
|
||||||
|
rateName: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export interface CreateContractDto {
|
||||||
|
contractorIdNumber: string;
|
||||||
|
contractorName: string;
|
||||||
|
contractorSurname: string;
|
||||||
|
rateId: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface UpdateContractDto {
|
||||||
|
contractId: number;
|
||||||
|
rateId: number;
|
||||||
|
contractorIdNumber?: string;
|
||||||
|
contractorName?: string;
|
||||||
|
contractorSurname?: string;
|
||||||
|
contractInitDate?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import {get} from '@/utils/http/Http'
|
||||||
|
import type { RateDetails } from '../types/RateDetails'
|
||||||
|
|
||||||
|
export const getAllRates = () => get<RateDetails[]>('/Rates/GetAllRates');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getAllRates } from '../api/RatesApi';
|
||||||
|
import type { RateDetails } from '../types/RateDetails';
|
||||||
|
import { getProblemMessage } from '@/utils/http/ErrorHelper';
|
||||||
|
|
||||||
|
export function useRates() {
|
||||||
|
const [rates, setRates] = useState<RateDetails[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setRates(await getAllRates());
|
||||||
|
} catch (err) {
|
||||||
|
setError(getProblemMessage(err, 'No se pudieron cargar las tarifas'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { rates, loading, error };
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface RateDetails {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
14
front/contracts-frontend/src/main.tsx
Normal file
14
front/contracts-frontend/src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import '@/styles/brand-proxima.css';
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import AppRoutes from './routes/Index.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
14
front/contracts-frontend/src/routes/Index.tsx
Normal file
14
front/contracts-frontend/src/routes/Index.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import ContractsList from '@/features/contracts/pages/ContractsList';
|
||||||
|
import ContractDetail from '@/features/contracts/pages/ContractDetail';
|
||||||
|
import ContractCreate from '@/features/contracts/pages/ContractCreate';
|
||||||
|
|
||||||
|
export default function AppRoutes() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<ContractsList />} />
|
||||||
|
<Route path="/contracts/:id" element={<ContractDetail />} />
|
||||||
|
<Route path="/contracts/new" element={<ContractCreate />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
front/contracts-frontend/src/styles/brand-proxima.css
Normal file
58
front/contracts-frontend/src/styles/brand-proxima.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
:root {
|
||||||
|
--brand-bg: #051622;
|
||||||
|
--brand-bg-alt: #0f2432;
|
||||||
|
--brand-bg-hover: #2d6588;
|
||||||
|
--brand-border: #113345;
|
||||||
|
|
||||||
|
--brand-text: #e5f2f7;
|
||||||
|
--brand-heading: #ffffff;
|
||||||
|
|
||||||
|
|
||||||
|
--brand-primary: #00d68f;
|
||||||
|
--brand-primary-hover: #00b67a;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bs-body-bg: var(--brand-bg);
|
||||||
|
--bs-body-color: var(--brand-text);
|
||||||
|
--bs-border-color: var(--brand-border);
|
||||||
|
--bs-heading-color: var(--brand-heading);
|
||||||
|
|
||||||
|
--bs-primary: var(--brand-primary);
|
||||||
|
--bs-primary-rgb: 0,214,143;
|
||||||
|
--bs-link-color: var(--brand-primary);
|
||||||
|
--bs-link-hover-color: var(--brand-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.table-contracts {
|
||||||
|
--bs-table-color: var(--brand-text);
|
||||||
|
--bs-table-bg: transparent;
|
||||||
|
--bs-table-border-color: var(--brand-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-contracts > thead > tr {
|
||||||
|
background-color: #0b1e2c;
|
||||||
|
color: var(--brand-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-contracts > tbody > tr:nth-of-type(odd) { background-color: var(--brand-bg-alt); }
|
||||||
|
.table-contracts > tbody > tr:nth-of-type(even) { background-color: var(--brand-bg); }
|
||||||
|
|
||||||
|
.table-contracts > tbody > tr:hover {
|
||||||
|
background-color: var(--brand-bg-hover);
|
||||||
|
outline: 1px solid var(--brand-primary-hover);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--brand-primary);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
color:#051622
|
||||||
|
}
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background-color: var(--brand-primary-hover);
|
||||||
|
border-color: var(--brand-primary-hover);
|
||||||
|
}
|
||||||
17
front/contracts-frontend/src/utils/http/ErrorHelper.ts
Normal file
17
front/contracts-frontend/src/utils/http/ErrorHelper.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { HttpError } from '@/utils/http/HttpError';
|
||||||
|
|
||||||
|
export function getProblemMessage(err: unknown, fallback = 'Error inesperado') {
|
||||||
|
console.log("Hemos entrado aqui")
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
console.log(
|
||||||
|
err
|
||||||
|
)
|
||||||
|
console.log(
|
||||||
|
err.body
|
||||||
|
)
|
||||||
|
return err.body?.detail
|
||||||
|
?? err.body?.title
|
||||||
|
?? err.message;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
87
front/contracts-frontend/src/utils/http/Http.ts
Normal file
87
front/contracts-frontend/src/utils/http/Http.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { HttpError } from "./HttpError";
|
||||||
|
import type { ProblemDetails } from './ProblemDetails'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type Params = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
function buildQuery(params?: Params): string {
|
||||||
|
if (!params) return '';
|
||||||
|
|
||||||
|
const usp = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value !== undefined) usp.append(key, String(value));
|
||||||
|
}
|
||||||
|
const qs = usp.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions<T> {
|
||||||
|
params?: Params;
|
||||||
|
body?: T;
|
||||||
|
headers?: HeadersInit;
|
||||||
|
timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function request<R, B = unknown>(
|
||||||
|
method: 'GET' | 'POST' | 'PUT',
|
||||||
|
url: string,
|
||||||
|
{ params, body, headers, timeout = 15_000 }: RequestOptions<B> = {}
|
||||||
|
): Promise<R> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const id =
|
||||||
|
timeout >= 0 ? setTimeout(() => controller.abort(), timeout) : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const q = buildQuery(params);
|
||||||
|
|
||||||
|
const res = await fetch(`${BASE_URL}${url}${q}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = res.headers.get('Content-Type') ?? '';
|
||||||
|
const isJson = /application\/(json|problem\+json)/i.test(contentType);
|
||||||
|
|
||||||
|
const raw = await res.text();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const problem: ProblemDetails | null =
|
||||||
|
isJson && raw ? JSON.parse(raw) : null;
|
||||||
|
throw new HttpError(res.status, res.statusText, problem);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: R =
|
||||||
|
isJson && raw ? (JSON.parse(raw) as R) : (undefined as unknown as R);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
if (id) clearTimeout(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get = <R>(url: string, options?: RequestOptions<never>) =>
|
||||||
|
request<R>('GET', url, options);
|
||||||
|
|
||||||
|
export const post = <R, B = unknown>(
|
||||||
|
url: string,
|
||||||
|
body: B,
|
||||||
|
options?: Omit<RequestOptions<B>, 'body'>
|
||||||
|
) => request<R, B>('POST', url, { ...options, body });
|
||||||
|
|
||||||
|
export const put = <R, B = unknown>(
|
||||||
|
url: string,
|
||||||
|
body: B,
|
||||||
|
options?: Omit<RequestOptions<B>, 'body'>
|
||||||
|
) => request<R, B>('PUT', url, { ...options, body });
|
||||||
19
front/contracts-frontend/src/utils/http/HttpError.ts
Normal file
19
front/contracts-frontend/src/utils/http/HttpError.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { ProblemDetails } from './ProblemDetails';
|
||||||
|
|
||||||
|
export class HttpError extends Error {
|
||||||
|
public readonly status: number;
|
||||||
|
public readonly statusText: string;
|
||||||
|
public readonly body: ProblemDetails | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
status: number,
|
||||||
|
statusText: string,
|
||||||
|
body: ProblemDetails | null
|
||||||
|
) {
|
||||||
|
super(`${status} ${statusText}`);
|
||||||
|
this.name = 'HttpError';
|
||||||
|
this.status = status;
|
||||||
|
this.statusText = statusText;
|
||||||
|
this.body = body;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ProblemDetails {
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
status?: number;
|
||||||
|
detail?: string;
|
||||||
|
instance?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export const maxLengthNonEmpty =
|
||||||
|
(max: number) =>
|
||||||
|
(value: unknown): true | string => {
|
||||||
|
const str = (value ?? '').toString().trim();
|
||||||
|
|
||||||
|
if (!str) return 'Campo obligatorio';
|
||||||
|
if (str.length > max)
|
||||||
|
return `Debe tener como máximo ${max} caracteres`;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export const validateNifNie = (value: unknown): true | string => {
|
||||||
|
if (typeof value !== 'string') return 'Formato no válido';
|
||||||
|
|
||||||
|
const sanitized = value.toUpperCase().replace(/[\s-]/g, '');
|
||||||
|
const nifReg = /^[0-9]{8}[A-Z]$/;
|
||||||
|
const nieReg = /^[XYZ][0-9]{7}[A-Z]$/;
|
||||||
|
const letters = 'TRWAGMYFPDXBNJZSQVHLCKE';
|
||||||
|
|
||||||
|
let number: number;
|
||||||
|
let control: string;
|
||||||
|
|
||||||
|
if (nifReg.test(sanitized)) {
|
||||||
|
number = parseInt(sanitized.slice(0, 8), 10);
|
||||||
|
control = sanitized[8];
|
||||||
|
} else if (nieReg.test(sanitized)) {
|
||||||
|
const prefix = { X: '0', Y: '1', Z: '2' }[sanitized[0] as 'X' | 'Y' | 'Z'];
|
||||||
|
number = parseInt(prefix + sanitized.slice(1, 8), 10);
|
||||||
|
control = sanitized[8];
|
||||||
|
} else {
|
||||||
|
return 'Formato de NIF/NIE inválido';
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = letters[number % 23];
|
||||||
|
return expected === control
|
||||||
|
? true
|
||||||
|
: 'Dígito de control incorrecto';
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
|
||||||
|
const parseToDate = (raw: string): Date | null => {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
let day: number, month: number, year: number;
|
||||||
|
|
||||||
|
const slashMatch = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(trimmed);
|
||||||
|
if (slashMatch) {
|
||||||
|
[, day, month, year] = slashMatch.map(Number);
|
||||||
|
} else {
|
||||||
|
const dashMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
|
||||||
|
if (!dashMatch) return null;
|
||||||
|
[, year, month, day] = dashMatch.map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.getFullYear() === year &&
|
||||||
|
date.getMonth() === month - 1 &&
|
||||||
|
date.getDate() === day
|
||||||
|
? date
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const minDate =
|
||||||
|
(min: string) =>
|
||||||
|
(value: unknown): true | string => {
|
||||||
|
const reference = parseToDate(min);
|
||||||
|
const dateVal = typeof value === 'string' ? parseToDate(value) : null;
|
||||||
|
|
||||||
|
if (!reference) throw new Error('minDate: formato de fecha incorrecto');
|
||||||
|
if (!dateVal) return 'Fecha inválida';
|
||||||
|
|
||||||
|
return dateVal >= reference
|
||||||
|
? true
|
||||||
|
: `Debe ser igual o posterior a ${min}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const maxDate =
|
||||||
|
(max: string) =>
|
||||||
|
(value: unknown): true | string => {
|
||||||
|
const reference = parseToDate(max);
|
||||||
|
const dateVal = typeof value === 'string' ? parseToDate(value) : null;
|
||||||
|
|
||||||
|
if (!reference) throw new Error('maxDate: formato de fecha incorrecto');
|
||||||
|
if (!dateVal) return 'Fecha inválida';
|
||||||
|
|
||||||
|
return dateVal <= reference
|
||||||
|
? true
|
||||||
|
: `Debe ser igual o anterior a ${max}`;
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user