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