diff --git a/src/main/java/com/imprimelibros/erp/configuracion/margenes_presupuestos/MargenPresupuestoController.java b/src/main/java/com/imprimelibros/erp/configuracion/margenes_presupuestos/MargenPresupuestoController.java index 11a83be..e8e9a5e 100644 --- a/src/main/java/com/imprimelibros/erp/configuracion/margenes_presupuestos/MargenPresupuestoController.java +++ b/src/main/java/com/imprimelibros/erp/configuracion/margenes_presupuestos/MargenPresupuestoController.java @@ -146,11 +146,11 @@ public class MargenPresupuestoController { if (id != null) { var opt = repo.findById(id); if (opt.isEmpty()) { - binding.reject("usuarios.error.noEncontrado", - messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); + binding.reject("margenes-presupuesto.error.noEncontrado", + messageSource.getMessage("margenes-presupuesto.error.noEncontrado", null, locale)); response.setStatus(404); - model.addAttribute("action", "/users/" + id); - return "imprimelibros/users/user-form :: userForm"; + model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id); + return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm"; } model.addAttribute("margenPresupuesto", opt.get()); diff --git a/src/main/java/com/imprimelibros/erp/direcciones/Direccion.java b/src/main/java/com/imprimelibros/erp/direcciones/Direccion.java new file mode 100644 index 0000000..e8f24bc --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/Direccion.java @@ -0,0 +1,225 @@ +package com.imprimelibros.erp.direcciones; + +import jakarta.persistence.*; +import java.io.Serializable; + +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import com.imprimelibros.erp.common.jpa.AbstractAuditedEntity; +import com.imprimelibros.erp.paises.Paises; +import com.imprimelibros.erp.users.User; + +@Entity +@Table(name = "direcciones", indexes = { + @Index(name = "idx_direcciones_user", columnList = "user_id"), + @Index(name = "idx_direcciones_pais_code3", columnList = "pais_code3") +}) +@SQLDelete(sql = "UPDATE direcciones SET deleted = 1, deleted_at = NOW(3) WHERE id = ?") +@SQLRestriction("deleted = 0") +public class Direccion extends AbstractAuditedEntity implements Serializable { + + public enum TipoIdentificacionFiscal { + DNI("direcciones.dni"), + NIE("direcciones.nie"), + CIF("direcciones.cif"), + Pasaporte("direcciones.pasaporte"), + VAT_ID("direcciones.vat_id"); + + private String key; + + TipoIdentificacionFiscal(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // --- FK a users(id) + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "alias", length = 100, nullable = false) + private String alias; + + @Column(name = "att", length = 150, nullable = false) + private String att; + + @Column(name = "direccion", length = 255, nullable = false) + private String direccion; + + @Column(name = "cp", length = 20, nullable = false) + private Integer cp; + + @Column(name = "ciudad", length = 100, nullable = false) + private String ciudad; + + @Column(name = "provincia", length = 100, nullable = false) + private String provincia; + + // Usamos el code3 del país como FK lógica (String) + @Column(name = "pais_code3", length = 3, nullable = false) + private String paisCode3 = "esp"; + + @Column(name = "telefono", length = 30, nullable = false) + private String telefono; + + @Column(name = "instrucciones", length = 255) + private String instrucciones; + + @Column(name = "is_facturacion", nullable = false) + private boolean direccionFacturacion = false; + + @Column(name = "razon_social", length = 150) + private String razonSocial; + + @Enumerated(EnumType.STRING) + @Column(name = "tipo_identificacion_fiscal", length = 20, nullable = false) + private TipoIdentificacionFiscal tipoIdentificacionFiscal = TipoIdentificacionFiscal.DNI; + + @Column(name = "identificacion_fiscal", length = 50) + private String identificacionFiscal; + + // --- Asociación opcional (read-only) a Pais por code3, si tienes la entidad + // Pais + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pais_code3", referencedColumnName = "code3", insertable = false, updatable = false) + private Paises pais; + + // --- Getters & Setters --- + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public User getUser() { + return user; + } + + public void setUser(User user) { + this.user = user; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public String getAtt() { + return att; + } + + public void setAtt(String att) { + this.att = att; + } + + public String getDireccion() { + return direccion; + } + + public void setDireccion(String direccion) { + this.direccion = direccion; + } + + public Integer getCp() { + return cp; + } + + public void setCp(Integer cp) { + this.cp = cp; + } + + public String getCiudad() { + return ciudad; + } + + public void setCiudad(String ciudad) { + this.ciudad = ciudad; + } + + public String getProvincia() { + return provincia; + } + + public void setProvincia(String provincia) { + this.provincia = provincia; + } + + public String getPaisCode3() { + return paisCode3; + } + + public void setPaisCode3(String paisCode3) { + this.paisCode3 = paisCode3; + } + + public String getTelefono() { + return telefono; + } + + public void setTelefono(String telefono) { + this.telefono = telefono; + } + + public String getInstrucciones() { + return instrucciones; + } + + public void setInstrucciones(String instrucciones) { + this.instrucciones = instrucciones; + } + + public boolean isDireccionFacturacion() { + return direccionFacturacion; + } + + public void setDireccionFacturacion(boolean direccionFacturacion) { + this.direccionFacturacion = direccionFacturacion; + } + + public String getRazonSocial() { + return razonSocial; + } + + public void setRazonSocial(String razonSocial) { + this.razonSocial = razonSocial; + } + + public TipoIdentificacionFiscal getTipoIdentificacionFiscal() { + return tipoIdentificacionFiscal; + } + + public void setTipoIdentificacionFiscal(TipoIdentificacionFiscal tipo) { + this.tipoIdentificacionFiscal = tipo; + } + + public String getIdentificacionFiscal() { + return identificacionFiscal; + } + + public void setIdentificacionFiscal(String identificacionFiscal) { + this.identificacionFiscal = identificacionFiscal; + } + + public Paises getPais() { + return pais; + } + + public void setPais(Paises pais) { + this.pais = pais; + } +} diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java new file mode 100644 index 0000000..2c42366 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java @@ -0,0 +1,317 @@ +package com.imprimelibros.erp.direcciones; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.springframework.context.MessageSource; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; + +import com.imprimelibros.erp.datatables.DataTable; +import com.imprimelibros.erp.datatables.DataTablesParser; +import com.imprimelibros.erp.datatables.DataTablesRequest; +import com.imprimelibros.erp.datatables.DataTablesResponse; +import com.imprimelibros.erp.paises.PaisesService; + +import jakarta.persistence.criteria.Predicate; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +@Controller +@RequestMapping("/direcciones") +public class DireccionController { + + protected final DireccionRepository repo; + protected final PaisesService paisesService; + protected final MessageSource messageSource; + + public DireccionController(DireccionRepository repo, PaisesService paisesService, MessageSource messageSource) { + this.repo = repo; + this.paisesService = paisesService; + this.messageSource = messageSource; + } + + @GetMapping() + public String viewDirecciones(Model model, Authentication auth, Locale locale) { + + boolean isUser = auth != null && auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER")); + model.addAttribute("isUser", isUser ? 1 : 0); + + return "imprimelibros/direcciones/direccion-list"; + } + + @GetMapping(value = "/datatable", produces = "application/json") + @ResponseBody + public DataTablesResponse> datatable( + 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", + "cliente", "alias", + "att", "direccion", "cp", "ciudad", "provincia", "pais"); + + List orderable = List.of( + "id", + "cliente", "alias", + "att", "direccion", "cp", "ciudad", "provincia", "pais"); + + // 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(); // total sin filtro global + + // 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()) + + // Columnas calculadas: + + // cliente = nombre del usuario (o username si no tienes name) + .add("cliente", d -> { + var u = d.getUser(); + return (u != null && u.getFullName() != null && !u.getFullName().isBlank()) + ? u.getFullName() + : ""; + }) + + // 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); + }) + + // Ejemplo de columna de acciones: + .add("actions", d -> """ + + """.formatted(d.getId(), d.getId())) + + // WHERE dinámico (spec base) + .where(base) + + // Si tu DataTable helper soporta “join/alias” para buscar/ordenar por campos + // relacionados: + // .searchAlias("cliente", (root, cb) -> root.join("user").get("name")) + // .orderAlias("cliente", (root) -> root.join("user").get("name")) + // .searchAlias("pais", (root, cb) -> root.join("pais", + // JoinType.LEFT).get("keyword")) + // .orderAlias("pais", (root) -> root.join("pais", + // JoinType.LEFT).get("keyword")) + + .toJson(total); + } + + @GetMapping("form") + public String getForm(@RequestParam(required = false) Long id, + Direccion direccion, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); + + if (id != null) { + var opt = repo.findByIdWithPaisAndUser(id); + if (opt == null) { + binding.reject("direcciones.error.noEncontrado", + messageSource.getMessage("direcciones.error.noEncontrado", null, locale)); + response.setStatus(404); + model.addAttribute("action", "/direcciones/" + id); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + model.addAttribute("direccion", opt.get()); + model.addAttribute("action", "/direcciones/" + id); + } else { + + model.addAttribute("direccion", new Direccion()); + model.addAttribute("action", "/direcciones"); + } + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + @PostMapping + public String create( + Direccion direccion, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + if (binding.hasErrors()) { + response.setStatus(422); + model.addAttribute("action", "/direcciones/"); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + var data = direccion; + + + try { + repo.save(data); + } catch (jakarta.validation.ConstraintViolationException vex) { + // Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap) + vex.getConstraintViolations().forEach(v -> { + // intenta asignar al campo si existe, si no, error global + String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null; + String code = v.getMessage() != null ? v.getMessage().trim() : ""; + + if (code.startsWith("{") && code.endsWith("}")) { + code = code.substring(1, code.length() - 1); // -> "validation.required" + } + + if (path != null && binding.getFieldError(path) == null) { + + binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale)); + } else { + binding.reject("validation", messageSource.getMessage(code, null, locale)); + } + }); + response.setStatus(422); + model.addAttribute("action", "/direcciones/"); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + response.setStatus(201); + return null; + } +/* + @PutMapping("/{id}") + public String edit( + @PathVariable Long id, + MargenPresupuesto form, + BindingResult binding, + Model model, + HttpServletResponse response, + Locale locale) { + + var uOpt = repo.findById(id); + if (uOpt.isEmpty()) { + binding.reject("usuarios.error.noEncontrado", + messageSource.getMessage("usuarios.error.noEncontrado", null, locale)); + } + + if (binding.hasErrors()) { + response.setStatus(422); + model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id); + return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm"; + } + + var entity = uOpt.get(); + + // 3) Copiar solamente campos editables + entity.setImporteMin(form.getImporteMin()); + entity.setImporteMax(form.getImporteMax()); + entity.setMargenMax(form.getMargenMax()); + entity.setMargenMin(form.getMargenMin()); + + try { + repo.saveAndFlush(entity); + + } catch (jakarta.validation.ConstraintViolationException vex) { + // Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap) + vex.getConstraintViolations().forEach(v -> { + // intenta asignar al campo si existe, si no, error global + String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null; + String code = v.getMessage() != null ? v.getMessage().trim() : ""; + + if (code.startsWith("{") && code.endsWith("}")) { + code = code.substring(1, code.length() - 1); // -> "validation.required" + } + + if (path != null && binding.getFieldError(path) == null) { + + binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale)); + } else { + binding.reject("validation", messageSource.getMessage(code, null, locale)); + } + }); + response.setStatus(422); + model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id); + return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm"; + + } catch (org.springframework.dao.DataIntegrityViolationException dex) { + // Uniques, FKs, checks… mensajes de la BD + String msg = dex.getMostSpecificCause() != null ? dex.getMostSpecificCause().getMessage() + : dex.getMessage(); + binding.reject("db.error", messageSource.getMessage(msg, null, locale)); + response.setStatus(422); + model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id); + return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm"; + } + + response.setStatus(204); + 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)))); + } + */ +} diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java new file mode 100644 index 0000000..3158f42 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java @@ -0,0 +1,77 @@ +package com.imprimelibros.erp.direcciones; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + + +import java.util.List; +import java.util.Optional; + +public interface DireccionRepository + extends JpaRepository, + JpaSpecificationExecutor { + + @Query(""" + SELECT + d.id AS id, + d.alias AS alias, + d.att AS att, + d.direccion AS direccion, + d.cp AS cp, + d.ciudad AS ciudad, + d.provincia AS provincia, + d.paisCode3 AS paisCode3, + p.keyword AS paisKeyword, + d.telefono AS telefono, + d.direccionFacturacion AS direccionFacturacion, + d.razonSocial AS razonSocial, + d.tipoIdentificacionFiscal AS tipoIdentificacionFiscal, + d.identificacionFiscal AS identificacionFiscal, + u.fullName AS cliente + FROM Direccion d + JOIN d.user u + LEFT JOIN Paises p ON d.paisCode3 = p.code3 + WHERE (:userId IS NULL OR u.id = :userId) + """) + List findAllWithPaisAndUser(@Param("userId") Long userId); + + //findbyidwithPaisAndUser + @Query(""" + SELECT + d.id AS id, + d.alias AS alias, + d.att AS att, + d.direccion AS direccion, + d.cp AS cp, + d.ciudad AS ciudad, + d.provincia AS provincia, + d.paisCode3 AS paisCode3, + p.keyword AS paisKeyword, + d.telefono AS telefono, + d.direccionFacturacion AS direccionFacturacion, + d.razonSocial AS razonSocial, + d.tipoIdentificacionFiscal AS tipoIdentificacionFiscal, + d.identificacionFiscal AS identificacionFiscal, + u.fullName AS cliente + FROM Direccion d + JOIN d.user u + LEFT JOIN Paises p ON d.paisCode3 = p.code3 + WHERE (d.id = :id) + """) + Optional findByIdWithPaisAndUser(@Param("id") Long id); + + + + @Query(value = "SELECT * FROM direcciones", nativeQuery = true) + List findAllWithDeleted(); + + // find by user_id + List findByUserId(Long userId); + + // find by user_id with deleted + @Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true) + List findByUserIdWithDeleted(@Param("userId") Long userId); + +} diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionView.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionView.java new file mode 100644 index 0000000..b80c28b --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionView.java @@ -0,0 +1,19 @@ +package com.imprimelibros.erp.direcciones; + +public interface DireccionView { + Long getId(); + String getAlias(); + String getAtt(); + String getDireccion(); + String getCp(); + String getCiudad(); + String getProvincia(); + String getPaisCode3(); + String getPaisKeyword(); + String getTelefono(); + Boolean getIsFacturacion(); + String getRazonSocial(); + String getTipoIdentificacionFiscal(); + String getIdentificacionFiscal(); + String getCliente(); +} diff --git a/src/main/java/com/imprimelibros/erp/paises/PaisesController.java b/src/main/java/com/imprimelibros/erp/paises/PaisesController.java index 18c7645..d732a3e 100644 --- a/src/main/java/com/imprimelibros/erp/paises/PaisesController.java +++ b/src/main/java/com/imprimelibros/erp/paises/PaisesController.java @@ -1,22 +1,17 @@ package com.imprimelibros.erp.paises; -import org.springframework.context.MessageSource; import org.springframework.web.bind.annotation.*; -import java.text.Collator; import java.util.*; -import java.util.stream.Collectors; @RestController @RequestMapping("/api/paises") public class PaisesController { - private final PaisesRepository paisesRepository; - private final MessageSource messageSource; + private final PaisesService paisesService; - public PaisesController(PaisesRepository paisesRepository, MessageSource messageSource) { - this.paisesRepository = paisesRepository; - this.messageSource = messageSource; + public PaisesController(PaisesService paisesService) { + this.paisesService = paisesService; } /** @@ -34,40 +29,8 @@ public class PaisesController { @RequestParam(value = "term", required = false) String q2, Locale locale) { - // Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config) - String search = Optional.ofNullable(q1).orElse(q2); - if (search != null) { - search = search.trim(); - } - final String q = (search == null || search.isEmpty()) - ? null - : search.toLowerCase(locale); - List all = paisesRepository.findAll(); - - // Mapear a opciones id/text con i18n y filtrar por búsqueda si llega - List> options = all.stream() - .map(cc -> { - String key = cc.getKeyword(); - String text = messageSource.getMessage("paises." + key, null, key, locale); - Map m = new HashMap<>(); - m.put("id", key); // lo normal en Select2: id = valor que guardarás (keyword) - m.put("text", text); // texto mostrado, i18n con fallback a keyword - return m; - }) - .filter(opt -> { - if (q == null || q.isEmpty()) - return true; - String text = opt.get("text").toLowerCase(locale); - String id = opt.get("id").toLowerCase(locale); - return text.contains(q) || id.contains(q); - }) - .sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance(locale))) - .collect(Collectors.toList()); - - // Estructura Select2 - Map resp = new HashMap<>(); - resp.put("results", options); - return resp; + return paisesService.getForSelect(q1, q2, locale); + } } diff --git a/src/main/java/com/imprimelibros/erp/paises/PaisesService.java b/src/main/java/com/imprimelibros/erp/paises/PaisesService.java new file mode 100644 index 0000000..d7b2769 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/paises/PaisesService.java @@ -0,0 +1,72 @@ +package com.imprimelibros.erp.paises; + +import java.text.Collator; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.context.MessageSource; +import org.springframework.stereotype.Service; + +@Service +public class PaisesService { + + protected final PaisesRepository repo; + protected final MessageSource messageSource; + + public PaisesService(PaisesRepository repo, MessageSource messageSource) { + this.repo = repo; + this.messageSource = messageSource; + } + + public Map getForSelect(String q1, String q2, Locale locale) { + + try { + + // Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config) + String search = Optional.ofNullable(q1).orElse(q2); + if (search != null) { + search = search.trim(); + } + final String q = (search == null || search.isEmpty()) + ? null + : search.toLowerCase(locale); + + List all = repo.findAll(); + + // Mapear a opciones id/text con i18n y filtrar por búsqueda si llega + List> options = all.stream() + .map(cc -> { + String key = cc.getKeyword(); + String id = cc.getCode3(); + String text = messageSource.getMessage("paises." + key, null, key, locale); + Map m = new HashMap<>(); + m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3) + m.put("text", text); // texto mostrado, i18n con fallback a keyword + return m; + }) + .filter(opt -> { + if (q == null || q.isEmpty()) + return true; + String text = opt.get("text").toLowerCase(locale); + String id = opt.get("id").toLowerCase(locale); + return text.contains(q) || id.contains(q); + }) + .sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance(locale))) + .collect(Collectors.toList()); + + // Estructura Select2 + Map resp = new HashMap<>(); + resp.put("results", options); + return resp; + } catch (Exception e) { + e.printStackTrace(); + return Map.of("results", List.of()); + } + } + +} diff --git a/src/main/resources/db/changelog/changesets/0004-create-direcciones.yml b/src/main/resources/db/changelog/changesets/0004-create-direcciones.yml new file mode 100644 index 0000000..cd5e15e --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0004-create-direcciones.yml @@ -0,0 +1,159 @@ +databaseChangeLog: + - changeSet: + id: 00XX-create-direcciones + author: jjo + changes: + - createTable: + tableName: direcciones + columns: + - column: + name: id + type: BIGINT AUTO_INCREMENT + constraints: + primaryKey: true + nullable: false + + - column: + name: user_id + type: BIGINT + constraints: + nullable: false + foreignKeyName: fk_direcciones_users + references: users(id) + onDelete: CASCADE + + - column: + name: alias + type: VARCHAR(100) + constraints: + nullable: false + + - column: + name: att + type: VARCHAR(150) + constraints: + nullable: false + + - column: + name: direccion + type: VARCHAR(255) + constraints: + nullable: false + + - column: + name: cp + type: MEDIUMINT UNSIGNED + constraints: + nullable: false + + - column: + name: ciudad + type: VARCHAR(100) + constraints: + nullable: false + + - column: + name: provincia + type: VARCHAR(100) + constraints: + nullable: false + + - column: + name: pais_code3 + type: CHAR(3) + constraints: + nullable: false + foreignKeyName: fk_direcciones_paises + references: paises(code3) + + - column: + name: telefono + type: VARCHAR(30) + constraints: + nullable: false + + - column: + name: instrucciones + type: VARCHAR(255) + constraints: + nullable: true + + - column: + name: is_facturacion + type: TINYINT(1) + defaultValueNumeric: 0 + constraints: + nullable: false + + - column: + name: razon_social + type: VARCHAR(150) + constraints: + nullable: true + + - column: + name: tipo_identificacion_fiscal + type: ENUM('DNI','NIE','CIF','Pasaporte','VAT_ID') + defaultValue: 'DNI' + constraints: + nullable: false + + - column: + name: identificacion_fiscal + type: VARCHAR(50) + constraints: + nullable: true + + - column: + name: created_by + type: BIGINT + constraints: + nullable: true + foreignKeyName: fk_direcciones_created + references: users(id) + onDelete: CASCADE + + - column: + name: updated_by + type: BIGINT + constraints: + nullable: true + foreignKeyName: fk_direcciones_updated + references: users(id) + onDelete: CASCADE + + - column: + name: deleted_by + type: BIGINT + constraints: + nullable: true + foreignKeyName: fk_direcciones_deleted + references: users(id) + onDelete: CASCADE + + - column: + name: deleted + type: TINYINT(1) + defaultValueNumeric: 0 + constraints: + nullable: false + + - column: + name: created_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + + - column: + name: updated_at + type: TIMESTAMP + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + + - column: + name: deleted_at + type: TIMESTAMP + constraints: + nullable: true diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index f52abaa..90f38f5 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -4,4 +4,6 @@ databaseChangeLog: - include: file: db/changelog/changesets/0002-create-pedidos.yml - include: - file: db/changelog/changesets/0003-create-paises.yml \ No newline at end of file + file: db/changelog/changesets/0003-create-paises.yml + - include: + file: db/changelog/changesets/0004-create-direcciones.yml \ No newline at end of file diff --git a/src/main/resources/i18n/app_es.properties b/src/main/resources/i18n/app_es.properties index bc0c37b..8e2de87 100644 --- a/src/main/resources/i18n/app_es.properties +++ b/src/main/resources/i18n/app_es.properties @@ -5,6 +5,7 @@ app.aceptar=Aceptar app.cancelar=Cancelar app.guardar=Guardar app.editar=Editar +app.add=Añadir app.eliminar=Eliminar app.imprimir=Imprimir app.acciones.siguiente=Siguiente @@ -19,5 +20,7 @@ app.sidebar.inicio=Inicio app.sidebar.presupuestos=Presupuestos app.sidebar.configuracion=Configuración app.sidebar.usuarios=Usuarios +app.sidebar.direcciones=Mis Direcciones +app.sidebar.direcciones-admin=Administrar Direcciones app.errors.403=No tienes permiso para acceder a esta página. \ No newline at end of file diff --git a/src/main/resources/i18n/direcciones_en.properties b/src/main/resources/i18n/direcciones_en.properties new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/i18n/direcciones_es.properties b/src/main/resources/i18n/direcciones_es.properties new file mode 100644 index 0000000..8f90a0b --- /dev/null +++ b/src/main/resources/i18n/direcciones_es.properties @@ -0,0 +1,34 @@ +direcciones.add=Añadir dirección +direcciones.editar=Editar dirección + +direcciones.breadcrumb=Direcciones +direcciones.add=Añadir dirección +direcciones.edit=Editar dirección +direcciones.alias=Alias +direcciones.alias-descripcion=Nombre descriptivo para identificar la dirección. +direcciones.nombre=Nombre y Apellidos +direcciones.direccion=Dirección +direcciones.cp=Código Postal +direcciones.ciudad=Ciudad +direcciones.provincia=Provincia +direcciones.estado=Estado +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.razon_social=Razón Social +direcciones.tipo_identificacion_fiscal=Tipo de identificación fiscal +direcciones.identificacion_fiscal=Número de identificación fiscal + +direcciones.tabla.id=ID +direcciones.tabla.cliente=Cliente +direcciones.tabla.acciones=Acciones + +direcciones.dni=D.N.I. +direcciones.nie=N.I.E. +direcciones.pasaporte=Pasaporte +direcciones.cif=C.I.F. +direcciones.vat_id=VAT ID + +direcciones.error.noEncontrado=Dirección no encontrada. + diff --git a/src/main/resources/i18n/margenesPresupuesto_es.properties b/src/main/resources/i18n/margenesPresupuesto_es.properties index 5d083fc..663e355 100644 --- a/src/main/resources/i18n/margenesPresupuesto_es.properties +++ b/src/main/resources/i18n/margenesPresupuesto_es.properties @@ -27,5 +27,6 @@ margenes-presupuesto.delete.ok.text=El margen ha sido eliminado con éxito. margenes-presupuesto.exito.eliminado=Margen eliminado con éxito. +margenes-presupuesto.error.noEncontrado=Margen no encontrado. margenes-presupuesto.error.delete-internal-error=No se puede eliminar: error interno. margenes-presupuesto.error.delete-not-found=No se puede eliminar: margen no encontrado. \ No newline at end of file diff --git a/src/main/resources/static/assets/css/app.css b/src/main/resources/static/assets/css/app.css index efffc86..525d693 100644 --- a/src/main/resources/static/assets/css/app.css +++ b/src/main/resources/static/assets/css/app.css @@ -8088,6 +8088,15 @@ a { color: #687cfe; } +.form-switch-custom .form-check-input:checked { + border-color: #92b2a7; + background-color: #cbcecd; +} + +.form-switch-custom .form-check-input:checked::before { + color: #92b2a7; +} + .form-switch-secondary .form-check-input:checked { background-color: #ff7f5d; border-color: #ff7f5d; 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 new file mode 100644 index 0000000..288cc92 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js @@ -0,0 +1,196 @@ + + +(() => { + // 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 table = new DataTable('#direcciones-datatable', { + processing: true, + serverSide: true, + orderCellsTop: true, + pageLength: 50, + language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, + responsive: true, + dom: 'lrBtip', + buttons: { + dom: { + button: { + className: 'btn btn-sm btn-outline-primary me-1' + }, + buttons: [ + { extend: 'copy' }, + { extend: 'csv' }, + { extend: 'excel' }, + { extend: 'pdf' }, + { extend: 'print' }, + { extend: 'colvis' } + ], + } + }, + ajax: { + url: '/direcciones/datatable', + method: 'GET', + }, + order: [[0, 'asc']], + columns: [ + { data: 'id', name: 'id', orderable: true, visible: $('#isUser').val() }, + { data: 'cliente', name: 'cliente', orderable: true }, + { data: 'alias', name: 'alias', orderable: true }, + { data: 'nombre', name: 'nombre', orderable: true }, + { data: 'direccion', name: 'direccion', orderable: true }, + { data: 'cp', name: 'cp', orderable: true }, + { data: 'ciudad', name: 'ciudad', orderable: true }, + { data: 'provincia', name: 'provincia', orderable: true }, + { data: 'pais', name: 'pais', orderable: true }, + { data: 'actions', name: 'actions' } + ], + columnDefs: [{ targets: -1, orderable: false, searchable: false }] + }); + + table.on("keyup", ".direcciones-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(normalizeNumericFilter(this.value)).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('/configuracion/margenes-presupuesto/form', { id }, function (html) { + $('#margenesPresupuestoModalBody').html(html); + const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data('edit'); + $('#margenesPresupuestoModal .modal-title').text(title); + modal.show();*/ + }); + + + // Botón "Eliminar" + $(document).on('click', '.btn-delete-margen', 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: '/configuracion/margenes-presupuesto/' + id, + type: 'DELETE', + success: function () { + Swal.fire({ + icon: 'success', title: window.languageBundle.get(['margenes-presupuesto.delete.ok.title']) || 'Eliminado', + text: window.languageBundle.get(['margenes-presupuesto.delete.ok.text']) || 'El margen ha sido eliminado con éxito.', + showConfirmButton: true, + customClass: { + confirmButton: 'btn btn-secondary w-xs mt-2', + }, + }); + $('#margenes-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', '#margenesPresupuestoForm', 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="margenesPresupuestoForm"') !== -1 && html.indexOf(' 0; + const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add'); + $('#margenesPresupuestoModal .modal-title').text(title); + return; + } + // Éxito real: cerrar y recargar tabla + modal.hide(); + table.ajax.reload(null, false); + }, + error: function (xhr) { + // Con 422 devolvemos el fragmento con errores aquí + if (xhr.status === 422 && xhr.responseText) { + $('#margenesPresupuestoModalBody').html(xhr.responseText); + const isEdit = $('#margenesPresupuestoModalBody #margenesPresupuestoForm input[name="_method"][value="PUT"]').length > 0; + const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add'); + $('#margenesPresupuestoModal .modal-title').text(title); + return; + } + // Fallback + $('#margenesPresupuestoModalBody').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 new file mode 100644 index 0000000..1087e07 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html @@ -0,0 +1,150 @@ +
+
+
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+ +
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccion-list.html b/src/main/resources/templates/imprimelibros/direcciones/direccion-list.html new file mode 100644 index 0000000..8dfa287 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-list.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + +
+
+ + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDClienteAliasNombre y ApellidosDirecciónCódigo PostalCiudadProvinciaPaísAcciones
+ + + + + + + + + + + + + + + +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/partials/sidebar.html b/src/main/resources/templates/imprimelibros/partials/sidebar.html index 508874d..a1854f0 100644 --- a/src/main/resources/templates/imprimelibros/partials/sidebar.html +++ b/src/main/resources/templates/imprimelibros/partials/sidebar.html @@ -43,6 +43,16 @@ Presupuestos +