From c4ff78647163dbe892f6276e89aa01619e9a66f4 Mon Sep 17 00:00:00 2001 From: Jonnathan Toro Date: Tue, 14 Apr 2026 19:09:17 -0400 Subject: [PATCH] - Nombre: Jonnathan Toro Jara - Email: jonnathan.toro@live.com - Cargo postulante: Desarrollador Java - Empresa reclutadora: Tecnova --- READMEJtoro.md | 164 ++++++++++++ pom.xml | 81 ++++++ .../DesafioServletsAjaxApplication.java | 13 + .../desafio/config/EmpleadoProperties.java | 30 +++ .../prueba/desafio/config/ServletConfig.java | 23 ++ .../com/prueba/desafio/dao/EmpleadoDao.java | 107 ++++++++ .../prueba/desafio/dto/ApiErrorResponse.java | 33 +++ .../prueba/desafio/dto/EmpleadoRequest.java | 73 +++++ .../prueba/desafio/dto/ValidationError.java | 30 +++ .../com/prueba/desafio/model/Empleado.java | 93 +++++++ .../service/BusinessValidationException.java | 19 ++ .../desafio/service/EmpleadoService.java | 178 +++++++++++++ .../desafio/servlet/EmpleadoServlet.java | 179 +++++++++++++ .../com/prueba/desafio/util/JsonUtil.java | 25 ++ .../java/com/prueba/desafio/util/RutUtil.java | 70 +++++ src/main/resources/application.properties | 18 ++ src/main/resources/schema.sql | 10 + src/main/resources/static/css/styles.css | 122 +++++++++ src/main/resources/static/index.html | 96 +++++++ src/main/resources/static/js/app.js | 250 ++++++++++++++++++ .../DesafioServletsAjaxApplicationTests.java | 13 + 21 files changed, 1627 insertions(+) create mode 100644 READMEJtoro.md create mode 100644 pom.xml create mode 100644 src/main/java/com/prueba/desafio/DesafioServletsAjaxApplication.java create mode 100644 src/main/java/com/prueba/desafio/config/EmpleadoProperties.java create mode 100644 src/main/java/com/prueba/desafio/config/ServletConfig.java create mode 100644 src/main/java/com/prueba/desafio/dao/EmpleadoDao.java create mode 100644 src/main/java/com/prueba/desafio/dto/ApiErrorResponse.java create mode 100644 src/main/java/com/prueba/desafio/dto/EmpleadoRequest.java create mode 100644 src/main/java/com/prueba/desafio/dto/ValidationError.java create mode 100644 src/main/java/com/prueba/desafio/model/Empleado.java create mode 100644 src/main/java/com/prueba/desafio/service/BusinessValidationException.java create mode 100644 src/main/java/com/prueba/desafio/service/EmpleadoService.java create mode 100644 src/main/java/com/prueba/desafio/servlet/EmpleadoServlet.java create mode 100644 src/main/java/com/prueba/desafio/util/JsonUtil.java create mode 100644 src/main/java/com/prueba/desafio/util/RutUtil.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/schema.sql create mode 100644 src/main/resources/static/css/styles.css create mode 100644 src/main/resources/static/index.html create mode 100644 src/main/resources/static/js/app.js create mode 100644 src/test/java/com/prueba/desafio/DesafioServletsAjaxApplicationTests.java diff --git a/READMEJtoro.md b/READMEJtoro.md new file mode 100644 index 0000000..3202d45 --- /dev/null +++ b/READMEJtoro.md @@ -0,0 +1,164 @@ +# 🧩 Desafío Técnico: Servlets + AJAX + +Aplicación web desarrollada en Java 8 que implementa un servicio de gestión de empleados utilizando **Servlets**, **JDBC** y **AJAX nativo**, cumpliendo con los requisitos del desafío. + +--- + +## 🚀 Tecnologías utilizadas + +- Java 8 +- Spring Boot 2.7 (runtime) +- Apache Tomcat embebido +- Servlets (HttpServlet) +- JDBC (sin ORM) +- Base de datos en memoria H2 +- HTML + CSS + JavaScript (Fetch API) +- Maven + +--- + +## 📦 Cómo ejecutar la aplicación + +### 1. Requisitos + +- Java 8 instalado +- Maven instalado + +### 2. Ejecutar el proyecto + +Desde consola: + +```bash +mvn spring-boot:run +``` + +O desde IntelliJ: + +Ejecutar la clase principal: +DesafioServletsAjaxApplication + +--- + +## 🌐 Acceso a la aplicación + +Frontend: +http://localhost:8080/index.html + +Consola H2: +http://localhost:8080/h2-console + +### Configuración H2 + +- JDBC URL: jdbc:h2:mem:empleadosdb +- User: sa +- Password: (vacío) + +--- + +## 🔌 Endpoints disponibles + +### 🔹 GET /api/empleados +Obtiene la lista de empleados. + +### 🔹 POST /api/empleados +Crea un nuevo empleado. + +Ejemplo request: + +```json +{ + "nombre": "Juan", + "apellido": "Perez", + "rutDni": "12345678-5", + "cargo": "Backend", + "salarioBase": 700000, + "bono": 100000, + "descuentos": 50000 +} +``` + +### 🔹 DELETE /api/empleados/{id} + +Ejemplo: + +```text +DELETE http://localhost:8080/api/empleados/1 +``` + +--- + +## 🧪 Cómo probar la aplicación + +### ✔ Opción 1: Desde la interfaz web + +1. Abrir: + http://localhost:8080/index.html + +2. Probar: +- Crear empleado +- Validaciones del formulario +- Listar empleados +- Eliminar empleados + +### ✔ Opción 2: Usando Postman + +#### Crear empleado (POST) + +```text +POST http://localhost:8080/api/empleados +``` + +#### Listar empleados (GET) + +```text +GET http://localhost:8080/api/empleados +``` + +#### Eliminar empleado (DELETE) + +```text +DELETE http://localhost:8080/api/empleados/1 +``` + +--- + +## ⚠️ Validaciones implementadas + +### 🔹 Backend + +- RUT/DNI duplicado +- Salario base mínimo configurable +- Bono máximo (50% del salario base) +- Descuentos no pueden superar el salario +- Validación real de RUT (módulo 11) +- Sugerencia del RUT correcto cuando el DV es inválido + +> Para cubrir las validaciones de la segunda parte del desafío, se incorporaron los campos **bono** y **descuentos** dentro de la carga de empleados. + +### 🔹 Frontend + +- Campos obligatorios +- Validación de formato de RUT/DNI +- Validación de salario mínimo +- Validación de bono y descuentos +- Mensajes dinámicos sin recargar la página + +--- + +## 🧠 Consideraciones técnicas + +- Arquitectura por capas (DAO / Service / Servlet) +- Manejo de errores con HTTP 400 + JSON estructurado +- Uso de @ConfigurationProperties para reglas de negocio configurables +- Uso de JDBC con PreparedStatement para prevenir SQL Injection +- Obtención del ID generado en el INSERT +- Uso de AJAX con Fetch API sin frameworks frontend + +--- + +## 👤 Autor + +- Nombre: Jonnathan Toro Jara +- Email: jonnathan.toro@live.com +- Cargo postulante: Desarrollador Java +- Empresa reclutadora: Tecnova diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..a688adc --- /dev/null +++ b/pom.xml @@ -0,0 +1,81 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.prueba + desafio-legaxy + 0.0.1-SNAPSHOT + desafio-legaxy + Desafío técnico Servlets + AJAX + + + 1.8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-jdbc + + + + com.h2database + h2 + runtime + + + + com.fasterxml.jackson.core + jackson-databind + + + + org.springframework.boot + spring-boot-starter-logging + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + + maven-compiler-plugin + + 1.8 + 1.8 + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/DesafioServletsAjaxApplication.java b/src/main/java/com/prueba/desafio/DesafioServletsAjaxApplication.java new file mode 100644 index 0000000..0eb104c --- /dev/null +++ b/src/main/java/com/prueba/desafio/DesafioServletsAjaxApplication.java @@ -0,0 +1,13 @@ +package com.prueba.desafio; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class DesafioServletsAjaxApplication { + + public static void main(String[] args) { + SpringApplication.run(DesafioServletsAjaxApplication.class, args); + } + +} diff --git a/src/main/java/com/prueba/desafio/config/EmpleadoProperties.java b/src/main/java/com/prueba/desafio/config/EmpleadoProperties.java new file mode 100644 index 0000000..13e6a71 --- /dev/null +++ b/src/main/java/com/prueba/desafio/config/EmpleadoProperties.java @@ -0,0 +1,30 @@ +package com.prueba.desafio.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +@ConfigurationProperties(prefix = "app.empleado") +public class EmpleadoProperties { + + private BigDecimal salarioMinimo; + private BigDecimal bonoMaxPorcentaje; + + public BigDecimal getSalarioMinimo() { + return salarioMinimo; + } + + public void setSalarioMinimo(BigDecimal salarioMinimo) { + this.salarioMinimo = salarioMinimo; + } + + public BigDecimal getBonoMaxPorcentaje() { + return bonoMaxPorcentaje; + } + + public void setBonoMaxPorcentaje(BigDecimal bonoMaxPorcentaje) { + this.bonoMaxPorcentaje = bonoMaxPorcentaje; + } +} diff --git a/src/main/java/com/prueba/desafio/config/ServletConfig.java b/src/main/java/com/prueba/desafio/config/ServletConfig.java new file mode 100644 index 0000000..4a423e8 --- /dev/null +++ b/src/main/java/com/prueba/desafio/config/ServletConfig.java @@ -0,0 +1,23 @@ +package com.prueba.desafio.config; +import com.prueba.desafio.service.EmpleadoService; +import com.prueba.desafio.servlet.EmpleadoServlet; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ServletConfig { + + @Bean + public ServletRegistrationBean empleadoServletRegistration(EmpleadoService empleadoService) { + ServletRegistrationBean registrationBean = + new ServletRegistrationBean( + new EmpleadoServlet(empleadoService), + "/api/empleados", + "/api/empleados/*" + ); + + registrationBean.setName("empleadoServlet"); + return registrationBean; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/dao/EmpleadoDao.java b/src/main/java/com/prueba/desafio/dao/EmpleadoDao.java new file mode 100644 index 0000000..b2f7950 --- /dev/null +++ b/src/main/java/com/prueba/desafio/dao/EmpleadoDao.java @@ -0,0 +1,107 @@ +package com.prueba.desafio.dao; + +import com.prueba.desafio.model.Empleado; +import org.springframework.stereotype.Repository; + +import javax.sql.DataSource; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +@Repository +public class EmpleadoDao { + + private final DataSource dataSource; + + public EmpleadoDao(DataSource dataSource) { + this.dataSource = dataSource; + } + + public List listar() throws SQLException { + List lista = new ArrayList<>(); + + String sql = "SELECT id, nombre, apellido, rut_dni, cargo, salario_base, bono, descuentos " + + "FROM empleados ORDER BY id ASC"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + + while (rs.next()) { + lista.add(map(rs)); + } + } + + return lista; + } + + public Empleado guardar(Empleado empleado) throws SQLException { + String sql = "INSERT INTO empleados (nombre, apellido, rut_dni, cargo, salario_base, bono, descuentos) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + + ps.setString(1, empleado.getNombre()); + ps.setString(2, empleado.getApellido()); + ps.setString(3, empleado.getRutDni()); + ps.setString(4, empleado.getCargo()); + ps.setBigDecimal(5, empleado.getSalarioBase()); + ps.setBigDecimal(6, empleado.getBono()); + ps.setBigDecimal(7, empleado.getDescuentos()); + + ps.executeUpdate(); + + try (ResultSet rs = ps.getGeneratedKeys()) { + if (rs.next()) { + empleado.setId(rs.getLong(1)); + } + } + + return empleado; + } + } + + public boolean existeRutDni(String rutDni) throws SQLException { + String sql = "SELECT COUNT(*) FROM empleados WHERE rut_dni = ?"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setString(1, rutDni); + + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getInt(1) > 0; + } + } + } + + return false; + } + + public boolean eliminarPorId(Long id) throws SQLException { + String sql = "DELETE FROM empleados WHERE id = ?"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + + ps.setLong(1, id); + int affectedRows = ps.executeUpdate(); + return affectedRows > 0; + } + } + + private Empleado map(ResultSet rs) throws SQLException { + Empleado e = new Empleado(); + e.setId(rs.getLong("id")); + e.setNombre(rs.getString("nombre")); + e.setApellido(rs.getString("apellido")); + e.setRutDni(rs.getString("rut_dni")); + e.setCargo(rs.getString("cargo")); + e.setSalarioBase(rs.getBigDecimal("salario_base")); + e.setBono(rs.getBigDecimal("bono")); + e.setDescuentos(rs.getBigDecimal("descuentos")); + return e; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/dto/ApiErrorResponse.java b/src/main/java/com/prueba/desafio/dto/ApiErrorResponse.java new file mode 100644 index 0000000..32dfb65 --- /dev/null +++ b/src/main/java/com/prueba/desafio/dto/ApiErrorResponse.java @@ -0,0 +1,33 @@ +package com.prueba.desafio.dto; + +import java.util.List; + +public class ApiErrorResponse { + + private String message; + private List errors; + + public ApiErrorResponse() { + } + + public ApiErrorResponse(String message, List errors) { + this.message = message; + this.errors = errors; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public List getErrors() { + return errors; + } + + public void setErrors(List errors) { + this.errors = errors; + } +} diff --git a/src/main/java/com/prueba/desafio/dto/EmpleadoRequest.java b/src/main/java/com/prueba/desafio/dto/EmpleadoRequest.java new file mode 100644 index 0000000..089d0e8 --- /dev/null +++ b/src/main/java/com/prueba/desafio/dto/EmpleadoRequest.java @@ -0,0 +1,73 @@ +package com.prueba.desafio.dto; + +import java.math.BigDecimal; + +public class EmpleadoRequest { + + private String nombre; + private String apellido; + private String rutDni; + private String cargo; + private BigDecimal salarioBase; + private BigDecimal bono; + private BigDecimal descuentos; + + public EmpleadoRequest() { + } + + public String getNombre() { + return nombre; + } + + public void setNombre(String nombre) { + this.nombre = nombre; + } + + public String getApellido() { + return apellido; + } + + public void setApellido(String apellido) { + this.apellido = apellido; + } + + public String getRutDni() { + return rutDni; + } + + public void setRutDni(String rutDni) { + this.rutDni = rutDni; + } + + public String getCargo() { + return cargo; + } + + public void setCargo(String cargo) { + this.cargo = cargo; + } + + public BigDecimal getSalarioBase() { + return salarioBase; + } + + public void setSalarioBase(BigDecimal salarioBase) { + this.salarioBase = salarioBase; + } + + public BigDecimal getBono() { + return bono; + } + + public void setBono(BigDecimal bono) { + this.bono = bono; + } + + public BigDecimal getDescuentos() { + return descuentos; + } + + public void setDescuentos(BigDecimal descuentos) { + this.descuentos = descuentos; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/dto/ValidationError.java b/src/main/java/com/prueba/desafio/dto/ValidationError.java new file mode 100644 index 0000000..3f20f84 --- /dev/null +++ b/src/main/java/com/prueba/desafio/dto/ValidationError.java @@ -0,0 +1,30 @@ +package com.prueba.desafio.dto; +public class ValidationError { + + private String field; + private String message; + + public ValidationError() { + } + + public ValidationError(String field, String message) { + this.field = field; + this.message = message; + } + + public String getField() { + return field; + } + + public void setField(String field) { + this.field = field; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/prueba/desafio/model/Empleado.java b/src/main/java/com/prueba/desafio/model/Empleado.java new file mode 100644 index 0000000..9feadf7 --- /dev/null +++ b/src/main/java/com/prueba/desafio/model/Empleado.java @@ -0,0 +1,93 @@ +package com.prueba.desafio.model; +import java.math.BigDecimal; + +public class Empleado { + + private Long id; + private String nombre; + private String apellido; + private String rutDni; + private String cargo; + private BigDecimal salarioBase; + private BigDecimal bono; + private BigDecimal descuentos; + + public Empleado() { + } + + public Empleado(Long id, String nombre, String apellido, String rutDni, String cargo, + BigDecimal salarioBase, BigDecimal bono, BigDecimal descuentos) { + this.id = id; + this.nombre = nombre; + this.apellido = apellido; + this.rutDni = rutDni; + this.cargo = cargo; + this.salarioBase = salarioBase; + this.bono = bono; + this.descuentos = descuentos; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getNombre() { + return nombre; + } + + public void setNombre(String nombre) { + this.nombre = nombre; + } + + public String getApellido() { + return apellido; + } + + public void setApellido(String apellido) { + this.apellido = apellido; + } + + public String getRutDni() { + return rutDni; + } + + public void setRutDni(String rutDni) { + this.rutDni = rutDni; + } + + public String getCargo() { + return cargo; + } + + public void setCargo(String cargo) { + this.cargo = cargo; + } + + public BigDecimal getSalarioBase() { + return salarioBase; + } + + public void setSalarioBase(BigDecimal salarioBase) { + this.salarioBase = salarioBase; + } + + public BigDecimal getBono() { + return bono; + } + + public void setBono(BigDecimal bono) { + this.bono = bono; + } + + public BigDecimal getDescuentos() { + return descuentos; + } + + public void setDescuentos(BigDecimal descuentos) { + this.descuentos = descuentos; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/service/BusinessValidationException.java b/src/main/java/com/prueba/desafio/service/BusinessValidationException.java new file mode 100644 index 0000000..5be09d1 --- /dev/null +++ b/src/main/java/com/prueba/desafio/service/BusinessValidationException.java @@ -0,0 +1,19 @@ +package com.prueba.desafio.service; + +import com.prueba.desafio.dto.ValidationError; + +import java.util.List; + +public class BusinessValidationException extends RuntimeException { + + private final List errors; + + public BusinessValidationException(String message, List errors) { + super(message); + this.errors = errors; + } + + public List getErrors() { + return errors; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/service/EmpleadoService.java b/src/main/java/com/prueba/desafio/service/EmpleadoService.java new file mode 100644 index 0000000..6ad9dee --- /dev/null +++ b/src/main/java/com/prueba/desafio/service/EmpleadoService.java @@ -0,0 +1,178 @@ +package com.prueba.desafio.service; + +import com.prueba.desafio.config.EmpleadoProperties; +import com.prueba.desafio.util.RutUtil; +import org.springframework.beans.factory.annotation.Value; +import com.prueba.desafio.dao.EmpleadoDao; +import com.prueba.desafio.dto.EmpleadoRequest; +import com.prueba.desafio.dto.ValidationError; +import com.prueba.desafio.model.Empleado; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +@Service +public class EmpleadoService { + + private static final Logger log = LoggerFactory.getLogger(EmpleadoService.class); + + private final EmpleadoDao empleadoDao; + private final EmpleadoProperties empleadoProperties; + private final RutUtil rutUtil; + + public EmpleadoService(EmpleadoDao empleadoDao, EmpleadoProperties empleadoProperties, RutUtil rutUtil) { + this.empleadoDao = empleadoDao; + this.empleadoProperties = empleadoProperties; + this.rutUtil = rutUtil; + } + + public List listar() { + try { + return empleadoDao.listar(); + } catch (SQLException e) { + log.error("Error al listar empleados", e); + throw new RuntimeException("Error al listar empleados"); + } + } + + public Empleado guardar(EmpleadoRequest request) { + List errors = validarEmpleado(request); + + if (!errors.isEmpty()) { + throw new BusinessValidationException("Errores de validación", errors); + } + + Empleado empleado = new Empleado(); + empleado.setNombre(request.getNombre().trim()); + empleado.setApellido(request.getApellido().trim()); + empleado.setRutDni(request.getRutDni().trim()); + empleado.setCargo(request.getCargo().trim()); + empleado.setSalarioBase(request.getSalarioBase()); + empleado.setBono(defaultIfNull(request.getBono())); + empleado.setDescuentos(defaultIfNull(request.getDescuentos())); + + try { + Empleado empleadoGuardado = empleadoDao.guardar(empleado); + log.info("Empleado guardado correctamente con rut/dni={}", empleado.getRutDni()); + return empleadoGuardado; + } catch (SQLException e) { + log.error("Error al guardar empleado", e); + throw new RuntimeException("Error al guardar empleado"); + } + } + + public boolean eliminar(Long id) { + try { + return empleadoDao.eliminarPorId(id); + } catch (SQLException e) { + log.error("Error al eliminar empleado id={}", id, e); + throw new RuntimeException("Error al eliminar empleado"); + } + } + + private List validarEmpleado(EmpleadoRequest request) { + List errors = new ArrayList<>(); + + if (request == null) { + errors.add(new ValidationError("request", "El cuerpo de la solicitud es obligatorio")); + return errors; + } + + if (isBlank(request.getNombre())) { + errors.add(new ValidationError("nombre", "El nombre es obligatorio")); + } + + if (isBlank(request.getApellido())) { + errors.add(new ValidationError("apellido", "El apellido es obligatorio")); + } + + if (isBlank(request.getRutDni())) { + errors.add(new ValidationError("rutDni", "El RUT/DNI es obligatorio")); + } + + if (isBlank(request.getCargo())) { + errors.add(new ValidationError("cargo", "El cargo es obligatorio")); + } + + if (request.getSalarioBase() == null) { + errors.add(new ValidationError("salarioBase", "El salario base es obligatorio")); + } + + BigDecimal bono = defaultIfNull(request.getBono()); + BigDecimal descuentos = defaultIfNull(request.getDescuentos()); + + if (bono.compareTo(BigDecimal.ZERO) < 0) { + errors.add(new ValidationError("bono", "El bono no puede ser negativo")); + } + + if (descuentos.compareTo(BigDecimal.ZERO) < 0) { + errors.add(new ValidationError("descuentos", "Los descuentos no pueden ser negativos")); + } + + if (request.getSalarioBase() != null) { + if (request.getSalarioBase().compareTo(empleadoProperties.getSalarioMinimo()) < 0) { + errors.add(new ValidationError("salarioBase", "El salario base no puede ser menor a " + + empleadoProperties.getSalarioMinimo().toPlainString())); + } + + BigDecimal bonoMaximo = request.getSalarioBase().multiply(empleadoProperties.getBonoMaxPorcentaje()); + if (bono.compareTo(bonoMaximo) > 0) { + errors.add(new ValidationError("bono", "El bono no puede superar el 50% del salario base")); + } + + if (descuentos.compareTo(request.getSalarioBase()) > 0) { + errors.add(new ValidationError("descuentos", "Los descuentos no pueden superar el salario base")); + } + } + + if (!isBlank(request.getRutDni())) { + String rutLimpio = request.getRutDni().trim().replace(".", "").replace("-", "").toUpperCase(); + + if (!rutUtil.esRutDniValido(request.getRutDni().trim())) { + try { + String cuerpo = rutLimpio.substring(0, rutLimpio.length() - 1); + int rutNumero = Integer.parseInt(cuerpo); + + String rutCorrecto = rutUtil.generarRutFormateado(rutNumero); + + errors.add(new ValidationError( + "rutDni", + "RUT inválido. El RUT correcto es " + rutCorrecto + )); + } catch (Exception e) { + errors.add(new ValidationError( + "rutDni", + "El formato del RUT/DNI no es válido" + )); + } + + } else { + try { + if (empleadoDao.existeRutDni(request.getRutDni().trim())) { + errors.add(new ValidationError("rutDni", "Ya existe un empleado con ese RUT/DNI")); + } + } catch (SQLException e) { + log.error("Error validando RUT/DNI duplicado", e); + throw new RuntimeException("Error validando RUT/DNI duplicado"); + } + } + } + + return errors; + } + + + + private BigDecimal defaultIfNull(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/servlet/EmpleadoServlet.java b/src/main/java/com/prueba/desafio/servlet/EmpleadoServlet.java new file mode 100644 index 0000000..2eea545 --- /dev/null +++ b/src/main/java/com/prueba/desafio/servlet/EmpleadoServlet.java @@ -0,0 +1,179 @@ +package com.prueba.desafio.servlet; + +import com.prueba.desafio.dto.ApiErrorResponse; +import com.prueba.desafio.dto.EmpleadoRequest; +import com.prueba.desafio.model.Empleado; +import com.prueba.desafio.service.BusinessValidationException; +import com.prueba.desafio.service.EmpleadoService; +import com.prueba.desafio.util.JsonUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +public class EmpleadoServlet extends HttpServlet { + + private static final Logger log = LoggerFactory.getLogger(EmpleadoServlet.class); + + private final EmpleadoService empleadoService; + + public EmpleadoServlet(EmpleadoService empleadoService) { + this.empleadoService = empleadoService; + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException { + try { + List empleados = empleadoService.listar(); + JsonUtil.writeJson(response, HttpServletResponse.SC_OK, empleados); + } catch (Exception e) { + log.error("Error en GET /api/empleados", e); + JsonUtil.writeJson( + response, + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + new ApiErrorResponse( + "Error interno al listar empleados", + Collections.emptyList() + ) + ); + } + } + + @Override + protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { + try { + String body = leerBody(request); + + if (body == null || body.trim().isEmpty()) { + JsonUtil.writeJson( + response, + HttpServletResponse.SC_BAD_REQUEST, + new ApiErrorResponse( + "El body de la solicitud es obligatorio", + Collections.emptyList() + ) + ); + return; + } + + EmpleadoRequest empleadoRequest = JsonUtil.fromJson(body, EmpleadoRequest.class); + Empleado empleadoGuardado = empleadoService.guardar(empleadoRequest); + + Map result = new HashMap(); + result.put("message", "Empleado creado correctamente"); + result.put("data", empleadoGuardado); + + JsonUtil.writeJson(response, HttpServletResponse.SC_CREATED, result); + + } catch (BusinessValidationException e) { + log.warn("Error de validación en POST /api/empleados: {}", e.getMessage()); + JsonUtil.writeJson( + response, + HttpServletResponse.SC_BAD_REQUEST, + new ApiErrorResponse(e.getMessage(), e.getErrors()) + ); + } catch (Exception e) { + log.error("Error en POST /api/empleados", e); + JsonUtil.writeJson( + response, + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + new ApiErrorResponse( + "Error interno al guardar empleado", + Collections.emptyList() + ) + ); + } + } + + @Override + protected void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException { + try { + Long id = obtenerIdDesdeRequest(request); + + if (id == null) { + JsonUtil.writeJson( + response, + HttpServletResponse.SC_BAD_REQUEST, + new ApiErrorResponse( + "Debe especificar el id en la URL (/api/empleados/1) o como query param (/api/empleados?id=1)", + Collections.emptyList() + ) + ); + return; + } + + boolean eliminado = empleadoService.eliminar(id); + + if (!eliminado) { + JsonUtil.writeJson( + response, + HttpServletResponse.SC_NOT_FOUND, + new ApiErrorResponse( + "No existe un empleado con el id enviado", + Collections.emptyList() + ) + ); + return; + } + + Map result = new HashMap(); + result.put("message", "Empleado eliminado correctamente"); + result.put("id", id); + + JsonUtil.writeJson(response, HttpServletResponse.SC_OK, result); + + } catch (NumberFormatException e) { + JsonUtil.writeJson( + response, + HttpServletResponse.SC_BAD_REQUEST, + new ApiErrorResponse( + "El id debe ser numérico", + Collections.emptyList() + ) + ); + } catch (Exception e) { + log.error("Error en DELETE /api/empleados", e); + + JsonUtil.writeJson( + response, + HttpServletResponse.SC_INTERNAL_SERVER_ERROR, + new ApiErrorResponse( + "Error interno al eliminar empleado", + Collections.emptyList() + ) + ); + } + } + + private String leerBody(HttpServletRequest request) throws IOException { + BufferedReader reader = request.getReader(); + return reader.lines().collect(Collectors.joining(System.lineSeparator())); + } + + private Long obtenerIdDesdeRequest(HttpServletRequest request) { + String pathInfo = request.getPathInfo(); + + if (pathInfo != null && !pathInfo.trim().isEmpty() && !"/".equals(pathInfo.trim())) { + String idStr = pathInfo.substring(1).trim(); + if (!idStr.isEmpty()) { + return Long.parseLong(idStr); + } + } + + String idParam = request.getParameter("id"); + if (idParam != null && !idParam.trim().isEmpty()) { + return Long.parseLong(idParam.trim()); + } + + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/util/JsonUtil.java b/src/main/java/com/prueba/desafio/util/JsonUtil.java new file mode 100644 index 0000000..ed7393b --- /dev/null +++ b/src/main/java/com/prueba/desafio/util/JsonUtil.java @@ -0,0 +1,25 @@ +package com.prueba.desafio.util; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; + +public final class JsonUtil { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private JsonUtil() { + } + + public static T fromJson(String json, Class clazz) throws IOException { + return OBJECT_MAPPER.readValue(json, clazz); + } + + public static void writeJson(HttpServletResponse response, int status, Object data) throws IOException { + response.setStatus(status); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(OBJECT_MAPPER.writeValueAsString(data)); + } +} \ No newline at end of file diff --git a/src/main/java/com/prueba/desafio/util/RutUtil.java b/src/main/java/com/prueba/desafio/util/RutUtil.java new file mode 100644 index 0000000..14348a6 --- /dev/null +++ b/src/main/java/com/prueba/desafio/util/RutUtil.java @@ -0,0 +1,70 @@ +package com.prueba.desafio.util; +import org.springframework.stereotype.Component; + +@Component +public class RutUtil { + public String generarRutFormateado(int rutNumero) { + char dv = calcularDV(rutNumero); + + String rutStr = String.valueOf(rutNumero); + StringBuilder rutConPuntos = new StringBuilder(); + + int contador = 0; + + for (int i = rutStr.length() - 1; i >= 0; i--) { + rutConPuntos.insert(0, rutStr.charAt(i)); + contador++; + + if (contador == 3 && i != 0) { + rutConPuntos.insert(0, "."); + contador = 0; + } + } + + return rutConPuntos.toString() + "-" + dv; + } + public char calcularDV(int rut) { + int suma = 0; + int multiplicador = 2; + + while (rut > 0) { + int digito = rut % 10; + suma += digito * multiplicador; + + rut /= 10; + multiplicador = (multiplicador == 7) ? 2 : multiplicador + 1; + } + + int resto = 11 - (suma % 11); + + if (resto == 11) return '0'; + if (resto == 10) return 'K'; + + return (char) (resto + '0'); + } + public boolean esRutDniValido(String rut) { + if (rut == null || rut.trim().isEmpty()) { + return false; + } + + rut = rut.replace(".", "").replace("-", "").toUpperCase(); + + if (rut.length() < 8) { + return false; + } + + String cuerpo = rut.substring(0, rut.length() - 1); + char dvIngresado = rut.charAt(rut.length() - 1); + + try { + int rutNumero = Integer.parseInt(cuerpo); + + char dvCalculado = calcularDV(rutNumero); + + return dvCalculado == dvIngresado; + + } catch (NumberFormatException e) { + return false; + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..39efe5b --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,18 @@ +spring.application.name=desafio-servlets-ajax +server.port=8080 + +spring.datasource.url=jdbc:h2:mem:empleadosdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.h2.console.enabled=true +spring.h2.console.path=/h2-console + +spring.sql.init.mode=always + +logging.level.root=INFO +logging.level.com.prueba.empleados=DEBUG + +app.empleado.salario-minimo=400000 +app.empleado.bono-max-porcentaje=0.5 \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql new file mode 100644 index 0000000..de21cc3 --- /dev/null +++ b/src/main/resources/schema.sql @@ -0,0 +1,10 @@ +CREATE TABLE empleados ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + nombre VARCHAR(100) NOT NULL, + apellido VARCHAR(100) NOT NULL, + rut_dni VARCHAR(20) NOT NULL UNIQUE, + cargo VARCHAR(100) NOT NULL, + salario_base DECIMAL(15,2) NOT NULL, + bono DECIMAL(15,2) NOT NULL DEFAULT 0, + descuentos DECIMAL(15,2) NOT NULL DEFAULT 0 +); \ No newline at end of file diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css new file mode 100644 index 0000000..980db3a --- /dev/null +++ b/src/main/resources/static/css/styles.css @@ -0,0 +1,122 @@ +* { + box-sizing: border-box; +} + +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 20px; + background: #f4f6f9; + color: #222; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +h1, h2 { + margin-top: 0; +} + +.card { + background: #fff; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.form-row { + display: flex; + gap: 16px; + margin-bottom: 16px; + flex-wrap: wrap; +} + +.form-group { + flex: 1; + min-width: 220px; +} + +label { + display: block; + margin-bottom: 6px; + font-weight: bold; +} + +input { + width: 100%; + padding: 10px; + border: 1px solid #ccc; + border-radius: 6px; +} + +button { + background: #1976d2; + color: white; + border: none; + padding: 10px 16px; + border-radius: 6px; + cursor: pointer; +} + +button:hover { + background: #125aa0; +} + +button.delete-btn { + background: #d32f2f; +} + +button.delete-btn:hover { + background: #9f2222; +} + +.table-container { + overflow-x: auto; + margin-top: 16px; +} + +table { + width: 100%; + border-collapse: collapse; + background: white; +} + +th, td { + border: 1px solid #ddd; + padding: 10px; + text-align: left; +} + +th { + background: #f0f0f0; +} + +.error-box { + background: #fdecea; + color: #b71c1c; + border: 1px solid #f5c6cb; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; +} + +.success-box { + background: #e8f5e9; + color: #1b5e20; + border: 1px solid #c8e6c9; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; +} + +.info-box { + background: #e3f2fd; + color: #0d47a1; + border: 1px solid #bbdefb; + padding: 12px; + border-radius: 6px; + margin-bottom: 16px; +} \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..4de2d73 --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,96 @@ + + + + + + Gestión de Empleados + + + +
+

Gestión de Empleados

+ +
+

Registrar empleado

+ +
+
+ +
+
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ +
+

Lista de empleados

+ + +
+ + + + + + + + + + + + + + + + + + + +
IDNombreApellidoRUT/DNICargoSalario BaseBonoDescuentosAcción
Cargando empleados...
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/static/js/app.js b/src/main/resources/static/js/app.js new file mode 100644 index 0000000..e6fa748 --- /dev/null +++ b/src/main/resources/static/js/app.js @@ -0,0 +1,250 @@ +const API_URL = '/api/empleados'; + +const empleadoForm = document.getElementById('empleadoForm'); +const empleadosBody = document.getElementById('empleadosBody'); +const validationErrors = document.getElementById('validationErrors'); +const globalMessage = document.getElementById('globalMessage'); +const btnRecargar = document.getElementById('btnRecargar'); + +document.addEventListener('DOMContentLoaded', function () { + cargarEmpleados(); +}); + +btnRecargar.addEventListener('click', function () { + cargarEmpleados(); +}); + +empleadoForm.addEventListener('submit', async function (event) { + event.preventDefault(); + + limpiarMensajes(); + + const empleado = obtenerDatosFormulario(); + const erroresFrontend = validarFormulario(empleado); + + if (erroresFrontend.length > 0) { + mostrarErrores(erroresFrontend); + return; + } + + try { + const response = await fetch(API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(empleado) + }); + + const data = await response.json(); + + if (!response.ok) { + if (data.errors) { + mostrarErrores(data.errors); + } else { + mostrarMensaje(data.message || 'Error al guardar empleado', 'error'); + } + return; + } + + mostrarMensaje(data.message || 'Empleado creado correctamente', 'success'); + empleadoForm.reset(); + document.getElementById('bono').value = 0; + document.getElementById('descuentos').value = 0; + cargarEmpleados(); + + } catch (error) { + mostrarMensaje('Error de conexión al guardar empleado', 'error'); + console.error(error); + } +}); + +async function cargarEmpleados() { + limpiarMensajes(); + empleadosBody.innerHTML = 'Cargando empleados...'; + + try { + const response = await fetch(API_URL); + const empleados = await response.json(); + + if (!response.ok) { + empleadosBody.innerHTML = 'Error al cargar empleados'; + return; + } + + if (!empleados || empleados.length === 0) { + empleadosBody.innerHTML = 'No hay empleados registrados'; + return; + } + + empleadosBody.innerHTML = ''; + + empleados.forEach(function (empleado) { + const row = document.createElement('tr'); + row.innerHTML = ` + ${empleado.id ?? ''} + ${empleado.nombre ?? ''} + ${empleado.apellido ?? ''} + ${empleado.rutDni ?? ''} + ${empleado.cargo ?? ''} + ${formatearNumero(empleado.salarioBase)} + ${formatearNumero(empleado.bono)} + ${formatearNumero(empleado.descuentos)} + + + + `; + empleadosBody.appendChild(row); + }); + + } catch (error) { + empleadosBody.innerHTML = 'Error de conexión al cargar empleados'; + console.error(error); + } +} + +async function eliminarEmpleado(id) { + limpiarMensajes(); + + if (!confirm('¿Deseas eliminar este empleado?')) { + return; + } + + try { + const response = await fetch(`${API_URL}/${id}`, { + method: 'DELETE' + }); + + const data = await response.json(); + + if (!response.ok) { + mostrarMensaje(data.message || 'No se pudo eliminar el empleado', 'error'); + return; + } + + mostrarMensaje(data.message || 'Empleado eliminado correctamente', 'success'); + cargarEmpleados(); + + } catch (error) { + mostrarMensaje('Error de conexión al eliminar empleado', 'error'); + console.error(error); + } +} + +function obtenerDatosFormulario() { + return { + nombre: document.getElementById('nombre').value.trim(), + apellido: document.getElementById('apellido').value.trim(), + rutDni: document.getElementById('rutDni').value.trim(), + cargo: document.getElementById('cargo').value.trim(), + salarioBase: convertirNumero(document.getElementById('salarioBase').value), + bono: convertirNumero(document.getElementById('bono').value), + descuentos: convertirNumero(document.getElementById('descuentos').value) + }; +} + +function validarFormulario(empleado) { + const errores = []; + + if (!empleado.nombre) { + errores.push({ field: 'nombre', message: 'El nombre es obligatorio' }); + } + + if (!empleado.apellido) { + errores.push({ field: 'apellido', message: 'El apellido es obligatorio' }); + } + + if (!empleado.rutDni) { + errores.push({ field: 'rutDni', message: 'El RUT/DNI es obligatorio' }); + } else if (!esRutDniValido(empleado.rutDni)) { + errores.push({ field: 'rutDni', message: 'El formato del RUT/DNI no es válido' }); + } + + if (!empleado.cargo) { + errores.push({ field: 'cargo', message: 'El cargo es obligatorio' }); + } + + if (empleado.salarioBase === null || isNaN(empleado.salarioBase)) { + errores.push({ field: 'salarioBase', message: 'El salario base es obligatorio' }); + } else if (empleado.salarioBase < 400000) { + errores.push({ field: 'salarioBase', message: 'El salario base no puede ser menor a 400000' }); + } + + if (empleado.bono !== null && empleado.salarioBase !== null) { + if (empleado.bono > empleado.salarioBase * 0.5) { + errores.push({ field: 'bono', message: 'El bono no puede superar el 50% del salario base' }); + } + } + + if (empleado.descuentos !== null && empleado.salarioBase !== null) { + if (empleado.descuentos > empleado.salarioBase) { + errores.push({ field: 'descuentos', message: 'Los descuentos no pueden superar el salario base' }); + } + } + + return errores; +} + +function esRutDniValido(valor) { + const regexRut = /^[0-9]{7,8}-[0-9kK]$/; + const regexDni = /^[0-9]{7,12}$/; + return regexRut.test(valor) || regexDni.test(valor); +} + +function convertirNumero(valor) { + if (valor === undefined || valor === null || valor === '') { + return 0; + } + return Number(valor); +} + +function mostrarErrores(errores) { + validationErrors.innerHTML = ''; + + if (!errores || errores.length === 0) { + return; + } + + const div = document.createElement('div'); + div.className = 'error-box'; + + let html = 'Se encontraron errores:
    '; + errores.forEach(function (error) { + html += `
  • ${error.field}: ${error.message}
  • `; + }); + html += '
'; + + div.innerHTML = html; + validationErrors.appendChild(div); +} + +function mostrarMensaje(mensaje, tipo) { + globalMessage.innerHTML = ''; + + const div = document.createElement('div'); + + if (tipo === 'success') { + div.className = 'success-box'; + } else if (tipo === 'info') { + div.className = 'info-box'; + } else { + div.className = 'error-box'; + } + + div.textContent = mensaje; + globalMessage.appendChild(div); +} + +function limpiarMensajes() { + validationErrors.innerHTML = ''; + globalMessage.innerHTML = ''; +} + +function formatearNumero(valor) { + if (valor === null || valor === undefined) { + return '0'; + } + return Number(valor).toLocaleString('es-CL'); +} \ No newline at end of file diff --git a/src/test/java/com/prueba/desafio/DesafioServletsAjaxApplicationTests.java b/src/test/java/com/prueba/desafio/DesafioServletsAjaxApplicationTests.java new file mode 100644 index 0000000..7ec1b59 --- /dev/null +++ b/src/test/java/com/prueba/desafio/DesafioServletsAjaxApplicationTests.java @@ -0,0 +1,13 @@ +package com.prueba.desafio; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DesafioServletsAjaxApplicationTests { + + @Test + void contextLoads() { + } + +}