prueba tecnica

This commit is contained in:
Alejandro
2025-06-15 18:29:25 +02:00
parent 9758ee0bc6
commit d97e55a83f
127 changed files with 6488 additions and 1 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
**/node_modules
**/bin
**/obj
.vscode
.idea
.git
*.log
Dockerfile
docker-compose.yml

Binary file not shown.

108
README.md
View File

@@ -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 Lets 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
| ![](images/Responsive2.png) | ![](images/Responsive7.png) | ![](images/Responsive4.png) |
|:--:|:--:|:--:|
| ![](images/validationExample.png) | ![](images/ValidationExampleDNI1.png) | ![](images/SelectTarifaEnCreate.png) |
# 🤝 Contacto
¿Dudas o sugerencias?
📧 contact@asarmiento.es  |  📞 Llámame cuando quieras.

View File

@@ -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);
}
}
}
}

View File

@@ -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());
}
}
}

View File

@@ -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);
}
}
}

View 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();

View File

@@ -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"
}
}
}
}

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
@ProximaContracts.API_HostAddress = http://localhost:5009

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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": "*"
}

View File

@@ -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
};
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Domain.Contracts.DTOs.Request
{
public class ContractByIdRequestDto
{
public int Id { get; set; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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; }
}
}

View File

@@ -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; }
}
}

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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)
""";
}

View File

@@ -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();
}
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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();";
}

View File

@@ -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();
}
}
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
{
public sealed class CreateContractException : Exception
{
public CreateContractException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
{
public sealed class CreateContractRateNotFoundException : Exception
{
public CreateContractRateNotFoundException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
{
public sealed class GetContractByIdException : Exception
{
public GetContractByIdException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,8 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
{
public sealed class GetContractsException : Exception
{
public GetContractsException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Contract
{
public sealed class UpdateContractException : Exception
{
public UpdateContractException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
{
public sealed class CheckIfRateIdExistsException : Exception
{
public CheckIfRateIdExistsException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
{
public sealed class GetAllRates404Exception : Exception
{
public GetAllRates404Exception(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,7 @@
namespace ProximaContracts.Shared.Exceptions.Repositories.Rates
{
public sealed class GetAllRatesException : Exception
{
public GetAllRatesException(string message) : base(message) { }
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]

View 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

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5009/api

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=https://quediaempiezo.asarmientotest.es/api

24
front/contracts-frontend/.gitignore vendored Normal file
View 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?

View 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,
},
})
```

View 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 },
],
},
},
)

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 B

View 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
View File

@@ -0,0 +1,7 @@
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@@ -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
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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"
>
&larr; Atrás
</button>
<h2 className="h4 m-0">Crear nuevo contrato</h2>
</div>
{/* Formulario */}
<ContractCreateForm />
</div>
</div>
</div>
);
}

View File

@@ -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"
>
&larr; Atrás
</button>
<h2 className="h4 m-0">Detalle de contrato</h2>
</div>
<ContractDetailComponent
contract={contract}
onEdit={() => setEditing(true)}
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,7 @@
import type { ContractSummary } from "./ContractSummary";
export interface ContractDetail extends ContractSummary {
contractorIdNumber: string;
rateId: number;
ratePrice: number;
}

View File

@@ -0,0 +1,7 @@
export interface ContractSummary {
id: number;
contractorName: string;
contractorSurname: string;
contractInitDate: string;
rateName: string;
}

View File

@@ -0,0 +1,6 @@
export interface CreateContractDto {
contractorIdNumber: string;
contractorName: string;
contractorSurname: string;
rateId: number;
}

View File

@@ -0,0 +1,8 @@
export interface UpdateContractDto {
contractId: number;
rateId: number;
contractorIdNumber?: string;
contractorName?: string;
contractorSurname?: string;
contractInitDate?: string;
}

View File

@@ -0,0 +1,4 @@
import {get} from '@/utils/http/Http'
import type { RateDetails } from '../types/RateDetails'
export const getAllRates = () => get<RateDetails[]>('/Rates/GetAllRates');

View File

@@ -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 };
}

View File

@@ -0,0 +1,5 @@
export interface RateDetails {
id: number;
name: string;
price: number;
}

View 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>,
)

View 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>
);
}

View 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);
}

View 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;
}

View 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 });

View 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;
}
}

View File

@@ -0,0 +1,7 @@
export interface ProblemDetails {
type?: string;
title?: string;
status?: number;
detail?: string;
instance?: string;
}

View File

@@ -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;
};

View File

@@ -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';
};

View File

@@ -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