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
+
+
+
+
+
+
+ | ID |
+ Nombre |
+ Apellido |
+ RUT/DNI |
+ Cargo |
+ Salario Base |
+ Bono |
+ Descuentos |
+ Acció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() {
+ }
+
+}