trabajando en añadir

This commit is contained in:
2025-10-24 16:15:05 +02:00
parent 3517918afe
commit 2ed032d7c6
18 changed files with 1412 additions and 47 deletions

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,225 @@
package com.imprimelibros.erp.direcciones;
import jakarta.persistence.*;
import java.io.Serializable;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import com.imprimelibros.erp.common.jpa.AbstractAuditedEntity;
import com.imprimelibros.erp.paises.Paises;
import com.imprimelibros.erp.users.User;
@Entity
@Table(name = "direcciones", indexes = {
@Index(name = "idx_direcciones_user", columnList = "user_id"),
@Index(name = "idx_direcciones_pais_code3", columnList = "pais_code3")
})
@SQLDelete(sql = "UPDATE direcciones SET deleted = 1, deleted_at = NOW(3) WHERE id = ?")
@SQLRestriction("deleted = 0")
public class Direccion extends AbstractAuditedEntity implements Serializable {
public enum TipoIdentificacionFiscal {
DNI("direcciones.dni"),
NIE("direcciones.nie"),
CIF("direcciones.cif"),
Pasaporte("direcciones.pasaporte"),
VAT_ID("direcciones.vat_id");
private String key;
TipoIdentificacionFiscal(String key) {
this.key = key;
}
public String getKey() {
return key;
}
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// --- FK a users(id)
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "alias", length = 100, nullable = false)
private String alias;
@Column(name = "att", length = 150, nullable = false)
private String att;
@Column(name = "direccion", length = 255, nullable = false)
private String direccion;
@Column(name = "cp", length = 20, nullable = false)
private Integer cp;
@Column(name = "ciudad", length = 100, nullable = false)
private String ciudad;
@Column(name = "provincia", length = 100, nullable = false)
private String provincia;
// Usamos el code3 del país como FK lógica (String)
@Column(name = "pais_code3", length = 3, nullable = false)
private String paisCode3 = "esp";
@Column(name = "telefono", length = 30, nullable = false)
private String telefono;
@Column(name = "instrucciones", length = 255)
private String instrucciones;
@Column(name = "is_facturacion", nullable = false)
private boolean direccionFacturacion = false;
@Column(name = "razon_social", length = 150)
private String razonSocial;
@Enumerated(EnumType.STRING)
@Column(name = "tipo_identificacion_fiscal", length = 20, nullable = false)
private TipoIdentificacionFiscal tipoIdentificacionFiscal = TipoIdentificacionFiscal.DNI;
@Column(name = "identificacion_fiscal", length = 50)
private String identificacionFiscal;
// --- Asociación opcional (read-only) a Pais por code3, si tienes la entidad
// Pais
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "pais_code3", referencedColumnName = "code3", insertable = false, updatable = false)
private Paises pais;
// --- Getters & Setters ---
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getAlias() {
return alias;
}
public void setAlias(String alias) {
this.alias = alias;
}
public String getAtt() {
return att;
}
public void setAtt(String att) {
this.att = att;
}
public String getDireccion() {
return direccion;
}
public void setDireccion(String direccion) {
this.direccion = direccion;
}
public Integer getCp() {
return cp;
}
public void setCp(Integer cp) {
this.cp = cp;
}
public String getCiudad() {
return ciudad;
}
public void setCiudad(String ciudad) {
this.ciudad = ciudad;
}
public String getProvincia() {
return provincia;
}
public void setProvincia(String provincia) {
this.provincia = provincia;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public String getTelefono() {
return telefono;
}
public void setTelefono(String telefono) {
this.telefono = telefono;
}
public String getInstrucciones() {
return instrucciones;
}
public void setInstrucciones(String instrucciones) {
this.instrucciones = instrucciones;
}
public boolean isDireccionFacturacion() {
return direccionFacturacion;
}
public void setDireccionFacturacion(boolean direccionFacturacion) {
this.direccionFacturacion = direccionFacturacion;
}
public String getRazonSocial() {
return razonSocial;
}
public void setRazonSocial(String razonSocial) {
this.razonSocial = razonSocial;
}
public TipoIdentificacionFiscal getTipoIdentificacionFiscal() {
return tipoIdentificacionFiscal;
}
public void setTipoIdentificacionFiscal(TipoIdentificacionFiscal tipo) {
this.tipoIdentificacionFiscal = tipo;
}
public String getIdentificacionFiscal() {
return identificacionFiscal;
}
public void setIdentificacionFiscal(String identificacionFiscal) {
this.identificacionFiscal = identificacionFiscal;
}
public Paises getPais() {
return pais;
}
public void setPais(Paises pais) {
this.pais = pais;
}
}

View File

@ -0,0 +1,317 @@
package com.imprimelibros.erp.direcciones;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.imprimelibros.erp.datatables.DataTable;
import com.imprimelibros.erp.datatables.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse;
import com.imprimelibros.erp.paises.PaisesService;
import jakarta.persistence.criteria.Predicate;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Controller
@RequestMapping("/direcciones")
public class DireccionController {
protected final DireccionRepository repo;
protected final PaisesService paisesService;
protected final MessageSource messageSource;
public DireccionController(DireccionRepository repo, PaisesService paisesService, MessageSource messageSource) {
this.repo = repo;
this.paisesService = paisesService;
this.messageSource = messageSource;
}
@GetMapping()
public String viewDirecciones(Model model, Authentication auth, Locale locale) {
boolean isUser = auth != null && auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
model.addAttribute("isUser", isUser ? 1 : 0);
return "imprimelibros/direcciones/direccion-list";
}
@GetMapping(value = "/datatable", produces = "application/json")
@ResponseBody
public DataTablesResponse<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(); // total sin filtro global
// Construcción del datatable con entity + spec
return DataTable
.of(repo, Direccion.class, dt, searchable)
.orderable(orderable)
// Columnas "crudas" (las que existen tal cual):
.edit("id", d -> d.getId())
.edit("alias", d -> d.getAlias())
.edit("att", d -> d.getAtt())
.edit("direccion", d -> d.getDireccion())
.edit("cp", d -> d.getCp())
.edit("ciudad", d -> d.getCiudad())
.edit("provincia", d -> d.getProvincia())
// Columnas calculadas:
// cliente = nombre del usuario (o username si no tienes name)
.add("cliente", d -> {
var u = d.getUser();
return (u != null && u.getFullName() != null && !u.getFullName().isBlank())
? u.getFullName()
: "";
})
// pais = nombre localizado desde MessageSource usando el keyword del país
.add("pais", d -> {
// si tienes la relación read-only a Pais (d.getPais()) con .getKeyword()
String keyword = (d.getPais() != null) ? d.getPais().getKeyword() : null;
if (keyword == null || keyword.isBlank())
return d.getPaisCode3();
return messageSource.getMessage("paises." + keyword, null, keyword, locale);
})
// Ejemplo de columna de acciones:
.add("actions", d -> """
<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("form")
public String getForm(@RequestParam(required = false) Long id,
Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
model.addAttribute("paises", paisesService.getForSelect("", "", locale).get("results"));
if (id != null) {
var opt = repo.findByIdWithPaisAndUser(id);
if (opt == null) {
binding.reject("direcciones.error.noEncontrado",
messageSource.getMessage("direcciones.error.noEncontrado", null, locale));
response.setStatus(404);
model.addAttribute("action", "/direcciones/" + id);
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
model.addAttribute("direccion", opt.get());
model.addAttribute("action", "/direcciones/" + id);
} else {
model.addAttribute("direccion", new Direccion());
model.addAttribute("action", "/direcciones");
}
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
@PostMapping
public String create(
Direccion direccion,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("action", "/direcciones/");
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
var data = direccion;
try {
repo.save(data);
} catch (jakarta.validation.ConstraintViolationException vex) {
// Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap)
vex.getConstraintViolations().forEach(v -> {
// intenta asignar al campo si existe, si no, error global
String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null;
String code = v.getMessage() != null ? v.getMessage().trim() : "";
if (code.startsWith("{") && code.endsWith("}")) {
code = code.substring(1, code.length() - 1); // -> "validation.required"
}
if (path != null && binding.getFieldError(path) == null) {
binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale));
} else {
binding.reject("validation", messageSource.getMessage(code, null, locale));
}
});
response.setStatus(422);
model.addAttribute("action", "/direcciones/");
return "imprimelibros/direcciones/direccion-form :: direccionForm";
}
response.setStatus(201);
return null;
}
/*
@PutMapping("/{id}")
public String edit(
@PathVariable Long id,
MargenPresupuesto form,
BindingResult binding,
Model model,
HttpServletResponse response,
Locale locale) {
var uOpt = repo.findById(id);
if (uOpt.isEmpty()) {
binding.reject("usuarios.error.noEncontrado",
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
}
if (binding.hasErrors()) {
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
var entity = uOpt.get();
// 3) Copiar solamente campos editables
entity.setImporteMin(form.getImporteMin());
entity.setImporteMax(form.getImporteMax());
entity.setMargenMax(form.getMargenMax());
entity.setMargenMin(form.getMargenMin());
try {
repo.saveAndFlush(entity);
} catch (jakarta.validation.ConstraintViolationException vex) {
// Errores de Bean Validation disparados al flush (incluye tu @NoRangeOverlap)
vex.getConstraintViolations().forEach(v -> {
// intenta asignar al campo si existe, si no, error global
String path = v.getPropertyPath() != null ? v.getPropertyPath().toString() : null;
String code = v.getMessage() != null ? v.getMessage().trim() : "";
if (code.startsWith("{") && code.endsWith("}")) {
code = code.substring(1, code.length() - 1); // -> "validation.required"
}
if (path != null && binding.getFieldError(path) == null) {
binding.rejectValue(path, "validation", messageSource.getMessage(code, null, locale));
} else {
binding.reject("validation", messageSource.getMessage(code, null, locale));
}
});
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
} catch (org.springframework.dao.DataIntegrityViolationException dex) {
// Uniques, FKs, checks… mensajes de la BD
String msg = dex.getMostSpecificCause() != null ? dex.getMostSpecificCause().getMessage()
: dex.getMessage();
binding.reject("db.error", messageSource.getMessage(msg, null, locale));
response.setStatus(422);
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
}
response.setStatus(204);
return null;
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
return repo.findById(id).map(u -> {
try {
u.setDeleted(true);
u.setDeletedAt(LocalDateTime.now());
repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno
return ResponseEntity.ok(Map.of("message",
messageSource.getMessage("margenes-presupuesto.exito.eliminado", null, locale)));
} catch (Exception ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("margenes-presupuesto.error.delete-internal-error", null,
locale)));
}
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message",
messageSource.getMessage("margenes-presupuesto.error.not-found", null, locale))));
}
*/
}

View File

@ -0,0 +1,77 @@
package com.imprimelibros.erp.direcciones;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface DireccionRepository
extends JpaRepository<Direccion, Long>,
JpaSpecificationExecutor<Direccion> {
@Query("""
SELECT
d.id AS id,
d.alias AS alias,
d.att AS att,
d.direccion AS direccion,
d.cp AS cp,
d.ciudad AS ciudad,
d.provincia AS provincia,
d.paisCode3 AS paisCode3,
p.keyword AS paisKeyword,
d.telefono AS telefono,
d.direccionFacturacion AS direccionFacturacion,
d.razonSocial AS razonSocial,
d.tipoIdentificacionFiscal AS tipoIdentificacionFiscal,
d.identificacionFiscal AS identificacionFiscal,
u.fullName AS cliente
FROM Direccion d
JOIN d.user u
LEFT JOIN Paises p ON d.paisCode3 = p.code3
WHERE (:userId IS NULL OR u.id = :userId)
""")
List<DireccionView> findAllWithPaisAndUser(@Param("userId") Long userId);
//findbyidwithPaisAndUser
@Query("""
SELECT
d.id AS id,
d.alias AS alias,
d.att AS att,
d.direccion AS direccion,
d.cp AS cp,
d.ciudad AS ciudad,
d.provincia AS provincia,
d.paisCode3 AS paisCode3,
p.keyword AS paisKeyword,
d.telefono AS telefono,
d.direccionFacturacion AS direccionFacturacion,
d.razonSocial AS razonSocial,
d.tipoIdentificacionFiscal AS tipoIdentificacionFiscal,
d.identificacionFiscal AS identificacionFiscal,
u.fullName AS cliente
FROM Direccion d
JOIN d.user u
LEFT JOIN Paises p ON d.paisCode3 = p.code3
WHERE (d.id = :id)
""")
Optional<DireccionView> 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,19 @@
package com.imprimelibros.erp.direcciones;
public interface DireccionView {
Long getId();
String getAlias();
String getAtt();
String getDireccion();
String getCp();
String getCiudad();
String getProvincia();
String getPaisCode3();
String getPaisKeyword();
String getTelefono();
Boolean getIsFacturacion();
String getRazonSocial();
String getTipoIdentificacionFiscal();
String getIdentificacionFiscal();
String getCliente();
}

View File

@ -1,22 +1,17 @@
package com.imprimelibros.erp.paises;
import org.springframework.context.MessageSource;
import org.springframework.web.bind.annotation.*;
import java.text.Collator;
import java.util.*;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/paises")
public class PaisesController {
private final PaisesRepository paisesRepository;
private final MessageSource messageSource;
private final PaisesService paisesService;
public PaisesController(PaisesRepository paisesRepository, MessageSource messageSource) {
this.paisesRepository = paisesRepository;
this.messageSource = messageSource;
public PaisesController(PaisesService paisesService) {
this.paisesService = paisesService;
}
/**
@ -34,40 +29,8 @@ public class PaisesController {
@RequestParam(value = "term", required = false) String q2,
Locale locale) {
// Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config)
String search = Optional.ofNullable(q1).orElse(q2);
if (search != null) {
search = search.trim();
}
final String q = (search == null || search.isEmpty())
? null
: search.toLowerCase(locale);
List<Paises> all = paisesRepository.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 text = messageSource.getMessage("paises." + key, null, key, locale);
Map<String, String> m = new HashMap<>();
m.put("id", key); // lo normal en Select2: id = valor que guardarás (keyword)
m.put("text", text); // texto mostrado, i18n con fallback a keyword
return m;
})
.filter(opt -> {
if (q == null || q.isEmpty())
return true;
String text = opt.get("text").toLowerCase(locale);
String id = opt.get("id").toLowerCase(locale);
return text.contains(q) || id.contains(q);
})
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance(locale)))
.collect(Collectors.toList());
// Estructura Select2
Map<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
return paisesService.getForSelect(q1, q2, locale);
}
}

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

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

@ -4,4 +4,6 @@ databaseChangeLog:
- include:
file: db/changelog/changesets/0002-create-pedidos.yml
- include:
file: db/changelog/changesets/0003-create-paises.yml
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,34 @@
direcciones.add=Añadir dirección
direcciones.editar=Editar dirección
direcciones.breadcrumb=Direcciones
direcciones.add=Añadir dirección
direcciones.edit=Editar dirección
direcciones.alias=Alias
direcciones.alias-descripcion=Nombre descriptivo para identificar la dirección.
direcciones.nombre=Nombre y Apellidos
direcciones.direccion=Dirección
direcciones.cp=Código Postal
direcciones.ciudad=Ciudad
direcciones.provincia=Provincia
direcciones.estado=Estado
direcciones.pais=País
direcciones.instrucciones=Instrucciones de envío
direcciones.telefono=Teléfono
direcciones.isFacturacion=Usar también como dirección de facturación
direcciones.razon_social=Razón Social
direcciones.tipo_identificacion_fiscal=Tipo de identificación fiscal
direcciones.identificacion_fiscal=Número de identificación fiscal
direcciones.tabla.id=ID
direcciones.tabla.cliente=Cliente
direcciones.tabla.acciones=Acciones
direcciones.dni=D.N.I.
direcciones.nie=N.I.E.
direcciones.pasaporte=Pasaporte
direcciones.cif=C.I.F.
direcciones.vat_id=VAT ID
direcciones.error.noEncontrado=Dirección no encontrada.

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

@ -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,196 @@
(() => {
// si jQuery está cargado, añade CSRF a AJAX
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
if (window.$ && csrfToken && csrfHeader) {
$.ajaxSetup({
beforeSend: function (xhr) {
xhr.setRequestHeader(csrfHeader, csrfToken);
}
});
}
const language = document.documentElement.lang || 'es-ES';
// Comprueba dependencias antes de iniciar
if (!window.DataTable) {
console.error('DataTables no está cargado aún');
return;
}
const table = new DataTable('#direcciones-datatable', {
processing: true,
serverSide: true,
orderCellsTop: true,
pageLength: 50,
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
responsive: true,
dom: 'lrBtip',
buttons: {
dom: {
button: {
className: 'btn btn-sm btn-outline-primary me-1'
},
buttons: [
{ extend: 'copy' },
{ extend: 'csv' },
{ extend: 'excel' },
{ extend: 'pdf' },
{ extend: 'print' },
{ extend: 'colvis' }
],
}
},
ajax: {
url: '/direcciones/datatable',
method: 'GET',
},
order: [[0, 'asc']],
columns: [
{ data: 'id', name: 'id', orderable: true, visible: $('#isUser').val() },
{ data: 'cliente', name: 'cliente', orderable: true },
{ data: 'alias', name: 'alias', orderable: true },
{ data: 'nombre', name: 'nombre', orderable: true },
{ data: 'direccion', name: 'direccion', orderable: true },
{ data: 'cp', name: 'cp', orderable: true },
{ data: 'ciudad', name: 'ciudad', orderable: true },
{ data: 'provincia', name: 'provincia', orderable: true },
{ data: 'pais', name: 'pais', orderable: true },
{ data: 'actions', name: 'actions' }
],
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
});
table.on("keyup", ".direcciones-filter", function () {
const colName = $(this).data("col");
const colIndex = table.settings()[0].aoColumns.findIndex(c => c.name === colName);
if (colIndex >= 0) {
table.column(colIndex).search(normalizeNumericFilter(this.value)).draw();
}
});
const modalEl = document.getElementById('direccionFormModal');
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
$(document).on("change", ".direccionFacturacion", function () {
const isChecked = $(this).is(':checked');
if(isChecked) {
$('.direccionFacturacionItems').removeClass('d-none');
} else {
$('.direccionFacturacionItems').addClass('d-none');
$('#razonSocial').val('');
$('#tipoIdentificacionFiscal').val('DNI');
$('#identificacionFiscal').val('');
}
});
// Abrir "Crear"
$('#addButton').on('click', (e) => {
e.preventDefault();
$.get('/direcciones/form', function (html) {
$('#direccionFormModalBody').html(html);
const title = $('#direccionFormModalBody #direccionForm').data('add');
$('#direccionFormModal .modal-title').text(title);
modal.show();
});
});
// Abrir "Editar"
$(document).on('click', '.btn-edit-direccion', function (e) {
e.preventDefault();
const id = $(this).data('id');
/*$.get('/configuracion/margenes-presupuesto/form', { id }, function (html) {
$('#margenesPresupuestoModalBody').html(html);
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data('edit');
$('#margenesPresupuestoModal .modal-title').text(title);
modal.show();*/
});
// Botón "Eliminar"
$(document).on('click', '.btn-delete-margen', function (e) {
e.preventDefault();
const id = $(this).data('id');
Swal.fire({
title: window.languageBundle.get(['direcciones.delete.title']) || 'Eliminar dirección',
html: window.languageBundle.get(['direcciones.delete.text']) || 'Esta acción no se puede deshacer.',
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-danger w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: window.languageBundle.get(['direcciones.delete.button']) || 'Eliminar',
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/configuracion/margenes-presupuesto/' + id,
type: 'DELETE',
success: function () {
Swal.fire({
icon: 'success', title: window.languageBundle.get(['margenes-presupuesto.delete.ok.title']) || 'Eliminado',
text: window.languageBundle.get(['margenes-presupuesto.delete.ok.text']) || 'El margen ha sido eliminado con éxito.',
showConfirmButton: true,
customClass: {
confirmButton: 'btn btn-secondary w-xs mt-2',
},
});
$('#margenes-datatable').DataTable().ajax.reload(null, false);
},
error: function (xhr) {
// usa el mensaje del backend; fallback genérico por si no llega JSON
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'Error al eliminar el usuario.';
Swal.fire({ icon: 'error', title: 'No se pudo eliminar', text: msg });
}
});
});
});
// Submit del form en el modal
$(document).on('submit', '#margenesPresupuestoForm', function (e) {
e.preventDefault();
const $form = $(this);
$.ajax({
url: $form.attr('action'),
type: 'POST', // PUT simulado via _method
data: $form.serialize(),
dataType: 'html',
success: function (html) {
// Si por cualquier motivo llega 200 con fragmento, lo insertamos igual
if (typeof html === 'string' && html.indexOf('id="margenesPresupuestoForm"') !== -1 && html.indexOf('<html') === -1) {
$('#margenesPresupuestoModalBody').html(html);
const isEdit = $('#margenesPresupuestoModalBody #margenesPresupuestoForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add');
$('#margenesPresupuestoModal .modal-title').text(title);
return;
}
// Éxito real: cerrar y recargar tabla
modal.hide();
table.ajax.reload(null, false);
},
error: function (xhr) {
// Con 422 devolvemos el fragmento con errores aquí
if (xhr.status === 422 && xhr.responseText) {
$('#margenesPresupuestoModalBody').html(xhr.responseText);
const isEdit = $('#margenesPresupuestoModalBody #margenesPresupuestoForm input[name="_method"][value="PUT"]').length > 0;
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data(isEdit ? 'edit' : 'add');
$('#margenesPresupuestoModal .modal-title').text(title);
return;
}
// Fallback
$('#margenesPresupuestoModalBody').html('<div class="p-3 text-danger">Error inesperado.</div>');
}
});
});
})();

View File

@ -0,0 +1,150 @@
<div th:fragment="direccionForm">
<form id="direccionForm" novalidate th:action="${action}" th:object="${direccion}" method="post"
th:data-add="#{direcciones.add}" th:data-edit="#{direcciones.editar}">
<div class="form-group">
<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>
<div class="invalid-feedback"></div>
<label th:text="#{direcciones.alias-descripcion}" class="form-text text-muted"></label>
</div>
<div class="form-group">
<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>
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<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;"></textarea>
<div class="invalid-feedback"></div>
</div>
<div class="row">
<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>
<div class="invalid-feedback"></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>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="row">
<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>
<div class="invalid-feedback"></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="pais" th:field="*{paisCode3}">
<option th:each="pais : ${paises}" th:value="${pais.id}" th:text="${pais.text}"
th:selected="${pais.id} == ${direccion.paisCode3}">
</option>
</select>
<div class="invalid-feedback"></div>
</div>
</div>
<div class="form-group">
<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">
<div class="invalid-feedback"></div>
</div>
<div class="form-group">
<label for="instrucciones">
<span th:text="#{direcciones.instrucciones}">Instrucciones</span>
</label>
<textarea class="form-control direccion-item" id="instrucciones" th:field="*{instrucciones}" maxlength="255"
required style="max-height: 125px;"></textarea>
<div class="invalid-feedback"></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" name="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="razon_social">
<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">
<div class="invalid-feedback"></div>
</div>
<div
th:class="'row 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}">
<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"></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">
<div class="invalid-feedback"></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.add}"></button>
</div>
</form>
</div>

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.nombre}">Nombre y Apellidos</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">