diff --git a/src/main/java/com/imprimelibros/erp/config/BeanValidationConfig.java b/src/main/java/com/imprimelibros/erp/config/BeanValidationConfig.java index 1d72169..05a7273 100644 --- a/src/main/java/com/imprimelibros/erp/config/BeanValidationConfig.java +++ b/src/main/java/com/imprimelibros/erp/config/BeanValidationConfig.java @@ -1,5 +1,6 @@ package com.imprimelibros.erp.config; +import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; @@ -10,13 +11,16 @@ import jakarta.validation.ValidatorFactory; @Configuration public class BeanValidationConfig { - // Asegura que usamos la factory de Spring (con SpringConstraintValidatorFactory) + // Usa el MessageSource (messages*.properties) para resolver {códigos} @Bean - public LocalValidatorFactoryBean validator() { - return new LocalValidatorFactoryBean(); + public LocalValidatorFactoryBean validator(MessageSource messageSource) { + LocalValidatorFactoryBean bean = new LocalValidatorFactoryBean(); + bean.setValidationMessageSource(messageSource); // <-- CLAVE + + return bean; } - // Inserta esa factory en Hibernate/JPA + // Inserta esa factory en Hibernate/JPA (opcional pero correcto) @Bean public HibernatePropertiesCustomizer hibernateValidationCustomizer(ValidatorFactory vf) { return props -> props.put("jakarta.persistence.validation.factory", vf); 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..693b771 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/Direccion.java @@ -0,0 +1,237 @@ +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; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; + +@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) + @NotNull(message = "{direcciones.form.error.required}") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "alias", length = 100, nullable = false) + private String alias; + + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "att", length = 150, nullable = false) + private String att; + + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "direccion", length = 255, nullable = false) + private String direccion; + + @NotNull(message = "{direcciones.form.error.required}") + @Column(name = "cp", length = 20, nullable = false) + private Integer cp; + + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "ciudad", length = 100, nullable = false) + private String ciudad; + + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "provincia", length = 100, nullable = false) + private String provincia; + + // Usamos el code3 del país como FK lógica (String) + @NotBlank(message = "{direcciones.form.error.required}") + @Column(name = "pais_code3", length = 3, nullable = false) + private String paisCode3 = "esp"; + + @NotBlank(message = "{direcciones.form.error.required}") + @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..8993f40 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java @@ -0,0 +1,437 @@ +package com.imprimelibros.erp.direcciones; + +import java.time.Instant; +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.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; +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.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; + +import jakarta.persistence.criteria.Predicate; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +@Controller +@RequestMapping("/direcciones") +public class DireccionController { + + protected final DireccionRepository repo; + protected final PaisesService paisesService; + protected final MessageSource messageSource; + protected final UserDao userRepo; + protected final TranslationService translationService; + + + public DireccionController(DireccionRepository repo, PaisesService paisesService, + MessageSource messageSource, UserDao userRepo, TranslationService translationService) { + this.repo = repo; + this.paisesService = paisesService; + this.messageSource = messageSource; + this.userRepo = userRepo; + this.translationService = translationService; + } + + @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); + + List keys = List.of( + "direcciones.delete.title", + "direcciones.delete.text", + "direcciones.eliminar", + "direcciones.delete.button", + "app.yes", + "app.cancelar", + "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); + + if (isUser) + return "imprimelibros/direcciones/direccion-list-cliente"; + else + 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(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()) + + // 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(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")); + + 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("dirForm", opt.get()); + model.addAttribute("action", "/direcciones/" + id); + } else { + + 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"; + } + + @PostMapping + public String create( + @Valid @ModelAttribute("dirForm") Direccion direccion, + 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")); + model.addAttribute("action", "/direcciones"); + model.addAttribute("dirForm", direccion); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + var data = direccion; + + repo.save(data); + response.setStatus(201); + return null; + } + + @PostMapping("/{id}") + public String update( + @PathVariable Long id, + @Valid @ModelAttribute("dirForm") Direccion direccion, // <- nombre distinto + BindingResult binding, + Model model, + Authentication auth, + HttpServletResponse response, + Locale locale) { + + var opt = repo.findById(id); + if (opt.isEmpty()) { + binding.reject("direcciones.error.noEncontrado", + messageSource.getMessage("direcciones.error.noEncontrado", null, locale)); + response.setStatus(404); + model.addAttribute("dirForm", direccion); // por si re-renderiza + model.addAttribute("action", "/direcciones/" + id); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + Long ownerId = opt.get().getUser() != null ? opt.get().getUser().getId() : null; + if (!isOwnerOrAdmin(auth, ownerId)) { + binding.reject("direcciones.error.sinPermiso", + messageSource.getMessage("direcciones.error.sinPermiso", null, locale)); + response.setStatus(403); + model.addAttribute("dirForm", direccion); // por si re-renderiza + model.addAttribute("action", "/direcciones/" + id); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + if (binding.hasErrors()) { + response.setStatus(422); + model.addAttribute("dirForm", direccion); // <- importante + model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results")); + model.addAttribute("action", "/direcciones/" + id); + return "imprimelibros/direcciones/direccion-form :: direccionForm"; + } + + repo.save(direccion); + response.setStatus(200); + return null; + } + + @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) { + return false; + } + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + if (isAdmin) { + return true; + } + // Aquí deberías obtener el ID del usuario actual desde tu servicio de usuarios + Long currentUserId = null; + if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { + currentUserId = udi.getId(); + } else if (auth != null) { + currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); + } + return currentUserId != null && currentUserId.equals(ownerId); + } +} 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..fc45640 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java @@ -0,0 +1,45 @@ +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 from Direccion d + left join fetch d.user + left join fetch d.pais + where d.user.id = :id + """) + List findAllWithPaisAndUser(@Param("userId") Long userId); + + @Query(""" + select d from Direccion d + left join fetch d.user + left join fetch d.pais + 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..b9d6cfd --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionView.java @@ -0,0 +1,28 @@ +package com.imprimelibros.erp.direcciones; + +public interface DireccionView { + Long getId(); + UserView getUser(); + String getAlias(); + String getAtt(); + String getDireccion(); + String getCp(); + String getCiudad(); + String getProvincia(); + PaisView getPais(); + String getPaisKeyword(); + String getTelefono(); + Boolean getIsFacturacion(); + String getRazonSocial(); + String getTipoIdentificacionFiscal(); + String getIdentificacionFiscal(); + String getCliente(); + interface UserView { + Long getId(); + String getFullName(); + } + interface PaisView { + String getCode3(); + String getKeyword(); + } +} diff --git a/src/main/java/com/imprimelibros/erp/paises/Paises.java b/src/main/java/com/imprimelibros/erp/paises/Paises.java new file mode 100644 index 0000000..3904924 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/paises/Paises.java @@ -0,0 +1,82 @@ +package com.imprimelibros.erp.paises; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "paises") +public class Paises { + + @Id + @Column(name = "keyword", length = 64, nullable = false) + private String keyword; + + @Column(name = "code", length = 2, nullable = false, unique = true) + private String code; + + @Column(name = "code3", length = 3, nullable = false, unique = true) + private String code3; + + @Column(name = "currency", length = 3, nullable = false) + private String currency; + + // --- Getters & Setters --- + + public String getKeyword() { + return keyword; + } + + public void setKeyword(String keyword) { + this.keyword = keyword; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } + + public String getCode3() { + return code3; + } + + public void setCode3(String code3) { + this.code3 = code3; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + // --- toString, equals & hashCode --- + + @Override + public String toString() { + return "Paises{" + + "keyword='" + keyword + '\'' + + ", code='" + code + '\'' + + ", code3='" + code3 + '\'' + + ", currency='" + currency + '\'' + + '}'; + } + + @Override + public int hashCode() { + return keyword != null ? keyword.hashCode() : 0; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof Paises other)) return false; + return keyword != null && keyword.equals(other.keyword); + } +} diff --git a/src/main/java/com/imprimelibros/erp/paises/PaisesController.java b/src/main/java/com/imprimelibros/erp/paises/PaisesController.java new file mode 100644 index 0000000..d732a3e --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/paises/PaisesController.java @@ -0,0 +1,36 @@ +package com.imprimelibros.erp.paises; + +import org.springframework.web.bind.annotation.*; + +import java.util.*; + +@RestController +@RequestMapping("/api/paises") +public class PaisesController { + + private final PaisesService paisesService; + + public PaisesController(PaisesService paisesService) { + this.paisesService = paisesService; + } + + /** + * Compatible con Select2 (AJAX): + * - Soporta parámetros opcionales: + * - q / term : texto a buscar + * - lang : ej. "es", "en" (si se omite usa Locale actual) + * + * Respuesta: + * { "results": [ { "id": "espania", "text": "España" }, ... ] } + */ + @GetMapping + public Map getPaises( + @RequestParam(value = "q", required = false) String q1, + @RequestParam(value = "term", required = false) String q2, + Locale locale) { + + + return paisesService.getForSelect(q1, q2, locale); + + } +} diff --git a/src/main/java/com/imprimelibros/erp/paises/PaisesRepository.java b/src/main/java/com/imprimelibros/erp/paises/PaisesRepository.java new file mode 100644 index 0000000..fd9bef6 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/paises/PaisesRepository.java @@ -0,0 +1,16 @@ +package com.imprimelibros.erp.paises; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface PaisesRepository extends JpaRepository { + + Optional findByCode(String code); + + Optional findByCode3(String code3); + + Optional findByCurrency(String currency); +} 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/application.properties b/src/main/resources/application.properties index a58a4f4..d56bf95 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -85,7 +85,6 @@ geoip.http.enabled=true # Hibernate Timezone # spring.jpa.properties.hibernate.jdbc.time_zone=UTC - # # PDF Templates # diff --git a/src/main/resources/db/changelog/changesets/0003-create-paises.yml b/src/main/resources/db/changelog/changesets/0003-create-paises.yml new file mode 100644 index 0000000..da3e899 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0003-create-paises.yml @@ -0,0 +1,52 @@ +databaseChangeLog: + - changeSet: + id: 0003-create-paises + author: jjo + changes: + - createTable: + tableName: paises + columns: + - column: + name: keyword + type: varchar(64) + constraints: + nullable: false + primaryKey: true + primaryKeyName: pk_paises + - column: + name: code + type: char(2) + constraints: + nullable: false + - column: + name: code3 + type: char(3) + constraints: + nullable: false + - column: + name: currency + type: char(3) + constraints: + nullable: false + + - addUniqueConstraint: + tableName: paises + columnNames: code + constraintName: uq_paises_code + + - addUniqueConstraint: + tableName: paises + columnNames: code3 + constraintName: uq_paises_code3 + + - loadData: + tableName: paises + file: db/changelog/data/paises.csv + separator: "," + encoding: UTF-8 + quotchar: '"' + + rollback: + - dropTable: + tableName: paises + cascadeConstraints: true 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/data/paises.csv b/src/main/resources/db/changelog/data/paises.csv new file mode 100644 index 0000000..12cea57 --- /dev/null +++ b/src/main/resources/db/changelog/data/paises.csv @@ -0,0 +1,40 @@ +keyword,code,code3,currency +argentina,ar,arg,ARS +austria,at,aut,EUR +australia,au,aus,AUD +bosnia_herzegovina,ba,bih,BAM +belgica,be,bel,EUR +bulgaria,bg,bgr,BGN +canada,ca,can,CAD +suiza,ch,che,CHF +chile,cl,chl,CLP +colombia,co,col,COP +republica_checa,cz,cze,CZK +alemania,de,deu,EUR +dinamarca,dk,dnk,DKK +estonia,ee,est,EUR +espania,es,esp,EUR +finlandia,fi,fin,EUR +francia,fr,fra,EUR +reino_unido,gb,gbr,GBP +grecia,gr,grc,EUR +croacia,hr,hrv,EUR +hungria,hu,hun,HUF +irlanda,ie,irl,EUR +israel,il,isr,ILS +italia,it,ita,EUR +lituania,lt,ltu,EUR +luxemburgo,lu,lux,EUR +letonia,lv,lva,EUR +mexico,mx,mex,MXN +holanda,nl,nld,EUR +noruega,no,nor,NOK +peru,pe,per,PEN +polonia,pl,pol,PLN +portugal,pt,prt,EUR +rumania,ro,rou,RON +serbia,rs,srb,RSD +suecia,se,swe,SEK +eslovenia,si,svn,EUR +eslovaquia,sk,svk,EUR +eeuu,us,usa,USD diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index d57981d..90f38f5 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -2,4 +2,8 @@ databaseChangeLog: - include: file: db/changelog/changesets/0001-baseline.yml - include: - file: db/changelog/changesets/0002-create-pedidos.yml \ No newline at end of file + file: db/changelog/changesets/0002-create-pedidos.yml + - include: + 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..bc30637 --- /dev/null +++ b/src/main/resources/i18n/direcciones_es.properties @@ -0,0 +1,57 @@ +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 +direcciones.edit=Editar dirección +direcciones.save=Guardar dirección +direcciones.user=Cliente +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.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 + +direcciones.tabla.id=ID +direcciones.tabla.att=Att. +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.delete.title=Eliminar dirección +direcciones.delete.button=Si, ELIMINAR +direcciones.delete.text=¿Está seguro de que desea eliminar esta dirección?
Esta acción no se puede deshacer. +direcciones.delete.ok.title=Dirección eliminada +direcciones.delete.ok.text=La dirección ha sido eliminada con éxito. + +direcciones.btn.edit=Editar +direcciones.btn.delete=Eliminar +direcciones.buscar-placeholder=Buscar en direcciones... +direcciones.registros-pagina=Registros por página: + +direcciones.exito.eliminado=Dirección eliminada con éxito. + +direcciones.error.delete-internal-error=Error interno al eliminar la dirección. + +direcciones.error.noEncontrado=Dirección no encontrada. +direcciones.error.sinPermiso=No tiene permiso para realizar esta acción. + +direcciones.form.error.required=Campo obligatorio. + 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/i18n/paises_en.properties b/src/main/resources/i18n/paises_en.properties new file mode 100644 index 0000000..39efe2d --- /dev/null +++ b/src/main/resources/i18n/paises_en.properties @@ -0,0 +1,39 @@ +paises.argentina=Argentina +paises.austria=Austria +paises.australia=Australia +paises.bosnia_herzegovina=Bosnia and Herzegovina +paises.belgica=Belgium +paises.bulgaria=Bulgaria +paises.canada=Canada +paises.suiza=Switzerland +paises.chile=Chile +paises.colombia=Colombia +paises.republica_checa=Czech Republic +paises.alemania=Germany +paises.dinamarca=Denmark +paises.estonia=Estonia +paises.espania=Spain +paises.finlandia=Finland +paises.francia=France +paises.reino_unido=United Kingdom +paises.grecia=Greece +paises.croacia=Croatia +paises.hungria=Hungary +paises.irlanda=Ireland +paises.israel=Israel +paises.italia=Italy +paises.lituania=Lithuania +paises.luxemburgo=Luxembourg +paises.letonia=Latvia +paises.mexico=Mexico +paises.holanda=Netherlands +paises.noruega=Norway +paises.peru=Peru +paises.polonia=Poland +paises.portugal=Portugal +paises.rumania=Romania +paises.serbia=Serbia +paises.suecia=Sweden +paises.eslovenia=Slovenia +paises.eslovaquia=Slovakia +paises.eeuu=United States diff --git a/src/main/resources/i18n/paises_es.properties b/src/main/resources/i18n/paises_es.properties new file mode 100644 index 0000000..2036962 --- /dev/null +++ b/src/main/resources/i18n/paises_es.properties @@ -0,0 +1,39 @@ +paises.argentina=Argentina +paises.austria=Austria +paises.australia=Australia +paises.bosnia_herzegovina=Bosnia y Herzegovina +paises.belgica=Bélgica +paises.bulgaria=Bulgaria +paises.canada=Canadá +paises.suiza=Suiza +paises.chile=Chile +paises.colombia=Colombia +paises.republica_checa=República Checa +paises.alemania=Alemania +paises.dinamarca=Dinamarca +paises.estonia=Estonia +paises.espania=España +paises.finlandia=Finlandia +paises.francia=Francia +paises.reino_unido=Reino Unido +paises.grecia=Grecia +paises.croacia=Croacia +paises.hungria=Hungría +paises.irlanda=Irlanda +paises.israel=Israel +paises.italia=Italia +paises.lituania=Lituania +paises.luxemburgo=Luxemburgo +paises.letonia=Letonia +paises.mexico=México +paises.holanda=Países Bajos +paises.noruega=Noruega +paises.peru=Perú +paises.polonia=Polonia +paises.portugal=Portugal +paises.rumania=Rumanía +paises.serbia=Serbia +paises.suecia=Suecia +paises.eslovenia=Eslovenia +paises.eslovaquia=Eslovaquia +paises.eeuu=Estados Unidos 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/direccionCard.js b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/direccionCard.js new file mode 100644 index 0000000..33d736d --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/direccionCard.js @@ -0,0 +1,140 @@ +// Requiere jQuery y Bootstrap 5 (para los data-bs-* de los modals) +export class DireccionCard { + /** + * @param {Object} opts + * @param {Object} opts.direccion // objeto con tus campos de BBDD + * - id, user_id, alias, att, direccion, cp, ciudad, provincia, pais_code3, + * telefono, is_facturacion, razon_social, + * tipo_identificacion_fiscal, identificacion_fiscal + */ + constructor(opts) { + this.opts = Object.assign({}, opts || {}); + + this.dir = this.opts.direccion || {}; + this.id = `shippingAddress_${this.dir.id || Math.random().toString(36).slice(2, 8)}`; + this.$el = null; + } + + // Escapa HTML para evitar XSS si llega texto “sucio”. + _esc(str) { + return String(str ?? "") + .replace(/&/g, "&") + .replace(//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 new file mode 100644 index 0000000..599a0f9 --- /dev/null +++ b/src/main/resources/static/assets/js/pages/imprimelibros/direcciones/list.js @@ -0,0 +1,222 @@ + + +(() => { + // 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: $('#isUser').val() == 1 ? 'lrtip' : '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() == 1 ? false : true }, + { data: 'cliente', name: 'cliente', orderable: true, visible: $('#isUser').val() == 1 ? false : true }, + { data: 'alias', name: 'alias', orderable: true }, + { data: 'att', name: 'att', 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(); + initSelect2Cliente(true); + }); + }); + + function initSelect2Cliente(initialize = false) { + + if ($('#isUser').val() == 0) { + const $sel = $('#user_id').select2({ + dropdownParent: modalEl, + width: '100%', + ajax: { + url: 'users/api/get-users', + dataType: 'json', + delay: 250, + }, + allowClear: true + }); + if (initialize) { + const id = $sel.data('init-id'); + const text = $sel.data('init-name'); + const option = new Option(text, id, true, true); + $('#user_id').append(option).trigger('change'); + } + } + + } + + // 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(); + initSelect2Cliente(true); + }); + }); + + + // 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', + }, + }); + $('#direccion-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 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(); + table.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); + initSelect2Cliente(true); + return; + } + // Fallback + $('#direccionFormModalBody').html('
Error inesperado.
'); + } + }); + }); + +})(); 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 new file mode 100644 index 0000000..d124b90 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-form.html @@ -0,0 +1,180 @@ +
+
+ +
+ Error +
+ +
+ + +
+
+
+ + + +
+ + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+
+ +
+ + +
+
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccion-list-cliente.html b/src/main/resources/templates/imprimelibros/direcciones/direccion-list-cliente.html new file mode 100644 index 0000000..0a6235f --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-list-cliente.html @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + +
+
+ + +
+ + +
+
+ + + +
+ + + +
+ +
+ +
+ +
+
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + +
aliasattdireccioncpciudadprovinciapaistelefonois_facturacionrazon_socialtipo_identificacion_fiscalidentificacion_fiscalid
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + \ 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..e2560b5 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccion-list.html @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + +
+
+ + +
+ + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDClienteAliasAtt.Direcció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 +