Merge branch 'feat/direcciones_cliente' into 'main'

Feat/direcciones cliente

See merge request jjimenez/erp-imprimelibros!18
This commit is contained in:
2025-10-26 14:45:28 +00:00
29 changed files with 2384 additions and 10 deletions

View File

@ -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);

View File

@ -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());

View File

@ -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;
}
}

View File

@ -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<String> 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<String, String> 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<Map<String, Object>> 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<String> searchable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais");
List<String> orderable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais");
// Filtro base por rol (ROLE_USER solo ve sus direcciones)
Specification<Direccion> base = (root, query, cb) -> {
List<Predicate> 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 -> """
<div class="hstack gap-3 flex-wrap">
<a href="javascript:void(0);" data-id="%d" class="link-success btn-edit-direccion fs-15">
<i class="ri-edit-2-line"></i>
</a>
<a href="javascript:void(0);" data-id="%d" class="link-danger btn-delete-direccion fs-15">
<i class="ri-delete-bin-5-line"></i>
</a>
</div>
""".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<Map<String, Object>> 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<String> searchable = List.of(
"id",
"alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono", "is_facturacion", "razonSocial",
"identificacionFiscal");
List<String> orderable = List.of(
"id",
"cliente", "alias",
"att", "direccion", "cp", "ciudad", "provincia", "pais", "telefono");
// Filtro base por rol (ROLE_USER solo ve sus direcciones)
Specification<Direccion> base = (root, query, cb) -> {
List<Predicate> 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);
}
}

View File

@ -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<Direccion, Long>,
JpaSpecificationExecutor<Direccion> {
@Query("""
select d from Direccion d
left join fetch d.user
left join fetch d.pais
where d.user.id = :id
""")
List<DireccionView> 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<Direccion> findByIdWithPaisAndUser(@Param("id") Long id);
@Query(value = "SELECT * FROM direcciones", nativeQuery = true)
List<DireccionView> findAllWithDeleted();
// find by user_id
List<Direccion> findByUserId(Long userId);
// find by user_id with deleted
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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<String, Object> getPaises(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
Locale locale) {
return paisesService.getForSelect(q1, q2, locale);
}
}

View File

@ -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<Paises, String> {
Optional<Paises> findByCode(String code);
Optional<Paises> findByCode3(String code3);
Optional<Paises> findByCurrency(String currency);
}

View File

@ -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<String, Object> 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<Paises> all = repo.findAll();
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> options = all.stream()
.map(cc -> {
String key = cc.getKeyword();
String id = cc.getCode3();
String text = messageSource.getMessage("paises." + key, null, key, locale);
Map<String, String> 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<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
}
}

View File

@ -85,7 +85,6 @@ geoip.http.enabled=true
# Hibernate Timezone
#
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
#
# PDF Templates
#

View File

@ -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

View File

@ -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

View File

@ -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
1 keyword code code3 currency
2 argentina ar arg ARS
3 austria at aut EUR
4 australia au aus AUD
5 bosnia_herzegovina ba bih BAM
6 belgica be bel EUR
7 bulgaria bg bgr BGN
8 canada ca can CAD
9 suiza ch che CHF
10 chile cl chl CLP
11 colombia co col COP
12 republica_checa cz cze CZK
13 alemania de deu EUR
14 dinamarca dk dnk DKK
15 estonia ee est EUR
16 espania es esp EUR
17 finlandia fi fin EUR
18 francia fr fra EUR
19 reino_unido gb gbr GBP
20 grecia gr grc EUR
21 croacia hr hrv EUR
22 hungria hu hun HUF
23 irlanda ie irl EUR
24 israel il isr ILS
25 italia it ita EUR
26 lituania lt ltu EUR
27 luxemburgo lu lux EUR
28 letonia lv lva EUR
29 mexico mx mex MXN
30 holanda nl nld EUR
31 noruega no nor NOK
32 peru pe per PEN
33 polonia pl pol PLN
34 portugal pt prt EUR
35 rumania ro rou RON
36 serbia rs srb RSD
37 suecia se swe SEK
38 eslovenia si svn EUR
39 eslovaquia sk svk EUR
40 eeuu us usa USD

View File

@ -2,4 +2,8 @@ databaseChangeLog:
- include:
file: db/changelog/changesets/0001-baseline.yml
- include:
file: db/changelog/changesets/0002-create-pedidos.yml
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

View File

@ -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.

View File

@ -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?<br>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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 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
? `<span class="text-muted mt-2 fw-normal d-block">
<i class="ri-check-line"></i> ${window.languageBundle.get(['direcciones.isFacturacionShort']) || 'Disponible para facturación'}<br/>
${razon_social ? razon_social + "<br/>" : ""}
${this._esc(d.tipo_identificacion_fiscal || "")}${(d.tipo_identificacion_fiscal && d.identificacion_fiscal) ? ": " : ""}
${this._esc(d.identificacion_fiscal || "")}
</span>`
: "";
const html = `
<div class="col-lg-4 col-sm-6 ">
<div class="form-check card h-100 px-0">
<input
id="${this._esc(this.id)}"
type="hidden"
class="form-check-input"
data-id="${this._esc(d.id ?? '')}">
<label class="form-check-label h-100 d-flex flex-column" for="${this._esc(this.id)}">
<div class="p-2 mx-3">
<span class="mb-2 fw-semibold d-block text-muted text-uppercase">${header}</span>
${contactLine ? `<span class="fs-14 mb-1 d-block">${contactLine}</span>` : ''}
${addressLine ? `<span class="text-muted fw-normal text-wrap mb-1 d-block">${addressLine}</span>` : ''}
${country ? `<span class="text-muted fw-normal d-block">${country}</span>` : ''}
${phoneLine ? `<span class="text-muted fw-normal d-block">${window.languageBundle.get(['direcciones.telefono'])}: ${phoneLine}</span>` : ''}
${extraFiscal}
</div>
<!-- Acciones integradas en la tarjeta -->
<div class="d-flex flex-wrap align-items-center gap-2 px-2 py-1 bg-light rounded-bottom border-top mt-auto actions-row">
<a href="#" class="d-block text-body p-1 px-2 btn-edit-direccion" data-id="${this._esc(d.id ?? '')}">
<i class="ri-pencil-fill text-muted align-bottom me-1"></i> ${window.languageBundle.get(['direcciones.btn.edit']) || 'Editar'}
</a>
<a href="#" class="d-block text-body p-1 px-2 btn-delete-direccion" data-id="${this._esc(d.id ?? '')}">
<i class="ri-delete-bin-fill text-muted align-bottom me-1"></i> ${window.languageBundle.get(['direcciones.btn.delete']) || 'Eliminar'}
</a>
</div>
</label>
</div>
</div>
`;
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"));
=========================================================== */

View File

@ -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('<html') === -1) {
$('#direccionFormModalBody').html(html);
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;
}
// É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('<div class="p-3 text-danger">Error inesperado.</div>');
}
});
});
})();

View File

@ -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('<html') === -1) {
$('#direccionFormModalBody').html(html);
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;
}
// É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('<div class="p-3 text-danger">Error inesperado.</div>');
}
});
});
})();

View File

@ -0,0 +1,180 @@
<div th:fragment="direccionForm">
<form id="direccionForm" novalidate th:action="${action}" th:object="${dirForm}" method="post"
th:data-add="#{direcciones.add}" th:data-edit="#{direcciones.editar}">
<div class="alert alert-danger" th:if="${#fields.hasGlobalErrors()}" th:each="err : ${#fields.globalErrors()}">
<span th:text="${err}">Error</span>
</div>
<div sec:authorize="hasAnyRole('SUPERADMIN','ADMIN')" class="form-group">
<label for="user_id">
<span th:text="#{direcciones.user}">Cliente</span>
<span class="text-danger">*</span>
</label>
<select class="form-control select2 direccion-item" id="user_id" th:field="*{user}" th:attr="data-init-id=*{user?.id},
data-init-name=*{user?.fullName}" th:classappend="${#fields.hasErrors('user')} ? ' is-invalid'">
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('user')}" th:errors="*{user}"></div>
<div class="invalid-feedback" th:if="${#fields.hasErrors('user.id')}" th:errors="*{user.id}"></div>
</div>
<input sec:authorize="hasAnyRole('USER')" type="hidden" th:field="*{user.id}" />
<div class="form-group mt-2">
<label for="alias">
<span th:text="#{direcciones.alias}">Alias</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="alias" th:field="*{alias}" maxlength="100" required
th:classappend="${#fields.hasErrors('alias')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('alias')}" th:errors="*{alias}"></div>
<label th:text="#{direcciones.alias-descripcion}" class="form-text text-muted"></label>
</div>
<div class="form-group mt-2">
<label for="att">
<span th:text="#{direcciones.nombre}">Nombre y Apellidos</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="att" th:field="*{att}" maxlength="150" required
th:classappend="${#fields.hasErrors('att')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('att')}" th:errors="*{att}"></div>
</div>
<div class="form-group mt-2">
<label for="direccion">
<span th:text="#{direcciones.direccion}">Dirección</span>
<span class="text-danger">*</span>
</label>
<textarea class="form-control direccion-item" id="direccion" th:field="*{direccion}" maxlength="255"
required style="max-height: 125px;"
th:classappend="${#fields.hasErrors('direccion')} ? ' is-invalid'"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('direccion')}" th:errors="*{direccion}"></div>
</div>
<div class="row mt-2">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="cp">
<span th:text="#{direcciones.cp}">Código Postal</span>
<span class="text-danger">*</span>
</label>
<input type="number" class="form-control direccion-item" id="cp" th:field="*{cp}" min="1" max="99999"
required th:classappend="${#fields.hasErrors('cp')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('cp')}" th:errors="*{cp}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 mr-0">
<label for="ciudad">
<span th:text="#{direcciones.ciudad}">Ciudad</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="ciudad" th:field="*{ciudad}" maxlength="100" required
th:classappend="${#fields.hasErrors('ciudad')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('ciudad')}" th:errors="*{ciudad}"></div>
</div>
</div>
<div class="row mt-2">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="provincia">
<span th:text="#{direcciones.provincia}">Provincia</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="provincia" th:field="*{provincia}" maxlength="100"
required th:classappend="${#fields.hasErrors('provincia')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('provincia')}" th:errors="*{provincia}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 mr-0">
<label for="pais">
<span th:text="#{direcciones.pais}">País</span>
<span class="text-danger">*</span>
</label>
<select class="form-control select2 direccion-item" id="paisCode3" th:field="*{paisCode3}"
th:classappend="${#fields.hasErrors('paisCode3')} ? ' is-invalid'">
<option th:each="pais : ${paises}" th:value="${pais.id}" th:text="${pais.text}"
th:selected="${pais.id} == ${dirForm.paisCode3}">
</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('paisCode3')}" th:errors="*{paisCode3}"></div>
</div>
</div>
<div class="form-group mt-2">
<label for="telefono">
<span th:text="#{direcciones.telefono}">Teléfono</span>
</label>
<input class="form-control direccion-item" id="telefono" th:field="*{telefono}" maxlength="50"
th:classappend="${#fields.hasErrors('telefono')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('telefono')}" th:errors="*{telefono}"></div>
</div>
<div class="form-group mt-2">
<label for="instrucciones">
<span th:text="#{direcciones.instrucciones}">Instrucciones</span>
</label>
<textarea class="form-control direccion-item" id="instrucciones" th:field="*{instrucciones}" maxlength="255"
style="max-height: 125px;"
th:classappend="${#fields.hasErrors('instrucciones')} ? ' is-invalid'"></textarea>
<div class="invalid-feedback" th:if="${#fields.hasErrors('instrucciones')}" th:errors="*{instrucciones}">
</div>
</div>
<div class="form-check form-switch form-switch-custom my-2">
<input type="checkbox"
class="form-check-input form-switch-custom-primary direccion-item direccionFacturacion"
id="direccionFacturacion" th:field="*{direccionFacturacion}">
<label for="direccionFacturacion" class="form-check-label" th:text="#{direcciones.isFacturacion}">
Usar también como dirección de facturación
</label>
</div>
<div
th:class="'form-group direccionFacturacionItems' + (${direccion != null and direccion.direccionFacturacion} ? '' : ' d-none')">
<label for="razonSocial">
<span th:text="#{direcciones.razon_social}">Razón Social</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="razonSocial" th:field="*{razonSocial}" maxlength="150"
th:classappend="${#fields.hasErrors('razonSocial')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('razonSocial')}" th:errors="*{razonSocial}"></div>
</div>
<div
th:class="'row mt-2 direccionFacturacionItems' + (${direccion != null and direccion.direccionFacturacion} ? '' : ' d-none')">
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="tipoIdentificacionFiscal">
<span th:text="#{direcciones.tipo_identificacion_fiscal}">Tipo de identificación fiscal</span>
<span class="text-danger">*</span>
</label>
<select class="form-control select2 direccion-item" id="tipoIdentificacionFiscal"
th:field="*{tipoIdentificacionFiscal}"
th:classappend="${#fields.hasErrors('tipoIdentificacionFiscal')} ? ' is-invalid'">
<option th:value="DNI" th:text="#{direcciones.dni}">DNI</option>
<option th:value="NIE" th:text="#{direcciones.nie}">NIE</option>
<option th:value="Pasaporte" th:text="#{direcciones.pasaporte}">Pasaporte</option>
<option th:value="CIF" th:text="#{direcciones.cif}">CIF</option>
<option th:value="VAT_ID" th:text="#{direcciones.vat_id}">VAT ID</option>
</select>
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoIdentificacionFiscal')}"
th:errors="*{tipoIdentificacionFiscal}"></div>
</div>
<div class="form-group col-lg-6 col-md-6 col-sm-12 ml-0">
<label for="identificacionFiscal">
<span th:text="#{direcciones.identificacion_fiscal}">Número de identificación fiscal</span>
<span class="text-danger">*</span>
</label>
<input class="form-control direccion-item" id="identificacionFiscal" th:field="*{identificacionFiscal}"
maxlength="50" th:classappend="${#fields.hasErrors('identificacionFiscal')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('identificacionFiscal')}"
th:errors="*{identificacionFiscal}"></div>
</div>
</div>
<div class="d-flex align-items-center justify-content-center">
<button type="submit" class="btn btn-secondary mt-3" th:text="#{direcciones.save}"></button>
</div>
</form>
</div>

View File

@ -0,0 +1,116 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<!-- Modales-->
<div
th:replace="imprimelibros/partials/modal-form :: modal('direccionFormModal', 'direcciones.add', 'modal-md', 'direccionFormModalBody')">
</div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{direcciones.breadcrumb}">
Direcciones</li>
</ol>
</nav>
<div class="container-fluid">
<input type="hidden" id="isUser" th:value="${isUser}" />
<div class="d-flex">
<button type="button" class="btn btn-secondary mb-3" id="addButton">
<i class="ri-add-line align-bottom me-1"></i> <span th:text="#{direcciones.add}">Añadir</span>
</button>
</div>
<div class="d-flex mb-2 ms-0 ps-0">
<input type="text" id="buscadorDirecciones" class="form-control ms-0"
th:placeholder="#{direcciones.buscar-placeholder}"
style="width:auto; display:inline-block; margin-left:10px;" />
</div>
<div class="d-flex">
<label class="form-label mt-2 me-2" for="pageSize" th:text="#{direcciones.registros-pagina}">Registros
por página</label>
<select id="pageSize" class="form-select" style="width:auto">
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
<div id="direccionesContainer" class="row g-3 my-3"></div>
<!-- Tabla sombra para DataTables (oculta) -->
<table id="dtDirecciones" class="d-none" style="width:100%">
<thead>
<tr>
<th>alias</th>
<th>att</th>
<th>direccion</th>
<th>cp</th>
<th>ciudad</th>
<th>provincia</th>
<th>pais</th>
<th>telefono</th>
<th>is_facturacion</th>
<th>razon_social</th>
<th>tipo_identificacion_fiscal</th>
<th>identificacion_fiscal</th>
<th>id</th>
</tr>
</thead>
</table>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script th:if="${isUser==0}" type="module"
th:src="@{/assets/js/pages/imprimelibros/direcciones/list.js}"></script>
<script th:if="${isUser==1}" type="module"
th:src="@{/assets/js/pages/imprimelibros/direcciones/listc.js}"></script>
</th:block>
</body>
</html>

View File

@ -0,0 +1,128 @@
<!doctype html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{imprimelibros/layout}">
<head>
<th:block layout:fragment="pagetitle" />
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
</th:block>
</head>
<body>
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}" />
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<!-- Modales-->
<div
th:replace="imprimelibros/partials/modal-form :: modal('direccionFormModal', 'direcciones.add', 'modal-md', 'direccionFormModalBody')">
</div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
<li class="breadcrumb-item active" aria-current="page" th:text="#{direcciones.breadcrumb}">
Direcciones</li>
</ol>
</nav>
<div class="container-fluid">
<input type="hidden" id="isUser" th:value="${isUser}" />
<button type="button" class="btn btn-secondary mb-3" id="addButton">
<i class="ri-add-line align-bottom me-1"></i> <span
th:text="#{direcciones.add}">Añadir</span>
</button>
<table id="direcciones-datatable" class="table table-striped table-nowrap responsive w-100">
<thead>
<tr>
<th scope="col" th:text="#{direcciones.tabla.id}">ID</th>
<th scope="col" th:text="#{direcciones.tabla.cliente}">Cliente</th>
<th scope="col" th:text="#{direcciones.alias}">Alias</th>
<th scope="col" th:text="#{direcciones.tabla.att}">Att.</th>
<th scope="col" th:text="#{direcciones.direccion}">Dirección</th>
<th scope="col" th:text="#{direcciones.cp}">Código Postal</th>
<th scope="col" th:text="#{direcciones.ciudad}">Ciudad</th>
<th scope="col" th:text="#{direcciones.provincia}">Provincia</th>
<th scope="col" th:text="#{direcciones.pais}">País</th>
<th scope="col" th:text="#{direcciones.tabla.acciones}">Acciones</th>
</tr>
<tr>
<th><input type="text" class="form-control form-control-sm direcciones-filter"
data-col="id" /></th>
<th>
<input type="text" class="form-control form-control-sm direcciones-filter"
data-col="cliente" />
</th>
<th>
<input type="text" class="form-control form-control-sm direcciones-filter"
data-col="alias" />
</th>
<th>
<input type="nombre" class="form-control form-control-sm direcciones-filter"
data-col="margenMin" />
</th>
<th>
<input type="direccion" class="form-control form-control-sm direcciones-filter"
data-col="margenMax" />
</th>
<th>
<input type="cp" class="form-control form-control-sm direcciones-filter"
data-col="cp" />
</th>
<th>
<input type="ciudad" class="form-control form-control-sm direcciones-filter"
data-col="ciudad" />
</th>
<th>
<input type="provincia" class="form-control form-control-sm direcciones-filter"
data-col="provincia" />
</th>
<th>
<input type="pais" class="form-control form-control-sm direcciones-filter"
data-col="pais" />
</th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
<th:block layout:fragment="pagejs">
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
<!-- JS de Buttons y dependencias -->
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
<script type="module" th:src="@{/assets/js/pages/imprimelibros/direcciones/list.js}"></script>
</th:block>
</body>
</html>

View File

@ -43,6 +43,16 @@
<i class="ri-file-paper-2-line"></i> <span th:text="#{app.sidebar.presupuestos}">Presupuestos</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link menu-link" href="/direcciones">
<i class="ri-truck-line"></i>
<span th:if="${#authentication.principal.role == 'SUPERADMIN' or #authentication.principal.role == 'ADMIN'}"
th:text="#{app.sidebar.direcciones-admin}">Administrar Direcciones</span>
</span>
<span th:if="${#authentication.principal.role != 'SUPERADMIN' and #authentication.principal.role != 'ADMIN'}"
th:text="#{app.sidebar.direcciones}">Mis Direcciones</span>
</a>
</li>
<li th:if="${#authentication.principal.role == 'SUPERADMIN' or #authentication.principal.role == 'ADMIN'}" class="nav-item">
<a class="nav-link menu-link collapsed" href="#sidebarConfig" data-bs-toggle="collapse"
role="button" aria-expanded="false" aria-controls="sidebarConfig">