diff --git a/src/main/java/com/imprimelibros/erp/users/UserController.java b/src/main/java/com/imprimelibros/erp/users/UserController.java index b55e367..9319a7a 100644 --- a/src/main/java/com/imprimelibros/erp/users/UserController.java +++ b/src/main/java/com/imprimelibros/erp/users/UserController.java @@ -7,7 +7,10 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.context.MessageSource; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; @@ -28,6 +31,7 @@ import com.imprimelibros.erp.config.Sanitizer; import com.imprimelibros.erp.datatables.DataTable; import java.util.Map; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import java.util.List; @@ -75,7 +79,8 @@ public class UserController { // Si 'role' es relación, sácalo de aquí: List searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de // verdad - List orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas columnas + List orderable = List.of("fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por estas + // columnas Specification base = (root, query, cb) -> cb.conjunction(); long total = repo.count(); @@ -83,7 +88,7 @@ public class UserController { return DataTable .of(repo, User.class, dt, searchable) // 'searchable' en DataTable.java // edita columnas "reales": - .orderable(orderable) + .orderable(orderable) .edit("enabled", (User u) -> { if (u.isEnabled()) { return "" @@ -105,10 +110,30 @@ public class UserController { " \n" + " \n" + + + "\" class=\"link-danger btn-delete-user fs-15\">\n" + " "; }) .where(base) + // Filtros custom: + .filter((builder, req) -> { + // f_enabled: 'true' | 'false' | '' + String fEnabled = Optional.ofNullable(req.raw.get("f_enabled")).orElse("").trim(); + if (!fEnabled.isEmpty()) { + boolean enabledVal = Boolean.parseBoolean(fEnabled); + builder.add((root, q, cb) -> cb.equal(root.get("enabled"), enabledVal)); + } + + // f_role: 'USER' | 'ADMIN' | 'SUPERADMIN' | '' + String fRole = Optional.ofNullable(req.raw.get("f_role")).orElse("").trim(); + if (!fRole.isEmpty()) { + builder.add((root, q, cb) -> { + // join a roles; marca la query como distinct para evitar duplicados + var r = root.join("roles", jakarta.persistence.criteria.JoinType.LEFT); + q.distinct(true); + return cb.equal(r.get("name"), fRole); + }); + } + }) .toJson(total); } @@ -255,33 +280,28 @@ public class UserController { } @DeleteMapping("/{id}") - @ResponseBody - public void delete(@PathVariable Long id, HttpServletResponse response, Authentication authentication) { - var uOpt = repo.findById(id); - if (uOpt.isEmpty()) { - response.setStatus(404); - return; - } - var u = uOpt.get(); - String currentUserName = authentication.getName(); - if (u.getUserName().equalsIgnoreCase(currentUserName)) { - response.setStatus(403); // no puede borrarse a sí mismo - return; - } - try { - repo.delete(u); - } catch (Exception ex) { - response.setStatus(500); - } - // Si llegamos aquí, la eliminación fue exitosa - /* - * response.setStatus(204); - * response.getWriter().flush(); - * response.getWriter().close(); - */ - - return; - + public ResponseEntity delete(@PathVariable Long id, Authentication authentication, Locale locale) { + return repo.findById(id).map(u -> { + + if (authentication != null && u.getUserName().equalsIgnoreCase(authentication.getName())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-self", null, locale))); + } + try { + repo.delete(u); + return ResponseEntity.status(HttpStatus.OK).body( + Map.of("message", messageSource.getMessage("usuarios.exito.eliminado", null, locale)) + ); + } catch (DataIntegrityViolationException dive) { + // Restricción FK / dependencias + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-relational-data", null, locale))); + } catch (Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-internal-error", null, locale))); + } + }).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", messageSource.getMessage("usuarios.error.delete-not-found", null, locale)))); } } diff --git a/src/main/resources/i18n/users_es.properties b/src/main/resources/i18n/users_es.properties index b984f51..e4eb19a 100644 --- a/src/main/resources/i18n/users_es.properties +++ b/src/main/resources/i18n/users_es.properties @@ -5,6 +5,7 @@ usuarios.add=Añadir usuario usuarios.eliminar=Eliminar usuario usuarios.confirmarEliminar=¿Está seguro de que desea eliminar este usuario? usuarios.guardar=Guardar +usuarios.todos=Todos usuarios.tabla.id=ID usuarios.tabla.nombre=Nombre @@ -38,6 +39,10 @@ usuarios.error.password.requerida=La contraseña es obligatoria. usuarios.error.password.min=La contraseña debe tener al menos 6 caracteres. usuarios.error.confirmPassword.requerida=La confirmación de la contraseña es obligatoria. usuarios.error.password-coinciden=Las contraseñas no coinciden. +usuarios.error.delete-relational-data=No se puede eliminar el usuario porque tiene datos relacionados. +usuarios.error.delete-internal-error=No se puede eliminar: error interno. +usuarios.error.delete-not-found=No se puede eliminar: usuario no encontrado. +usuarios.error.delete-self=No se puede eliminar a sí mismo. usuarios.exito.creado=Usuario creado con éxito. usuarios.exito.actualizado=Usuario actualizado con éxito. diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js index 2389947..8c45210 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/users/list.js @@ -1,16 +1,37 @@ $(() => { const language = document.documentElement.lang || 'es-ES'; + // CSRF global para AJAX + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content'); + if (csrfToken && csrfHeader) { + $.ajaxSetup({ + beforeSend: function (xhr) { + xhr.setRequestHeader(csrfHeader, csrfToken); + } + }); + } + const table = new DataTable('#users-datatable', { - processing: true, serverSide: true, pageLength: 50, + processing: true, + serverSide: true, + orderCellsTop: true, + pageLength: 50, language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, responsive: true, - ajax: { url: '/users/datatable', method: 'GET' }, - order: [[0,'asc']], + ajax: { + url: '/users/datatable', + method: 'GET', + data: function (d) { + d.f_role = $('#search-role').val() || ''; // 'USER' | 'ADMIN' | 'SUPERADMIN' | '' + d.f_enabled = $('#search-status').val() || ''; // 'true' | 'false' | '' + } + }, + order: [[0, 'asc']], columns: [ - { data: 'id', name: 'id' , orderable: true }, - { data: 'fullName', name: 'fullName' , orderable: true }, - { data: 'userName', name: 'userName' , orderable: true }, + { data: 'id', name: 'id', orderable: true }, + { data: 'fullName', name: 'fullName', orderable: true }, + { data: 'userName', name: 'userName', orderable: true }, { data: 'roles', name: 'roleRank' }, { data: 'enabled', name: 'enabled', searchable: false }, { data: 'actions', name: 'actions' } @@ -18,8 +39,21 @@ $(() => { columnDefs: [{ targets: -1, orderable: false, searchable: false }] }); + table.on("keyup", ".user-filter", function () { + const colName = $(this).data("col"); + const colIndex = table.settings()[0].aoColumns.findIndex(c => c.name === colName); + + if (colIndex >= 0) { + table.column(colIndex).search(this.value).draw(); + } + }); + + table.on("change", ".user-filter-select", function () { + table.draw(); + }); + const modalEl = document.getElementById('userFormModal'); - const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); // Abrir "Crear" $('#addUserButton').on('click', (e) => { @@ -36,7 +70,7 @@ $(() => { $(document).on('click', '.btn-edit-user', function (e) { e.preventDefault(); const id = $(this).data('id'); - $.get('/users/form', { id }, function (html) { + $.get('/users/form', { id }, function (html) { $('#userModalBody').html(html); const title = $('#userModalBody #userForm').data('edit'); $('#userFormModal .modal-title').text(title); @@ -44,6 +78,40 @@ $(() => { }); }); + // Botón "Eliminar" + $(document).on('click', '.btn-delete-user', function (e) { + e.preventDefault(); + const id = $(this).data('id'); + + Swal.fire({ + title: '¿Eliminar usuario?', + text: 'Esta acción no se puede deshacer.', + icon: 'warning', + showCancelButton: true, + confirmButtonText: 'Sí, eliminar', + cancelButtonText: 'Cancelar', + reverseButtons: true + }).then((result) => { + if (!result.isConfirmed) return; + + $.ajax({ + url: '/users/' + id, + type: 'DELETE', + success: function () { + Swal.fire({ icon: 'success', title: 'Eliminado', timer: 1200, showConfirmButton: false }); + $('#users-datatable').DataTable().ajax.reload(null, false); + }, + error: function (xhr) { + // usa el mensaje del backend; fallback genérico por si no llega JSON + const msg = (xhr.responseJSON && xhr.responseJSON.message) + || 'Error al eliminar el usuario.'; + Swal.fire({ icon: 'error', title: 'No se pudo eliminar', text: msg }); + } + }); + }); + }); + + // Submit del form en el modal $(document).on('submit', '#userForm', function (e) { e.preventDefault(); @@ -59,7 +127,7 @@ $(() => { if (typeof html === 'string' && html.indexOf('id="userForm"') !== -1 && html.indexOf(' 0; - const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); + const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); $('#userFormModal .modal-title').text(title); return; } @@ -72,7 +140,7 @@ $(() => { if (xhr.status === 422 && xhr.responseText) { $('#userModalBody').html(xhr.responseText); const isEdit = $('#userModalBody #userForm input[name="_method"][value="PUT"]').length > 0; - const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); + const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add'); $('#userFormModal .modal-title').text(title); return; } diff --git a/src/main/resources/templates/imprimelibros/layout.html b/src/main/resources/templates/imprimelibros/layout.html index b9a488b..49dc1dc 100644 --- a/src/main/resources/templates/imprimelibros/layout.html +++ b/src/main/resources/templates/imprimelibros/layout.html @@ -2,9 +2,13 @@ th:with="isAuth=${#authorization.expression('isAuthenticated()')}" th:attrappend="data-layout=${isAuth} ? 'semibox' : 'horizontal'" data-sidebar-visibility="show" data-topbar="light" data-sidebar="light" data-sidebar-size="lg" data-sidebar-image="none" data-preloader="disable" - xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> + xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" + xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"> + + + diff --git a/src/main/resources/templates/imprimelibros/users/users-list.html b/src/main/resources/templates/imprimelibros/users/users-list.html index 2a07dac..14bc033 100644 --- a/src/main/resources/templates/imprimelibros/users/users-list.html +++ b/src/main/resources/templates/imprimelibros/users/users-list.html @@ -49,6 +49,31 @@ Estado Acciones + + + + + + + + + + + + +