diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java index 1141617..8993f40 100644 --- a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java @@ -1,5 +1,6 @@ package com.imprimelibros.erp.direcciones; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -7,10 +8,14 @@ import java.util.Map; import org.springframework.context.MessageSource; import org.springframework.data.jpa.domain.Specification; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Controller; +import org.springframework.transaction.annotation.Transactional; import org.springframework.ui.Model; import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; @@ -25,6 +30,7 @@ import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesResponse; import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.paises.PaisesService; +import com.imprimelibros.erp.users.User; import com.imprimelibros.erp.users.UserDao; import com.imprimelibros.erp.users.UserDetailsImpl; @@ -43,6 +49,7 @@ public class DireccionController { protected final UserDao userRepo; protected final TranslationService translationService; + public DireccionController(DireccionRepository repo, PaisesService paisesService, MessageSource messageSource, UserDao userRepo, TranslationService translationService) { this.repo = repo; @@ -60,19 +67,25 @@ public class DireccionController { model.addAttribute("isUser", isUser ? 1 : 0); List keys = List.of( - "margenes-presupuesto.delete.title", - "margenes-presupuesto.delete.text", - "margenes-presupuesto.eliminar", - "margenes-presupuesto.delete.button", + "direcciones.delete.title", + "direcciones.delete.text", + "direcciones.eliminar", + "direcciones.delete.button", "app.yes", "app.cancelar", - "margenes-presupuesto.delete.ok.title", - "margenes-presupuesto.delete.ok.text"); + "direcciones.delete.ok.title", + "direcciones.delete.ok.text", + "direcciones.btn.edit", + "direcciones.btn.delete", + "direcciones.telefono", "direcciones.isFacturacionShort"); Map translations = translationService.getTranslations(locale, keys); model.addAttribute("languageBundle", translations); - return "imprimelibros/direcciones/direccion-list"; + if (isUser) + return "imprimelibros/direcciones/direccion-list-cliente"; + else + return "imprimelibros/direcciones/direccion-list"; } @GetMapping(value = "/datatable", produces = "application/json") @@ -172,12 +185,83 @@ public class DireccionController { .toJson(total); } + @GetMapping(value = "/datatableDirecciones", produces = "application/json") + @ResponseBody + public DataTablesResponse> datatableCliente( + HttpServletRequest request, + Authentication authentication, + Locale locale) { + + DataTablesRequest dt = DataTablesParser.from(request); + + // Columnas visibles / lógicas para el DataTable en el frontend: + // id, cliente (nombre de usuario), alias, att, direccion, cp, ciudad, + // provincia, pais + List searchable = List.of( + "id", + "alias", + "att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono", "is_facturacion", "razonSocial", + "identificacionFiscal"); + + List orderable = List.of( + "id", + "cliente", "alias", + "att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono"); + + // Filtro base por rol (ROLE_USER solo ve sus direcciones) + Specification base = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (authentication != null && authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER"))) { + String username = authentication.getName(); + predicates.add(cb.equal(root.get("user").get("userName"), username)); + } + + return cb.and(predicates.toArray(new Predicate[0])); + }; + + long total = repo.count(base); + + // Construcción del datatable con entity + spec + return DataTable + .of(repo, Direccion.class, dt, searchable) + .orderable(orderable) + + // Columnas "crudas" (las que existen tal cual): + .edit("id", d -> d.getId()) + .edit("alias", d -> d.getAlias()) + .edit("att", d -> d.getAtt()) + .edit("direccion", d -> d.getDireccion()) + .edit("cp", d -> d.getCp()) + .edit("ciudad", d -> d.getCiudad()) + .edit("provincia", d -> d.getProvincia()) + .edit("telefono", d -> d.getTelefono()) + .edit("is_facturacion", d -> d.isDireccionFacturacion()) + .edit("razon_social", d -> d.getRazonSocial()) + .edit("tipo_identificacion_fiscal", d -> d.getTipoIdentificacionFiscal()) + .edit("identificacion_fiscal", d -> d.getIdentificacionFiscal()) + + // pais = nombre localizado desde MessageSource usando el keyword del país + .add("pais", d -> { + // si tienes la relación read-only a Pais (d.getPais()) con .getKeyword() + String keyword = (d.getPais() != null) ? d.getPais().getKeyword() : null; + if (keyword == null || keyword.isBlank()) + return d.getPaisCode3(); + return messageSource.getMessage("paises." + keyword, null, keyword, locale); + }) + // WHERE dinámico (spec base) + .where(base) + .toJson(total); + } + @GetMapping("form") public String getForm(@RequestParam(required = false) Long id, Direccion direccion, BindingResult binding, Model model, HttpServletResponse response, + Authentication auth, Locale locale) { model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); @@ -196,7 +280,16 @@ public class DireccionController { model.addAttribute("action", "/direcciones/" + id); } else { - model.addAttribute("dirForm", new Direccion()); + Direccion newDireccion = new Direccion(); + boolean isUser = auth != null && auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER")); + if (isUser) { + User user = direccion.getUser() != null ? direccion.getUser() : null; + if (user != null) { + newDireccion.setUser(user); + } + } + model.addAttribute("dirForm", newDireccion); model.addAttribute("action", "/direcciones"); } return "imprimelibros/direcciones/direccion-form :: direccionForm"; @@ -208,8 +301,17 @@ public class DireccionController { BindingResult binding, Model model, HttpServletResponse response, + Authentication auth, Locale locale) { + boolean isUser = auth != null && auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER")); + + if (isUser) { + User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null); + direccion.setUser(current); // ignora lo que venga del hidden + } + if (binding.hasErrors()) { response.setStatus(422); model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); @@ -268,37 +370,51 @@ public class DireccionController { return null; } - /* - * - * @DeleteMapping("/{id}") - * - * @Transactional - * public ResponseEntity delete(@PathVariable Long id, Authentication auth, - * Locale locale) { - * - * return repo.findById(id).map(u -> { - * try { - * u.setDeleted(true); - * u.setDeletedAt(LocalDateTime.now()); - * - * repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by - * relleno - * return ResponseEntity.ok(Map.of("message", - * messageSource.getMessage("margenes-presupuesto.exito.eliminado", null, - * locale))); - * } catch (Exception ex) { - * return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - * .body(Map.of("message", - * messageSource.getMessage("margenes-presupuesto.error.delete-internal-error", - * null, - * locale))); - * } - * }).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND) - * .body(Map.of("message", - * messageSource.getMessage("margenes-presupuesto.error.not-found", null, - * locale)))); - * } - */ + @DeleteMapping("/{id}") + @Transactional + public ResponseEntity delete(@PathVariable Long id, Authentication auth, Locale locale) { + + Direccion direccion = repo.findById(id).orElse(null); + if (direccion == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("message", messageSource.getMessage("direcciones.error.noEncontrado", null, locale))); + } + + boolean isUser = auth != null && auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER")); + + Long ownerId = direccion.getUser() != null ? direccion.getUser().getId() : null; + Boolean isOwner = this.isOwnerOrAdmin(auth, ownerId); + + if (isUser && !isOwner) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("message", + messageSource.getMessage("direcciones.error.sinPermiso", null, locale))); + } + + try { + direccion.setDeleted(true); + direccion.setDeletedAt(Instant.now()); + + if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { + direccion.setDeletedBy(userRepo.getReferenceById(udi.getId())); + } else if (auth != null) { + userRepo.findByUserNameIgnoreCase(auth.getName()).ifPresent(direccion::setDeletedBy); + } + repo.saveAndFlush(direccion); + + return ResponseEntity.ok(Map.of("message", + messageSource.getMessage("direcciones.exito.eliminado", null, locale))); + + } catch (Exception ex) { + // Devuelve SIEMPRE algo en el catch + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("message", + messageSource.getMessage("direcciones.error.delete-internal-error", null, locale), + "detail", + ex.getClass().getSimpleName() + ": " + (ex.getMessage() != null ? ex.getMessage() : ""))); + } + } private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) { if (auth == null) { diff --git a/src/main/resources/i18n/direcciones_es.properties b/src/main/resources/i18n/direcciones_es.properties index a88de3c..bc30637 100644 --- a/src/main/resources/i18n/direcciones_es.properties +++ b/src/main/resources/i18n/direcciones_es.properties @@ -1,5 +1,6 @@ direcciones.add=Añadir dirección direcciones.editar=Editar dirección +direcciones.eliminar=Eliminar dirección direcciones.breadcrumb=Direcciones direcciones.add=Añadir dirección @@ -18,6 +19,7 @@ direcciones.pais=País direcciones.instrucciones=Instrucciones de envío direcciones.telefono=Teléfono direcciones.isFacturacion=Usar también como dirección de facturación +direcciones.isFacturacionShort=Disponible para facturación direcciones.razon_social=Razón Social direcciones.tipo_identificacion_fiscal=Tipo de identificación fiscal direcciones.identificacion_fiscal=Número de identificación fiscal @@ -39,9 +41,17 @@ direcciones.delete.text=¿Está seguro de que desea eliminar esta dirección?
/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + // Construye la parte “línea de dirección” + _buildAddressLine() { + const p = this.dir; + const partes = [ + this._esc(p.direccion), + [this._esc(p.cp), this._esc(p.ciudad)].filter(Boolean).join(" "), + [this._esc(p.provincia), this._esc((p.pais_code3 || "").toUpperCase())].filter(Boolean).join(", ") + ].filter(Boolean); + return partes.join(", "); + } + + toElement() { + if (this.$el) return this.$el; + + const d = this.dir; + const isFact = !!d.is_facturacion || !!d.isFacturacion; + + const header = this._esc(d.alias || "Dirección"); + const contactLine = this._esc(d.att || ""); + const razon_social = this._esc(d.razon_social || ""); + const country = (d.pais || "").toUpperCase(); + const addressLine = this._buildAddressLine(); + const phoneLine = this._esc(d.telefono || ""); + const extraFiscal = isFact + ? ` + ${window.languageBundle.get(['direcciones.isFacturacionShort']) || 'Disponible para facturación'}
+ ${razon_social ? razon_social + "
" : ""} + ${this._esc(d.tipo_identificacion_fiscal || "")}${(d.tipo_identificacion_fiscal && d.identificacion_fiscal) ? ": " : ""} + ${this._esc(d.identificacion_fiscal || "")} +
` + : ""; + + const html = ` +
+
+ + +
+
+ `; + + this.$el = $(html); + + return this.$el; + } + + appendTo($container) { + const $node = this.toElement(); + $container.append($node); + return this; + } + + getValue() { + return this.dir.id ?? null; + } +} + + +/* ===================== Ejemplo de uso ===================== + +const direccion = { + id: 123, + user_id: 45, + alias: "Casa", + att: "Juan Pérez", + direccion: "C/ Hola 22", + cp: "28001", + ciudad: "Madrid", + provincia: "Madrid", + pais_code3: "ESP", + telefono: "600123123", + instrucciones: "Llamar al timbre 2ºB", + is_facturacion: true, + razon_social: "Editorial ImprimeLibros S.L.", + tipo_identificacion_fiscal: "CIF", + identificacion_fiscal: "B12345678" +}; + +new DireccionCard({ + direccion, + name: "direccionSeleccionada", + checked: true, + editModal: "#direccionEditarModal", + removeModal: "#direccionEliminarModal", + onEdit: (dir) => { console.log("Editar", dir); }, + onRemove: (dir) => { console.log("Eliminar", dir); }, + onChange: (dir, checked) => { if (checked) console.log("Seleccionada", dir.id); } +}).appendTo($("#direccionesGrid")); + +=========================================================== */ diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js index 5fce408..599a0f9 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js @@ -126,7 +126,6 @@ $(document).on('click', '.btn-edit-direccion', function (e) { e.preventDefault(); const id = $(this).data('id'); - e.preventDefault(); $.get('/direcciones/form', { id }, function (html) { $('#direccionFormModalBody').html(html); const title = $('#direccionFormModalBody #direccionForm').data('edit'); @@ -169,7 +168,7 @@ confirmButton: 'btn btn-secondary w-xs mt-2', }, }); - $('#margenes-datatable').DataTable().ajax.reload(null, false); + $('#direccion-datatable').DataTable().ajax.reload(null, false); }, error: function (xhr) { // usa el mensaje del backend; fallback genérico por si no llega JSON diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/listc.js b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/listc.js new file mode 100644 index 0000000..230b3c5 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/listc.js @@ -0,0 +1,219 @@ + + +import { DireccionCard } from './direccionCard.js'; +(() => { + // si jQuery está cargado, añade CSRF a AJAX + const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); + const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content'); + if (window.$ && csrfToken && csrfHeader) { + $.ajaxSetup({ + beforeSend: function (xhr) { + xhr.setRequestHeader(csrfHeader, csrfToken); + } + }); + } + + const language = document.documentElement.lang || 'es-ES'; + + // Comprueba dependencias antes de iniciar + if (!window.DataTable) { + console.error('DataTables no está cargado aún'); + return; + } + + const $container = $('#direccionesContainer'); + const $shadow = $('#dtDirecciones'); + + // Inicializa DataTable en modo server-side + const dt = $shadow.DataTable({ + processing: true, + serverSide: true, + deferRender: true, + pageLength: 10, // sincronizado con el select + lengthChange: false, // manejado por #pageSize + searching: true, + ordering: true, + paging: true, + order: [[0, 'asc']], // orden inicial por alias + dom: 'tpi', + language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, + ajax: { + url: '/direcciones/datatableDirecciones', // ajusta a tu endpoint + type: 'GET', + // Si tu backend espera JSON puro, descomenta: + // contentType: 'application/json', + // data: function (d) { return JSON.stringify(d); } + // Si espera form-urlencoded (el típico de DataTables), deja como está. + data: function (d) { + // Puedes incluir filtros extra aquí si los necesitas + // d.isFacturacionOnly = $('#chkFacturacion').prop('checked') ? 1 : 0; + } + }, + // Mapea las columnas a las propiedades del JSON que retorna tu backend + columns: [ + { data: 'alias', name: 'alias' }, + { data: 'att', name: 'att' }, + { data: 'direccion', name: 'direccion' }, + { data: 'cp', name: 'cp' }, + { data: 'ciudad', name: 'ciudad' }, + { data: 'provincia', name: 'provincia' }, + { data: 'pais', name: 'pais' }, + { data: 'telefono', name: 'telefono' }, + { data: 'is_facturacion', name: 'is_facturacion' }, + { data: 'razon_social', name: 'razon_social' }, + { data: 'tipo_identificacion_fiscal', name: 'tipo_identificacion_fiscal' }, + { data: 'identificacion_fiscal', name: 'identificacion_fiscal' }, + { data: 'id', name: 'id' } + ], + // No usamos rows/tds visibles; renderizamos tarjetas en drawCallback + drawCallback: function () { + const api = this.api(); + const $container = $('#direccionesContainer').empty(); + + api.rows().every(function () { + const dir = this.data(); + try { + new DireccionCard({ + direccion: dir, + }).appendTo($container); + } catch (err) { + console.error('Error renderizando tarjeta de dirección', dir, err); + } + }); + } + + }); + + // Buscar + $('#buscadorDirecciones').on('keyup', function () { + dt.search(this.value).draw(); + }); + + // Page size + $('#pageSize').on('change', function () { + dt.page.len(parseInt(this.value, 10)).draw(); + }); + + + const modalEl = document.getElementById('direccionFormModal'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + + $(document).on("change", ".direccionFacturacion", function () { + const isChecked = $(this).is(':checked'); + if (isChecked) { + $('.direccionFacturacionItems').removeClass('d-none'); + } else { + $('.direccionFacturacionItems').addClass('d-none'); + $('#razonSocial').val(''); + $('#tipoIdentificacionFiscal').val('DNI'); + $('#identificacionFiscal').val(''); + } + }); + + // Abrir "Crear" + $('#addButton').on('click', (e) => { + e.preventDefault(); + $.get('/direcciones/form', function (html) { + $('#direccionFormModalBody').html(html); + const title = $('#direccionFormModalBody #direccionForm').data('add'); + $('#direccionFormModal .modal-title').text(title); + modal.show(); + }); + }); + + // Abrir "Editar" + $(document).on('click', '.btn-edit-direccion', function (e) { + e.preventDefault(); + const id = $(this).data('id'); + $.get('/direcciones/form', { id }, function (html) { + $('#direccionFormModalBody').html(html); + const title = $('#direccionFormModalBody #direccionForm').data('edit'); + $('#direccionFormModal .modal-title').text(title); + modal.show(); + }); + }); + + + // Botón "Eliminar" + $(document).on('click', '.btn-delete-direccion', function (e) { + e.preventDefault(); + const id = $(this).data('id'); + + Swal.fire({ + title: window.languageBundle.get(['direcciones.delete.title']) || 'Eliminar dirección', + html: window.languageBundle.get(['direcciones.delete.text']) || 'Esta acción no se puede deshacer.', + icon: 'warning', + showCancelButton: true, + buttonsStyling: false, + customClass: { + confirmButton: 'btn btn-danger w-xs mt-2', + cancelButton: 'btn btn-light w-xs mt-2' + }, + confirmButtonText: window.languageBundle.get(['direcciones.delete.button']) || 'Eliminar', + cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar', + }).then((result) => { + if (!result.isConfirmed) return; + + $.ajax({ + url: '/direcciones/' + id, + type: 'DELETE', + success: function () { + Swal.fire({ + icon: 'success', title: window.languageBundle.get(['direcciones.delete.ok.title']) || 'Eliminado', + text: window.languageBundle.get(['direcciones.delete.ok.text']) || 'La dirección ha sido eliminada con éxito.', + showConfirmButton: true, + customClass: { + confirmButton: 'btn btn-secondary w-xs mt-2', + }, + }); + $('#dtDirecciones').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 la direccion.'; + Swal.fire({ icon: 'error', title: 'No se pudo eliminar', text: msg }); + } + }); + }); + }); + + // Submit del form en el modal + $(document).on('submit', '#direccionForm', function (e) { + e.preventDefault(); + const $form = $(this); + + $.ajax({ + url: $form.attr('action'), + type: 'POST', // PUT simulado via _method + data: $form.serialize(), + dataType: 'html', + success: function (html) { + // Si por cualquier motivo llega 200 con fragmento, lo insertamos igual + if (typeof html === 'string' && html.indexOf('id="direccionForm"') !== -1 && html.indexOf(' 0; + const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add'); + $('#direccionModal .modal-title').text(title); + return; + } + // Éxito real: cerrar y recargar tabla + modal.hide(); + dt.ajax.reload(null, false); + }, + error: function (xhr) { + // Con 422 devolvemos el fragmento con errores aquí + if (xhr.status === 422 && xhr.responseText) { + $('#direccionFormModalBody').html(xhr.responseText); + const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0; + const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add'); + $('#direccionModal .modal-title').text(title); + return; + } + // Fallback + $('#direccionFormModalBody').html('
Error inesperado.
'); + } + }); + }); + +})(); diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html index ddfe02a..d124b90 100644 --- a/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html @@ -18,7 +18,7 @@
- +