mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-30 07:38:51 +00:00
Compare commits
17 Commits
2a40539496
...
892c473266
| Author | SHA1 | Date | |
|---|---|---|---|
| 892c473266 | |||
| dbc2038f9f | |||
| b66ceee85c | |||
| d9c4f16cf0 | |||
| cc49732531 | |||
| 5da73a3679 | |||
| 1e24065fb7 | |||
| 460d2cfc01 | |||
| add4e43955 | |||
| 656bb5bad2 | |||
| 865b1573b9 | |||
| 22198b4f25 | |||
| 50599cf33e | |||
| 847249d2de | |||
| 88b43847f0 | |||
| 01a1ac4b71 | |||
| 062a20c26a |
24
pom.xml
24
pom.xml
@ -47,6 +47,10 @@
|
||||
<groupId>nz.net.ultraq.thymeleaf</groupId>
|
||||
<artifactId>thymeleaf-layout-dialect</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.thymeleaf.extras</groupId>
|
||||
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
@ -85,7 +89,7 @@
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
@ -98,6 +102,24 @@
|
||||
<version>1.17.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Escape seguro al renderizar -->
|
||||
<dependency>
|
||||
<groupId>org.owasp.encoder</groupId>
|
||||
<artifactId>encoder</artifactId>
|
||||
<version>1.3.1</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<!-- Rate limiting -->
|
||||
<dependency>
|
||||
<groupId>com.bucket4j</groupId>
|
||||
<artifactId>bucket4j-core</artifactId>
|
||||
<version>8.10.1</version>
|
||||
</dependency>
|
||||
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
package com.imprimelibros.erp.auth;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/auth/password")
|
||||
@Validated
|
||||
public class PasswordResetController {
|
||||
|
||||
private final PasswordResetService service;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public PasswordResetController(PasswordResetService service, MessageSource messageSource) {
|
||||
this.service = service;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
// 4.1 Página "¿Olvidaste tu contraseña?"
|
||||
@GetMapping("/forgot")
|
||||
public String forgotForm(Model model, Locale locale) {
|
||||
model.addAttribute("form", "_forgot-password");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
|
||||
// 4.2 Envío del email (si existe)
|
||||
@PostMapping("/forgot")
|
||||
public String handleForgot(
|
||||
@RequestParam @NotBlank @Email String username,
|
||||
HttpServletRequest request,
|
||||
Model model,
|
||||
RedirectAttributes ra,
|
||||
Locale locale) {
|
||||
String baseUrl = request.getScheme() + "://" + request.getServerName()
|
||||
+ (request.getServerPort() == 80 || request.getServerPort() == 443 ? ""
|
||||
: ":" + request.getServerPort());
|
||||
String ip = request.getRemoteAddr();
|
||||
String ua = request.getHeader("User-Agent");
|
||||
service.requestReset(username, baseUrl, ip, ua, 60, locale);
|
||||
|
||||
ra.addFlashAttribute("info", messageSource.getMessage("login.password-recovery.email-info", null, locale));
|
||||
return "redirect:/login";
|
||||
}
|
||||
|
||||
// 4.3 Formulario de nueva contraseña (a partir del enlace)
|
||||
@GetMapping("/reset")
|
||||
public String resetForm(@RequestParam("uid") Long uid,
|
||||
@RequestParam("token") String token,
|
||||
Model model, Locale locale) {
|
||||
boolean ok = service.isValid(uid, token);
|
||||
model.addAttribute("uid", uid);
|
||||
model.addAttribute("token", token);
|
||||
model.addAttribute("valid", ok);
|
||||
|
||||
model.addAttribute("form", "_reset-password");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
|
||||
// 4.4 Confirmación del reseteo
|
||||
@PostMapping("/reset")
|
||||
public String doReset(@RequestParam("uid") Long uid,
|
||||
@RequestParam("token") String token,
|
||||
@RequestParam("password") String password,
|
||||
@RequestParam("password2") String password2,
|
||||
Model model, Locale locale) {
|
||||
if (!password.equals(password2) || password.length() < 8) {
|
||||
model.addAttribute("uid", uid);
|
||||
model.addAttribute("token", token);
|
||||
model.addAttribute("danger", messageSource.getMessage("login.password-reset.error", null, locale));
|
||||
model.addAttribute("form", "_reset-password");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
if (service.resetPassword(uid, token, password)) {
|
||||
model.addAttribute("info", messageSource.getMessage("login.password-reset.success", null, locale));
|
||||
} else {
|
||||
model.addAttribute("danger", messageSource.getMessage("login.password-reset.error-link", null, locale));
|
||||
}
|
||||
model.addAttribute("form", "_login");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
package com.imprimelibros.erp.auth;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
|
||||
import com.imprimelibros.erp.common.email.EmailService;
|
||||
import com.imprimelibros.erp.users.User;
|
||||
import com.imprimelibros.erp.users.UserDao; // ajusta al path real de tu UserRepository
|
||||
|
||||
@Service
|
||||
public class PasswordResetService {
|
||||
|
||||
private final PasswordResetTokenRepository tokenRepo;
|
||||
private final UserDao userRepo;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JavaMailSender mailSender;
|
||||
private final SpringTemplateEngine templateEngine;
|
||||
private final MessageSource messages;
|
||||
private final EmailService emailService;
|
||||
|
||||
public PasswordResetService(
|
||||
PasswordResetTokenRepository tokenRepo,
|
||||
UserDao userRepo,
|
||||
PasswordEncoder passwordEncoder,
|
||||
JavaMailSender mailSender,
|
||||
SpringTemplateEngine templateEngine,
|
||||
MessageSource messages,
|
||||
EmailService emailService
|
||||
) {
|
||||
this.tokenRepo = tokenRepo;
|
||||
this.userRepo = userRepo;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.mailSender = mailSender;
|
||||
this.templateEngine = templateEngine;
|
||||
this.messages = messages;
|
||||
this.emailService = emailService;
|
||||
}
|
||||
|
||||
// 3.1 Solicitar reset (si el email existe, genera token y envía)
|
||||
@Transactional
|
||||
public void requestReset(String email, String baseUrl, String ip, String userAgent, int minutes, Locale locale) {
|
||||
|
||||
User user = userRepo.findByUserNameIgnoreCase(email).orElse(null);
|
||||
// Siempre responder OK aunque no exista para evitar enumeración
|
||||
if (user == null) return;
|
||||
|
||||
tokenRepo.invalidateActiveTokens(user.getId(), LocalDateTime.now());
|
||||
|
||||
String token = generateToken(); // token en claro SOLO para el enlace
|
||||
String tokenHash = sha256(token); // guardamos hash en DB
|
||||
|
||||
PasswordResetToken row = new PasswordResetToken();
|
||||
row.setUserId(user.getId());
|
||||
row.setCreatedAt(LocalDateTime.now());
|
||||
row.setExpiresAt(LocalDateTime.now().plusMinutes(minutes));
|
||||
row.setRequestIp(ip);
|
||||
row.setUserAgent(userAgent);
|
||||
row.setTokenHash(tokenHash);
|
||||
tokenRepo.save(row);
|
||||
|
||||
String resetUrl = baseUrl + "/auth/password/reset?uid=" + user.getId() + "&token=" + token;
|
||||
|
||||
emailService.sendPasswordResetMail(user.getUserName(), user.getFullName(), resetUrl, locale);
|
||||
}
|
||||
|
||||
// 3.2 Validar token (para mostrar el formulario de nueva contraseña)
|
||||
public boolean isValid(Long userId, String token) {
|
||||
String hash = sha256(token);
|
||||
return tokenRepo.findValidByUserAndHash(userId, hash, LocalDateTime.now()).isPresent();
|
||||
}
|
||||
|
||||
// 3.3 Confirmar reseteo y marcar token como usado
|
||||
@Transactional
|
||||
public boolean resetPassword(Long userId, String token, String newPassword) {
|
||||
String hash = sha256(token);
|
||||
var opt = tokenRepo.findValidByUserAndHash(userId, hash, LocalDateTime.now());
|
||||
if (opt.isEmpty()) return false;
|
||||
|
||||
var entry = opt.get();
|
||||
var user = userRepo.findById(userId).orElse(null);
|
||||
if (user == null) return false;
|
||||
|
||||
user.setPassword(passwordEncoder.encode(newPassword));
|
||||
userRepo.save(user);
|
||||
|
||||
entry.setUsedAt(LocalDateTime.now());
|
||||
tokenRepo.save(entry);
|
||||
|
||||
// (Opcional) invalidar otros tokens activos del usuario
|
||||
tokenRepo.invalidateActiveTokens(userId, LocalDateTime.now());
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
private String generateToken() {
|
||||
byte[] buf = new byte[32];
|
||||
new SecureRandom().nextBytes(buf);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
|
||||
}
|
||||
private String sha256(String s) {
|
||||
try {
|
||||
var md = MessageDigest.getInstance("SHA-256");
|
||||
return Base64.getEncoder().encodeToString(md.digest(s.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package com.imprimelibros.erp.auth;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "password_reset_tokens")
|
||||
public class PasswordResetToken {
|
||||
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "created_at", nullable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at", nullable = false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
@Column(name = "request_ip", length = 64)
|
||||
private String requestIp;
|
||||
|
||||
@Column(name = "token_hash", length = 128, nullable = false)
|
||||
private String tokenHash;
|
||||
|
||||
@Column(name = "used_at")
|
||||
private LocalDateTime usedAt;
|
||||
|
||||
@Column(name = "user_agent", length = 255)
|
||||
private String userAgent;
|
||||
|
||||
@Column(name = "user_id", nullable = false)
|
||||
private Long userId;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getExpiresAt() {
|
||||
return expiresAt;
|
||||
}
|
||||
|
||||
public void setExpiresAt(LocalDateTime expiresAt) {
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public String getRequestIp() {
|
||||
return requestIp;
|
||||
}
|
||||
|
||||
public void setRequestIp(String requestIp) {
|
||||
this.requestIp = requestIp;
|
||||
}
|
||||
|
||||
public String getTokenHash() {
|
||||
return tokenHash;
|
||||
}
|
||||
|
||||
public void setTokenHash(String tokenHash) {
|
||||
this.tokenHash = tokenHash;
|
||||
}
|
||||
|
||||
public LocalDateTime getUsedAt() {
|
||||
return usedAt;
|
||||
}
|
||||
|
||||
public void setUsedAt(LocalDateTime usedAt) {
|
||||
this.usedAt = usedAt;
|
||||
}
|
||||
|
||||
public String getUserAgent() {
|
||||
return userAgent;
|
||||
}
|
||||
|
||||
public void setUserAgent(String userAgent) {
|
||||
this.userAgent = userAgent;
|
||||
}
|
||||
|
||||
public Long getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public void setUserId(Long userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.imprimelibros.erp.auth;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.*;
|
||||
import java.util.List;
|
||||
|
||||
public interface PasswordResetTokenRepository extends JpaRepository<PasswordResetToken, Long> {
|
||||
|
||||
// Para validar un token en el reset
|
||||
@Query("""
|
||||
SELECT t FROM PasswordResetToken t
|
||||
WHERE t.userId = :userId
|
||||
AND t.tokenHash = :tokenHash
|
||||
AND t.usedAt IS NULL
|
||||
AND t.expiresAt > :now
|
||||
""")
|
||||
Optional<PasswordResetToken> findValidByUserAndHash(Long userId, String tokenHash, LocalDateTime now);
|
||||
|
||||
// Invalida cualquier token activo del usuario
|
||||
@Modifying
|
||||
@Query("""
|
||||
UPDATE PasswordResetToken t
|
||||
SET t.usedAt = :now
|
||||
WHERE t.userId = :userId
|
||||
AND t.usedAt IS NULL
|
||||
AND t.expiresAt > :now
|
||||
""")
|
||||
int invalidateActiveTokens(Long userId, LocalDateTime now);
|
||||
|
||||
// (Opcional) lista de activos, por si quieres inspeccionarlos
|
||||
@Query("""
|
||||
SELECT t FROM PasswordResetToken t
|
||||
WHERE t.userId = :userId
|
||||
AND t.usedAt IS NULL
|
||||
AND t.expiresAt > :now
|
||||
ORDER BY t.createdAt DESC
|
||||
""")
|
||||
List<PasswordResetToken> findAllActiveForUser(Long userId, LocalDateTime now);
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package com.imprimelibros.erp.common;
|
||||
|
||||
import io.github.bucket4j.Bandwidth;
|
||||
import io.github.bucket4j.Bucket;
|
||||
import io.github.bucket4j.Refill;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Service
|
||||
public class RateLimiterService {
|
||||
|
||||
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
// 5 solicitudes cada 15 minutos por clave (IP)
|
||||
private Bucket newBucket() {
|
||||
Bandwidth limit = Bandwidth.classic(5, Refill.greedy(5, Duration.ofMinutes(15)));
|
||||
return Bucket.builder().addLimit(limit).build();
|
||||
}
|
||||
|
||||
public boolean tryConsume(String key) {
|
||||
return buckets.computeIfAbsent(key, k -> newBucket()).tryConsume(1);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.imprimelibros.erp.common.email;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import jakarta.mail.internet.MimeMessage;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.thymeleaf.TemplateEngine;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
public class EmailService {
|
||||
|
||||
private final JavaMailSender mailSender;
|
||||
private final TemplateEngine templateEngine;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public EmailService(JavaMailSender mailSender, TemplateEngine templateEngine, MessageSource messageSource) {
|
||||
this.mailSender = mailSender;
|
||||
this.templateEngine = templateEngine;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
public void sendPasswordResetMail(String to, String fullName, String resetUrl, Locale locale) {
|
||||
String subject = messageSource.getMessage("email.reset-password.title", null, locale);
|
||||
Map<String, Object> variables = Map.of(
|
||||
"fullName", fullName,
|
||||
"resetUrl", resetUrl,
|
||||
"minutes", 60);
|
||||
sendEmail(to, subject, "imprimelibros/email/password-reset", variables, locale);
|
||||
}
|
||||
|
||||
public void sendVerificationEmail(String to, String fullName, String verifyUrl, Locale locale) {
|
||||
String subject = messageSource.getMessage("email.verify.title", null, locale);
|
||||
Map<String, Object> variables = Map.of(
|
||||
"fullName", fullName,
|
||||
"verifyUrl", verifyUrl,
|
||||
"minutes", 60);
|
||||
sendEmail(to, subject, "imprimelibros/email/verify", variables, locale);
|
||||
}
|
||||
|
||||
// ->>>>>>>> PRIVATE METHODS <<<<<<<<<<<-
|
||||
|
||||
/******************
|
||||
* Envía un email usando una plantilla Thymeleaf.
|
||||
*
|
||||
* @param to
|
||||
* @param subject
|
||||
* @param template
|
||||
* @param variables
|
||||
**********************************************/
|
||||
private void sendEmail(String to, String subject, String template, Map<String, Object> variables, Locale locale) {
|
||||
try {
|
||||
Context ctx = new Context(locale);
|
||||
ctx.setVariables(variables);
|
||||
ctx.setVariable("subject", subject);
|
||||
ctx.setVariable("companyName", "ImprimeLibros");
|
||||
ctx.setVariable("year", java.time.Year.now().getValue());
|
||||
|
||||
// Incrusta el CSS (no uses <link> en emails)
|
||||
var cssRes = new ClassPathResource("static/assets/css/email.css");
|
||||
String emailCss = org.springframework.util.StreamUtils.copyToString(cssRes.getInputStream(),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
ctx.setVariable("emailCss", emailCss);
|
||||
|
||||
ctx.setVariable("template", template);
|
||||
String html = templateEngine.process("imprimelibros/email/layout", ctx);
|
||||
|
||||
MimeMessage msg = mailSender.createMimeMessage();
|
||||
|
||||
MimeMessageHelper h = new MimeMessageHelper(
|
||||
msg,
|
||||
MimeMessageHelper.MULTIPART_MODE_MIXED_RELATED,
|
||||
"UTF-8");
|
||||
|
||||
h.setFrom("no-reply@imprimelibros.com");
|
||||
h.setTo(to);
|
||||
h.setSubject(subject);
|
||||
|
||||
h.setText(html, true);
|
||||
|
||||
// 3) ahora el inline, con content-type explícito
|
||||
ClassPathResource logoRes = new ClassPathResource("static/assets/images/logo-light.png");
|
||||
h.addInline("logo", logoRes, "image/png");
|
||||
|
||||
mailSender.send(msg);
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Error enviando email", e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package com.imprimelibros.erp.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
|
||||
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
|
||||
|
||||
import jakarta.validation.ValidatorFactory;
|
||||
|
||||
@Configuration
|
||||
public class BeanValidationConfig {
|
||||
|
||||
// Asegura que usamos la factory de Spring (con SpringConstraintValidatorFactory)
|
||||
@Bean
|
||||
public LocalValidatorFactoryBean validator() {
|
||||
return new LocalValidatorFactoryBean();
|
||||
}
|
||||
|
||||
// Inserta esa factory en Hibernate/JPA
|
||||
@Bean
|
||||
public HibernatePropertiesCustomizer hibernateValidationCustomizer(ValidatorFactory vf) {
|
||||
return props -> props.put("jakarta.persistence.validation.factory", vf);
|
||||
}
|
||||
}
|
||||
@ -22,7 +22,7 @@ public class InternationalizationConfig implements WebMvcConfigurer {
|
||||
@Bean
|
||||
public LocaleResolver localeResolver() {
|
||||
SessionLocaleResolver slr = new SessionLocaleResolver();
|
||||
slr.setDefaultLocale(Locale.forLanguageTag("es")); // idioma por defecto
|
||||
slr.setDefaultLocale(Locale.forLanguageTag("es-ES")); // idioma por defecto
|
||||
return slr;
|
||||
}
|
||||
|
||||
|
||||
25
src/main/java/com/imprimelibros/erp/config/Sanitizer.java
Normal file
25
src/main/java/com/imprimelibros/erp/config/Sanitizer.java
Normal file
@ -0,0 +1,25 @@
|
||||
package com.imprimelibros.erp.config;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.safety.Safelist;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class Sanitizer {
|
||||
|
||||
// Sin HTML: todo a texto plano
|
||||
public String plain(String input) {
|
||||
if (input == null) return null;
|
||||
String cleaned = Jsoup.clean(input, Safelist.none());
|
||||
return cleaned.strip();
|
||||
}
|
||||
|
||||
// HTML mínimo permitido (opcional)
|
||||
public String minimalHtml(String input) {
|
||||
if (input == null) return null;
|
||||
Safelist wl = Safelist.basic(); // b, i, em, strong, a...
|
||||
wl.addTags("ul","ol","li"); // añade lo que necesites
|
||||
wl.addAttributes("a","rel","nofollow"); // endurece enlaces
|
||||
return Jsoup.clean(input, wl);
|
||||
}
|
||||
}
|
||||
@ -1,35 +1,167 @@
|
||||
package com.imprimelibros.erp.config;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl;
|
||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
|
||||
import org.springframework.security.web.util.matcher.AndRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
|
||||
import com.imprimelibros.erp.users.UserServiceImpl;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public SecurityConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
// ========== Beans base ==========
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
// Remember-me (tabla persistent_logins)
|
||||
@Bean
|
||||
public PersistentTokenRepository persistentTokenRepository() {
|
||||
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
|
||||
repo.setDataSource(dataSource);
|
||||
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
|
||||
// tabla
|
||||
return repo;
|
||||
}
|
||||
|
||||
// Provider que soporta UsernamePasswordAuthenticationToken
|
||||
private static RequestMatcher pathStartsWith(String... prefixes) {
|
||||
return new RequestMatcher() {
|
||||
@Override
|
||||
public boolean matches(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null)
|
||||
return false;
|
||||
for (String p : prefixes) {
|
||||
if (uri.startsWith(p))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
@Value("${security.rememberme.key}") String keyRememberMe,
|
||||
UserDetailsService userDetailsService,
|
||||
PersistentTokenRepository tokenRepo,
|
||||
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
|
||||
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
http.authenticationProvider(provider);
|
||||
http
|
||||
.authenticationProvider(provider)
|
||||
|
||||
.sessionManagement(session -> session
|
||||
.invalidSessionUrl("/login?expired")
|
||||
.maximumSessions(1))
|
||||
|
||||
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/")))
|
||||
|
||||
// ====== RequestCache: sólo navegaciones HTML reales ======
|
||||
.requestCache(rc -> {
|
||||
HttpSessionRequestCache cache = new HttpSessionRequestCache();
|
||||
|
||||
// Navegación HTML (por tipo o por cabecera Accept)
|
||||
RequestMatcher htmlPage = new OrRequestMatcher(
|
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
|
||||
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
|
||||
new RequestHeaderRequestMatcher("Accept", "text/html"));
|
||||
|
||||
// No AJAX
|
||||
RequestMatcher nonAjax = new NegatedRequestMatcher(
|
||||
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
|
||||
|
||||
// Excluir sondas .well-known
|
||||
RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/"));
|
||||
|
||||
// Excluir estáticos: comunes + tu /assets/**
|
||||
RequestMatcher notStatic = new AndRequestMatcher(
|
||||
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
|
||||
new NegatedRequestMatcher(pathStartsWith("/assets/")));
|
||||
|
||||
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown));
|
||||
rc.requestCache(cache);
|
||||
})
|
||||
// ========================================================
|
||||
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Aquí usa patrones String (no deprecados)
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/login",
|
||||
"/signup",
|
||||
"/verify",
|
||||
"/auth/password/**",
|
||||
"/assets/**",
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/public/**",
|
||||
"/error",
|
||||
"/presupuesto/public/**",
|
||||
"/favicon.ico")
|
||||
.permitAll()
|
||||
"/error",
|
||||
"/favicon.ico",
|
||||
"/.well-known/**" // opcional
|
||||
).permitAll()
|
||||
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers("/presupuesto/public/**"))
|
||||
|
||||
.formLogin(login -> login
|
||||
// .loginPage("/login") añadir cuando se tenga login personalizado
|
||||
.permitAll())
|
||||
.logout(logout -> logout.permitAll());
|
||||
.loginPage("/login").permitAll()
|
||||
.loginProcessingUrl("/login")
|
||||
.usernameParameter("username")
|
||||
.passwordParameter("password")
|
||||
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
|
||||
.failureUrl("/login?error"))
|
||||
|
||||
.rememberMe(rm -> rm
|
||||
.key(keyRememberMe)
|
||||
.rememberMeParameter("remember-me")
|
||||
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
|
||||
.tokenValiditySeconds(60 * 60 * 24 * 2)
|
||||
.userDetailsService(userDetailsService)
|
||||
.tokenRepository(tokenRepo))
|
||||
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/")
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
|
||||
.permitAll());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
|
||||
@ -0,0 +1,181 @@
|
||||
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.SQLRestriction;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
|
||||
import com.imprimelibros.erp.shared.validation.NoRangeOverlap;
|
||||
|
||||
|
||||
@NoRangeOverlap(
|
||||
min = "tiradaMin",
|
||||
max = "tiradaMax",
|
||||
id = "id",
|
||||
partitionBy = {"tipoEncuadernacion","tipoCubierta"},
|
||||
deletedFlag = "deleted", // <- si usas soft delete
|
||||
deletedActiveValue = false, // activo cuando deleted == false
|
||||
message = "{validation.range.overlaps}",
|
||||
invalidRangeMessage = "{validation.range.invalid}"
|
||||
)
|
||||
@Entity
|
||||
@Table(name = "margenes_presupuesto")
|
||||
@SQLDelete(sql = "UPDATE margenes_presupuesto SET deleted = TRUE, deleted_at = NOW() WHERE id = ?")
|
||||
@SQLRestriction("deleted = false")
|
||||
public class MargenPresupuesto {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name="tipo_encuadernacion", nullable = false, length = 50)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private TipoEncuadernacion tipoEncuadernacion;
|
||||
|
||||
@Column(name="tipo_cubierta", nullable = false, length = 50)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Enumerated(EnumType.STRING)
|
||||
private TipoCubierta tipoCubierta;
|
||||
|
||||
@Column(name="tirada_min", nullable = false)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Min(value=1, message="{validation.min}")
|
||||
private Integer tiradaMin;
|
||||
|
||||
@Column(name="tirada_max", nullable = false)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Min(value=1, message="{validation.min}")
|
||||
private Integer tiradaMax;
|
||||
|
||||
@Column(name="margen_max", nullable = false)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Min(value = 0, message="{validation.min}")
|
||||
@Max(value = 200, message="{validation.max}")
|
||||
private Integer margenMax;
|
||||
|
||||
@Column(name = "margen_min", nullable = false)
|
||||
@NotNull(message="{validation.required}")
|
||||
@Min(value = 0, message="{validation.min}")
|
||||
@Max(value = 200, message="{validation.max}")
|
||||
private Integer margenMin;
|
||||
|
||||
@Column(name="created_at", nullable = false, updatable = false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name="updated_at")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean deleted = false;
|
||||
|
||||
@Column(name="deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public TipoEncuadernacion getTipoEncuadernacion() {
|
||||
return tipoEncuadernacion;
|
||||
}
|
||||
|
||||
public void setTipoEncuadernacion(TipoEncuadernacion tipoEncuadernacion) {
|
||||
this.tipoEncuadernacion = tipoEncuadernacion;
|
||||
}
|
||||
|
||||
public TipoCubierta getTipoCubierta() {
|
||||
return tipoCubierta;
|
||||
}
|
||||
|
||||
public void setTipoCubierta(TipoCubierta tipoCubierta) {
|
||||
this.tipoCubierta = tipoCubierta;
|
||||
}
|
||||
|
||||
public Integer getTiradaMin() {
|
||||
return tiradaMin;
|
||||
}
|
||||
|
||||
public void setTiradaMin(Integer tiradaMin) {
|
||||
this.tiradaMin = tiradaMin;
|
||||
}
|
||||
|
||||
public Integer getTiradaMax() {
|
||||
return tiradaMax;
|
||||
}
|
||||
|
||||
public void setTiradaMax(Integer tiradaMax) {
|
||||
this.tiradaMax = tiradaMax;
|
||||
}
|
||||
|
||||
public Integer getMargenMax() {
|
||||
return margenMax;
|
||||
}
|
||||
|
||||
public void setMargenMax(Integer margenMax) {
|
||||
this.margenMax = margenMax;
|
||||
}
|
||||
|
||||
public Integer getMargenMin() {
|
||||
return margenMin;
|
||||
}
|
||||
|
||||
public void setMargenMin(Integer margenMin) {
|
||||
this.margenMin = margenMin;
|
||||
}
|
||||
|
||||
public LocalDateTime getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(LocalDateTime createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public LocalDateTime getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) {
|
||||
this.updatedAt = updatedAt;
|
||||
}
|
||||
|
||||
public boolean isDeleted() {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public void setDeleted(boolean deleted) {
|
||||
this.deleted = deleted;
|
||||
}
|
||||
|
||||
public LocalDateTime getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(LocalDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
|
||||
@PrePersist
|
||||
void onCreate() {
|
||||
this.createdAt = LocalDateTime.now();
|
||||
this.updatedAt = this.createdAt;
|
||||
}
|
||||
|
||||
@PreUpdate
|
||||
void onUpdate() {
|
||||
this.updatedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,340 @@
|
||||
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
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.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
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.PutMapping;
|
||||
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.presupuesto.Presupuesto.TipoCubierta;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/configuracion/margenes-presupuesto")
|
||||
@PreAuthorize("hasRole('SUPERADMIN')")
|
||||
public class MargenPresupuestoController {
|
||||
|
||||
private final MargenPresupuestoDao repo;
|
||||
private final TranslationService translationService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public MargenPresupuestoController(MargenPresupuestoDao repo, TranslationService translationService,
|
||||
MessageSource messageSource) {
|
||||
this.repo = repo;
|
||||
this.translationService = translationService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@GetMapping()
|
||||
public String listView(Model model, Authentication authentication, Locale locale) {
|
||||
|
||||
List<String> keys = List.of(
|
||||
"margenes-presupuesto.delete.title",
|
||||
"margenes-presupuesto.delete.text",
|
||||
"margenes-presupuesto.eliminar",
|
||||
"margenes-presupuesto.delete.button",
|
||||
"app.yes",
|
||||
"app.cancelar",
|
||||
"margenes-presupuesto.delete.ok.title",
|
||||
"margenes-presupuesto.delete.ok.text");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
|
||||
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-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);
|
||||
|
||||
List<String> searchable = List.of(
|
||||
"id",
|
||||
"tiradaMin", "tiradaMax",
|
||||
"margenMin", "margenMax");
|
||||
|
||||
List<String> orderable = List.of(
|
||||
"id",
|
||||
"tipoEncuadernacion",
|
||||
"tipoCubierta",
|
||||
"tiradaMin",
|
||||
"tiradaMax",
|
||||
"margenMin",
|
||||
"margenMax");
|
||||
|
||||
Specification<MargenPresupuesto> base = (root, query, cb) -> cb.conjunction();
|
||||
long total = repo.count();
|
||||
|
||||
return DataTable
|
||||
.of(repo, MargenPresupuesto.class, dt, searchable) // 'searchable' en DataTable.java
|
||||
// edita columnas "reales":
|
||||
.orderable(orderable)
|
||||
.add("actions", (margen) -> {
|
||||
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
|
||||
" <a href=\"javascript:void(0);\" data-id=\"" + margen.getId()
|
||||
+ "\" class=\"link-success btn-edit-margen fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
|
||||
+ " <a href=\"javascript:void(0);\" data-id=\"" + margen.getId()
|
||||
+ "\" class=\"link-danger btn-delete-margen fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n"
|
||||
+ " </div>";
|
||||
})
|
||||
.edit("tipoEncuadernacion", (margen) -> {
|
||||
return messageSource.getMessage("presupuesto." + margen.getTipoEncuadernacion().name(), null,
|
||||
locale);
|
||||
})
|
||||
.edit("tipoCubierta", (margen) -> {
|
||||
return messageSource.getMessage("presupuesto." + margen.getTipoCubierta().name(), null, locale);
|
||||
})
|
||||
.where(base)
|
||||
// Filtros custom:
|
||||
.filter((builder, req) -> {
|
||||
String fEncuadernacion = Optional.ofNullable(req.raw.get("f_encuadernacion")).orElse("").trim();
|
||||
if (!fEncuadernacion.isEmpty()) {
|
||||
boolean added = false;
|
||||
// 1) Si llega el nombre del enum (p.ej. "fresado", "cosido", ...)
|
||||
try {
|
||||
var encEnum = TipoEncuadernacion.valueOf(fEncuadernacion);
|
||||
builder.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum));
|
||||
added = true;
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
// 2) Si llega la clave i18n (p.ej. "presupuesto.fresado", ...)
|
||||
if (!added) {
|
||||
Arrays.stream(TipoEncuadernacion.values())
|
||||
.filter(e -> e.getMessageKey().equals(fEncuadernacion))
|
||||
.findFirst()
|
||||
.ifPresent(encEnum -> builder
|
||||
.add((root, q, cb) -> cb.equal(root.get("tipoEncuadernacion"), encEnum)));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cubierta ---
|
||||
String fCubierta = Optional.ofNullable(req.raw.get("f_cubierta")).orElse("").trim();
|
||||
if (!fCubierta.isEmpty()) {
|
||||
boolean added = false;
|
||||
// 1) Si llega el nombre del enum (p.ej. "tapaBlanda", "tapaDura",
|
||||
// "tapaDuraLomoRedondo")
|
||||
try {
|
||||
var cubEnum = TipoCubierta.valueOf(fCubierta);
|
||||
builder.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum));
|
||||
added = true;
|
||||
} catch (IllegalArgumentException ignored) {
|
||||
}
|
||||
// 2) Si llega la clave i18n (p.ej. "presupuesto.tapa-blanda", ...)
|
||||
if (!added) {
|
||||
Arrays.stream(TipoCubierta.values())
|
||||
.filter(e -> e.getMessageKey().equals(fCubierta))
|
||||
.findFirst()
|
||||
.ifPresent(cubEnum -> builder
|
||||
.add((root, q, cb) -> cb.equal(root.get("tipoCubierta"), cubEnum)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.toJson(total);
|
||||
}
|
||||
|
||||
@GetMapping("form")
|
||||
public String getForm(@RequestParam(required = false) Long id,
|
||||
MargenPresupuesto margenPresupuesto,
|
||||
BindingResult binding,
|
||||
Model model,
|
||||
HttpServletResponse response,
|
||||
Locale locale) {
|
||||
|
||||
if (id != null) {
|
||||
var opt = repo.findById(id);
|
||||
if (opt.isEmpty()) {
|
||||
binding.reject("usuarios.error.noEncontrado",
|
||||
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
|
||||
response.setStatus(404);
|
||||
model.addAttribute("action", "/users/" + id);
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
model.addAttribute("margenPresupuesto", opt.get());
|
||||
model.addAttribute("action", "/configuracion/margenes-presupuesto/" + id);
|
||||
} else {
|
||||
// Crear: valores por defecto
|
||||
model.addAttribute("margenPresupuesto", new MargenPresupuesto());
|
||||
model.addAttribute("action", "/configuracion/margenes-presupuesto");
|
||||
}
|
||||
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String create(
|
||||
MargenPresupuesto margenPresupuesto,
|
||||
BindingResult binding,
|
||||
Model model,
|
||||
HttpServletResponse response,
|
||||
Locale locale) {
|
||||
|
||||
if (binding.hasErrors()) {
|
||||
response.setStatus(422);
|
||||
model.addAttribute("action", "/configuracion/margenes-presupuesto");
|
||||
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
|
||||
}
|
||||
|
||||
MargenPresupuesto data = new MargenPresupuesto();
|
||||
data.setTipoEncuadernacion(margenPresupuesto.getTipoEncuadernacion());
|
||||
data.setTipoCubierta(margenPresupuesto.getTipoCubierta());
|
||||
data.setTiradaMin(margenPresupuesto.getTiradaMin());
|
||||
data.setTiradaMax(margenPresupuesto.getTiradaMax());
|
||||
data.setMargenMax(margenPresupuesto.getMargenMax());
|
||||
data.setMargenMin(margenPresupuesto.getMargenMin());
|
||||
|
||||
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", "/configuracion/margenes-presupuesto");
|
||||
return "imprimelibros/configuracion/margenes-presupuesto/margenes-presupuesto-form :: margenesPresupuestoForm";
|
||||
}
|
||||
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.setTipoEncuadernacion(form.getTipoEncuadernacion());
|
||||
entity.setTipoCubierta(form.getTipoCubierta());
|
||||
entity.setTiradaMin(form.getTiradaMin());
|
||||
entity.setTiradaMax(form.getTiradaMax());
|
||||
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))));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
|
||||
|
||||
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 com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
|
||||
|
||||
public interface MargenPresupuestoDao
|
||||
extends JpaRepository<MargenPresupuesto, Long>, JpaSpecificationExecutor<MargenPresupuesto> {
|
||||
|
||||
@Query("""
|
||||
SELECT COUNT(m) FROM MargenPresupuesto m
|
||||
WHERE m.deleted = false
|
||||
AND m.tipoEncuadernacion = :enc
|
||||
AND m.tipoCubierta = :cub
|
||||
AND (:id IS NULL OR m.id <> :id)
|
||||
AND NOT (m.tiradaMax < :min OR m.tiradaMin > :max)
|
||||
""")
|
||||
long countOverlaps(
|
||||
@Param("enc") TipoEncuadernacion enc,
|
||||
@Param("cub") TipoCubierta cub,
|
||||
@Param("min") Integer min,
|
||||
@Param("max") Integer max,
|
||||
@Param("id") Long id);
|
||||
|
||||
@Query("""
|
||||
SELECT m FROM MargenPresupuesto m
|
||||
WHERE m.deleted = false
|
||||
AND m.tipoEncuadernacion = :enc
|
||||
AND m.tipoCubierta = :cub
|
||||
AND :tirada BETWEEN m.tiradaMin AND m.tiradaMax
|
||||
""")
|
||||
MargenPresupuesto findByTipoAndTirada(
|
||||
@Param("enc") TipoEncuadernacion enc,
|
||||
@Param("cub") TipoCubierta cub,
|
||||
@Param("tirada") Integer tirada);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.imprimelibros.erp.configuracion.margenes_presupuestos;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
|
||||
|
||||
@Service
|
||||
@Transactional
|
||||
public class MargenPresupuestoService {
|
||||
|
||||
private final MargenPresupuestoDao dao;
|
||||
|
||||
public MargenPresupuestoService(MargenPresupuestoDao dao) {
|
||||
this.dao = dao;
|
||||
}
|
||||
|
||||
public List<MargenPresupuesto> findAll() {
|
||||
return dao.findAll();
|
||||
}
|
||||
|
||||
public Optional<MargenPresupuesto> findById(Long id) {
|
||||
return dao.findById(id);
|
||||
}
|
||||
|
||||
public MargenPresupuesto save(MargenPresupuesto entity) {
|
||||
return dao.save(entity);
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
dao.deleteById(id);
|
||||
}
|
||||
|
||||
|
||||
public boolean hasOverlap(TipoEncuadernacion enc, TipoCubierta cub, Integer min, Integer max, Long excludeId) {
|
||||
long count = dao.countOverlaps(enc, cub, min, max, excludeId);
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
162
src/main/java/com/imprimelibros/erp/datatables/DataTable.java
Normal file
162
src/main/java/com/imprimelibros/erp/datatables/DataTable.java
Normal file
@ -0,0 +1,162 @@
|
||||
package com.imprimelibros.erp.datatables;
|
||||
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.data.domain.*;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class DataTable<T> {
|
||||
|
||||
public interface FilterHook<T> extends BiConsumer<SpecBuilder<T>, DataTablesRequest> {
|
||||
}
|
||||
|
||||
public interface SpecBuilder<T> {
|
||||
void add(Specification<T> extra);
|
||||
}
|
||||
|
||||
private final JpaSpecificationExecutor<T> repo;
|
||||
private final Class<T> entityClass;
|
||||
private final DataTablesRequest dt;
|
||||
private final List<String> searchable;
|
||||
private final List<Function<T, Map<String, Object>>> adders = new ArrayList<>();
|
||||
private final List<Function<Map<String, Object>, Map<String, Object>>> editors = new ArrayList<>();
|
||||
private final List<FilterHook<T>> filters = new ArrayList<>();
|
||||
private Specification<T> baseSpec = (root, q, cb) -> cb.conjunction();
|
||||
private final ObjectMapper om = new ObjectMapper()
|
||||
.registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
private List<String> orderable = null;
|
||||
|
||||
private DataTable(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
|
||||
List<String> searchable) {
|
||||
this.repo = repo;
|
||||
this.entityClass = entityClass;
|
||||
this.dt = dt;
|
||||
this.searchable = searchable;
|
||||
}
|
||||
|
||||
public static <T> DataTable<T> of(JpaSpecificationExecutor<T> repo, Class<T> entityClass, DataTablesRequest dt,
|
||||
List<String> searchable) {
|
||||
return new DataTable<>(repo, entityClass, dt, searchable);
|
||||
}
|
||||
|
||||
/** Equivalente a tu $q->where(...): establece condición base */
|
||||
public DataTable<T> where(Specification<T> spec) {
|
||||
this.baseSpec = this.baseSpec.and(spec);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** add("campo", fn(entity)->valor|Map) */
|
||||
public DataTable<T> add(String field, Function<T, Object> fn) {
|
||||
adders.add(entity -> {
|
||||
Map<String, Object> m = new HashMap<>();
|
||||
m.put(field, fn.apply(entity));
|
||||
return m;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* add(fn(entity)->Map<String,Object>) para devolver objetos anidados como tu
|
||||
* "logo"
|
||||
*/
|
||||
public DataTable<T> add(Function<T, Map<String, Object>> fn) {
|
||||
adders.add(fn);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* edit("campo", fn(entity)->valor) sobreescribe un campo existente o lo crea si
|
||||
* no existe
|
||||
*/
|
||||
public DataTable<T> edit(String field, Function<T, Object> fn) {
|
||||
editors.add(row -> {
|
||||
row.put(field, fn.apply((T) row.get("__entity")));
|
||||
return row;
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public DataTable<T> orderable(List<String> fields) {
|
||||
this.orderable = fields;
|
||||
return this;
|
||||
}
|
||||
|
||||
private List<String> getOrderable() {
|
||||
return (orderable == null || orderable.isEmpty()) ? this.searchable : this.orderable;
|
||||
}
|
||||
|
||||
/** filter((builder, req) -> builder.add(miExtraSpec(req))) */
|
||||
public DataTable<T> filter(FilterHook<T> hook) {
|
||||
filters.add(hook);
|
||||
return this;
|
||||
}
|
||||
|
||||
public DataTablesResponse<Map<String, Object>> toJson(long totalCount) {
|
||||
// Construye spec con búsqueda global + base + filtros custom
|
||||
Specification<T> spec = baseSpec.and(DataTablesSpecification.build(dt, searchable));
|
||||
final Specification<T>[] holder = new Specification[] { spec };
|
||||
filters.forEach(h -> h.accept(extra -> holder[0] = holder[0].and(extra), dt));
|
||||
spec = holder[0];
|
||||
|
||||
// Sort
|
||||
// Sort
|
||||
Sort sort = Sort.unsorted();
|
||||
if (!dt.order.isEmpty() && !dt.columns.isEmpty()) {
|
||||
List<Sort.Order> orders = new ArrayList<>();
|
||||
for (var o : dt.order) {
|
||||
var col = dt.columns.get(o.column);
|
||||
String field = col != null ? col.name : null;
|
||||
|
||||
if (field == null || field.isBlank())
|
||||
continue;
|
||||
if (!col.orderable)
|
||||
continue;
|
||||
if (!getOrderable().contains(field))
|
||||
continue; // << usa tu whitelist
|
||||
|
||||
orders.add(new Sort.Order(
|
||||
"desc".equalsIgnoreCase(o.dir) ? Sort.Direction.DESC : Sort.Direction.ASC,
|
||||
field));
|
||||
}
|
||||
if (!orders.isEmpty()) {
|
||||
sort = Sort.by(orders);
|
||||
} else {
|
||||
for (var c : dt.columns) {
|
||||
if (c != null && c.orderable && c.name != null && !c.name.isBlank()
|
||||
&& getOrderable().contains(c.name)) {
|
||||
sort = Sort.by(c.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Page
|
||||
int page = dt.length > 0 ? dt.start / dt.length : 0;
|
||||
Pageable pageable = dt.length > 0 ? PageRequest.of(page, dt.length, sort) : Pageable.unpaged();
|
||||
|
||||
var p = repo.findAll(holder[0], pageable);
|
||||
long filtered = p.getTotalElements();
|
||||
|
||||
// Mapear entidad -> Map base (via Jackson) + add/edit
|
||||
List<Map<String, Object>> data = new ArrayList<>();
|
||||
for (T e : p.getContent()) {
|
||||
Map<String, Object> row = om.convertValue(e, Map.class);
|
||||
row.put("__entity", e); // para editores que necesiten la entidad
|
||||
for (var ad : adders)
|
||||
row.putAll(ad.apply(e));
|
||||
for (var ed : editors)
|
||||
ed.apply(row);
|
||||
row.remove("__entity");
|
||||
data.add(row);
|
||||
}
|
||||
return new DataTablesResponse<>(dt.draw, totalCount, filtered, data);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.imprimelibros.erp.datatables;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.*;
|
||||
|
||||
public class DataTablesParser {
|
||||
public static DataTablesRequest from(HttpServletRequest req) {
|
||||
DataTablesRequest dt = new DataTablesRequest();
|
||||
dt.draw = parseInt(req.getParameter("draw"), 0);
|
||||
dt.start = parseInt(req.getParameter("start"), 0);
|
||||
dt.length = parseInt(req.getParameter("length"), 10);
|
||||
|
||||
if (req.getParameter("search[value]") != null) {
|
||||
dt.search.value = req.getParameter("search[value]");
|
||||
dt.search.regex = Boolean.parseBoolean(req.getParameter("search[regex]"));
|
||||
}
|
||||
|
||||
for (int i=0;; i++) {
|
||||
String data = req.getParameter("columns["+i+"][data]");
|
||||
if (data == null) break;
|
||||
DataTablesRequest.Column c = new DataTablesRequest.Column();
|
||||
c.data = data;
|
||||
c.name = Optional.ofNullable(req.getParameter("columns["+i+"][name]")).orElse(data);
|
||||
c.searchable = Boolean.parseBoolean(Optional.ofNullable(req.getParameter("columns["+i+"][searchable]")).orElse("true"));
|
||||
c.orderable = Boolean.parseBoolean(Optional.ofNullable(req.getParameter("columns["+i+"][orderable]")).orElse("true"));
|
||||
c.search.value = Optional.ofNullable(req.getParameter("columns["+i+"][search][value]")).orElse("");
|
||||
dt.columns.add(c);
|
||||
}
|
||||
for (int i=0;; i++) {
|
||||
String colIdx = req.getParameter("order["+i+"][column]");
|
||||
if (colIdx == null) break;
|
||||
DataTablesRequest.Order o = new DataTablesRequest.Order();
|
||||
o.column = parseInt(colIdx,0);
|
||||
o.dir = Optional.ofNullable(req.getParameter("order["+i+"][dir]")).orElse("asc");
|
||||
dt.order.add(o);
|
||||
}
|
||||
|
||||
// guarda TODOS los params crudos (para filtros custom)
|
||||
req.getParameterMap().forEach((k,v) -> dt.raw.put(k, v!=null && v.length>0 ? v[0] : null));
|
||||
return dt;
|
||||
}
|
||||
private static int parseInt(String s, int def){ try{return Integer.parseInt(s);}catch(Exception e){return def;}}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
package com.imprimelibros.erp.datatables;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
public class DataTablesRequest {
|
||||
public int draw;
|
||||
public int start;
|
||||
public int length;
|
||||
public Search search = new Search();
|
||||
public List<Order> order = new ArrayList<>();
|
||||
public List<Column> columns = new ArrayList<>();
|
||||
public Map<String,String> raw = new HashMap<>(); // <- params extra
|
||||
|
||||
public static class Search { public String value=""; public boolean regex; }
|
||||
public static class Order { public int column; public String dir; }
|
||||
public static class Column {
|
||||
public String data;
|
||||
public String name;
|
||||
public boolean searchable=true;
|
||||
public boolean orderable=true;
|
||||
public Search search=new Search();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.imprimelibros.erp.datatables;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class DataTablesResponse<T> {
|
||||
public int draw;
|
||||
public long recordsTotal;
|
||||
public long recordsFiltered;
|
||||
public List<T> data;
|
||||
|
||||
public DataTablesResponse(int draw, long total, long filtered, List<T> data) {
|
||||
this.draw = draw;
|
||||
this.recordsTotal = total;
|
||||
this.recordsFiltered = filtered;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package com.imprimelibros.erp.datatables;
|
||||
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import jakarta.persistence.criteria.*;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class DataTablesSpecification {
|
||||
|
||||
/**
|
||||
* Crea una Specification con búsqueda global y por columna (LIKE
|
||||
* case-insensitive)
|
||||
*
|
||||
* @param dt request de datatables
|
||||
* @param searchableFields campos del entity para el buscador global
|
||||
*/
|
||||
public static <T> Specification<T> build(DataTablesRequest dt, List<String> searchableFields) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> ands = new ArrayList<>();
|
||||
|
||||
// Filtro por columna (si lo usas en el cliente)
|
||||
for (int i = 0; i < dt.columns.size(); i++) {
|
||||
DataTablesRequest.Column col = dt.columns.get(i);
|
||||
if (col.searchable && col.search != null && col.search.value != null && !col.search.value.isEmpty()) {
|
||||
try {
|
||||
ands.add(like(cb, root.get(col.name), col.search.value));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// columna no mapeada o relación: la ignoramos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Búsqueda global
|
||||
if (dt.search != null && dt.search.value != null && !dt.search.value.isEmpty()
|
||||
&& !searchableFields.isEmpty()) {
|
||||
String term = "%" + dt.search.value.trim().toLowerCase() + "%";
|
||||
List<Predicate> ors = new ArrayList<>();
|
||||
for (String f : searchableFields) {
|
||||
try {
|
||||
ors.add(cb.like(cb.lower(root.get(f).as(String.class)), term));
|
||||
} catch (IllegalArgumentException ex) {
|
||||
// campo no simple: lo saltamos
|
||||
}
|
||||
}
|
||||
if (!ors.isEmpty())
|
||||
ands.add(cb.or(ors.toArray(new Predicate[0])));
|
||||
}
|
||||
|
||||
return ands.isEmpty() ? cb.conjunction() : cb.and(ands.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private static Predicate like(CriteriaBuilder cb, Path<?> path, String value) {
|
||||
return cb.like(cb.lower(path.as(String.class)), "%" + value.trim().toLowerCase() + "%");
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,19 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
|
||||
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuestoDao;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
@Service
|
||||
@ -22,13 +29,16 @@ public class skApiClient {
|
||||
|
||||
private final AuthService authService;
|
||||
private final RestTemplate restTemplate;
|
||||
private final MargenPresupuestoDao margenPresupuestoDao;
|
||||
|
||||
public skApiClient(AuthService authService) {
|
||||
public skApiClient(AuthService authService, MargenPresupuestoDao margenPresupuestoDao) {
|
||||
this.authService = authService;
|
||||
this.restTemplate = new RestTemplate();
|
||||
this.margenPresupuestoDao = margenPresupuestoDao;
|
||||
}
|
||||
|
||||
public String getPrice(Map<String, Object> requestBody) {
|
||||
public String getPrice(Map<String, Object> requestBody, TipoEncuadernacion tipoEncuadernacion,
|
||||
TipoCubierta tipoCubierta) {
|
||||
return performWithRetry(() -> {
|
||||
String url = this.skApiUrl + "api/calcular";
|
||||
|
||||
@ -45,14 +55,57 @@ public class skApiClient {
|
||||
String.class);
|
||||
|
||||
try {
|
||||
Map<String, Object> responseBody = new ObjectMapper().readValue(response.getBody(), Map.class);
|
||||
Map<String, Object> responseBody = new ObjectMapper().readValue(
|
||||
response.getBody(),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
if (responseBody.get("error") == null) {
|
||||
return new ObjectMapper().writeValueAsString(
|
||||
Map.of("data", responseBody.get("data")));
|
||||
Object dataObj = responseBody.get("data");
|
||||
|
||||
if (dataObj instanceof Map) {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> data = (Map<String, Object>) dataObj;
|
||||
|
||||
List<Integer> tiradas = mapper.convertValue(
|
||||
data.get("tiradas"), new TypeReference<List<Integer>>() {
|
||||
});
|
||||
List<Double> precios = mapper.convertValue(
|
||||
data.get("precios"), new TypeReference<List<Double>>() {
|
||||
});
|
||||
|
||||
for (int i = 0; i < tiradas.size(); i++) {
|
||||
int tirada = tiradas.get(i);
|
||||
|
||||
MargenPresupuesto margen = margenPresupuestoDao.findByTipoAndTirada(
|
||||
tipoEncuadernacion, tipoCubierta, tirada);
|
||||
|
||||
if (margen != null) {
|
||||
double margenValue = calcularMargen(
|
||||
tirada,
|
||||
margen.getTiradaMin(),
|
||||
margen.getTiradaMax(),
|
||||
margen.getMargenMax(),
|
||||
margen.getMargenMin());
|
||||
double nuevoPrecio = precios.get(i) * (1 + margenValue / 100.0);
|
||||
precios.set(i, nuevoPrecio);
|
||||
} else {
|
||||
System.out.println("No se encontró margen para tirada " + tirada);
|
||||
}
|
||||
}
|
||||
|
||||
// <-- Clave: sustituir la lista en el map que se devuelve
|
||||
data.put("precios", precios);
|
||||
// (tiradas no cambia, pero si la modificases: data.put("tiradas", tiradas);)
|
||||
}
|
||||
|
||||
return mapper.writeValueAsString(Map.of("data", responseBody.get("data")));
|
||||
} else {
|
||||
return "{\"error\": 1}";
|
||||
}
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
e.printStackTrace();
|
||||
return "{\"error\": 1}";
|
||||
@ -104,7 +157,11 @@ public class skApiClient {
|
||||
|
||||
} catch (JsonProcessingException e) {
|
||||
// Fallback al 80% del ancho
|
||||
Map<String, Object> tamanio = (Map<String, Object>) requestBody.get("tamanio");
|
||||
Map<String, Object> tamanio = new ObjectMapper().convertValue(
|
||||
requestBody.get("tamanio"),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
|
||||
if (tamanio == null || tamanio.get("ancho") == null)
|
||||
throw new RuntimeException("Tamaño no válido en la solicitud: " + requestBody);
|
||||
else {
|
||||
@ -132,7 +189,10 @@ public class skApiClient {
|
||||
String.class);
|
||||
|
||||
try {
|
||||
Map<String, Object> responseBody = new ObjectMapper().readValue(response.getBody(), Map.class);
|
||||
Map<String, Object> responseBody = new ObjectMapper().readValue(
|
||||
response.getBody(),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
return responseBody.get("data").toString();
|
||||
} catch (JsonProcessingException e) {
|
||||
e.printStackTrace();
|
||||
@ -162,4 +222,14 @@ public class skApiClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static double calcularMargen(
|
||||
int tirada, int tiradaMin, int tiradaMax,
|
||||
double margenMax, double margenMin) {
|
||||
if (tirada <= tiradaMin)
|
||||
return margenMax;
|
||||
if (tirada >= tiradaMax)
|
||||
return margenMin;
|
||||
return margenMax - ((double) (tirada - tiradaMin) / (tiradaMax - tiradaMin)) * (margenMax - margenMin);
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,11 @@ public class HomeController {
|
||||
model.addAttribute("ancho_alto_min", variableService.getValorEntero("ancho_alto_min"));
|
||||
model.addAttribute("ancho_alto_max", variableService.getValorEntero("ancho_alto_max"));
|
||||
}
|
||||
else{
|
||||
// empty translations for authenticated users
|
||||
Map<String, String> translations = Map.of();
|
||||
model.addAttribute("languageBundle", translations);
|
||||
}
|
||||
return "imprimelibros/home";
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,78 @@ package com.imprimelibros.erp.login;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
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.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
import com.imprimelibros.erp.login.dto.SignupForm;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
|
||||
@Controller
|
||||
public class LoginController {
|
||||
|
||||
@GetMapping("/login")
|
||||
public String index(Model model, Locale locale) {
|
||||
return "imprimelibros/login/login";
|
||||
|
||||
private final SignupService signupService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public LoginController(SignupService signupService, MessageSource messageSource) {
|
||||
this.signupService = signupService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@GetMapping("/login")
|
||||
public String index(Model model, Locale locale) {
|
||||
model.addAttribute("form", "_login");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String signup(Model model, Locale locale) {
|
||||
if (!model.containsAttribute("signupForm")) {
|
||||
model.addAttribute("signupForm", new SignupForm());
|
||||
}
|
||||
model.addAttribute("form", "_signup");
|
||||
return "imprimelibros/login/login";
|
||||
}
|
||||
|
||||
@PostMapping("/signup")
|
||||
public String doSignup(@Valid @ModelAttribute("signupForm") SignupForm form,
|
||||
BindingResult br,
|
||||
RedirectAttributes ra,
|
||||
Locale locale) {
|
||||
if (br.hasErrors()) {
|
||||
ra.addFlashAttribute("org.springframework.validation.BindingResult.signupForm", br);
|
||||
ra.addFlashAttribute("signupForm", form);
|
||||
ra.addFlashAttribute("signup_error",
|
||||
messageSource.getMessage("login.signup.error.review", null, locale));
|
||||
return "redirect:/signup";
|
||||
}
|
||||
try {
|
||||
signupService.register(form, locale);
|
||||
ra.addFlashAttribute("info", messageSource.getMessage("login.signup.success", null, locale));
|
||||
return "redirect:/login";
|
||||
} catch (IllegalArgumentException ex) {
|
||||
ra.addFlashAttribute("signup_error", ex.getMessage());
|
||||
ra.addFlashAttribute("signupForm", form);
|
||||
return "redirect:/signup";
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/verify")
|
||||
public String verify(@RequestParam("token") String token, RedirectAttributes ra, Locale locale) {
|
||||
boolean ok = signupService.verify(token);
|
||||
if (ok) {
|
||||
ra.addFlashAttribute("info", messageSource.getMessage("login.signup.success.verified", null, locale));
|
||||
} else {
|
||||
ra.addFlashAttribute("danger", messageSource.getMessage("login.signup.error.token.invalid", null, locale));
|
||||
}
|
||||
return "redirect:/login";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
129
src/main/java/com/imprimelibros/erp/login/SignupService.java
Normal file
129
src/main/java/com/imprimelibros/erp/login/SignupService.java
Normal file
@ -0,0 +1,129 @@
|
||||
package com.imprimelibros.erp.login;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
import com.imprimelibros.erp.common.email.EmailService;
|
||||
import com.imprimelibros.erp.login.dto.SignupForm;
|
||||
import com.imprimelibros.erp.users.Role;
|
||||
import com.imprimelibros.erp.users.RoleDao;
|
||||
import com.imprimelibros.erp.users.User;
|
||||
import com.imprimelibros.erp.users.UserDao;
|
||||
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@Service
|
||||
public class SignupService {
|
||||
|
||||
private final UserDao userRepository;
|
||||
private final RoleDao roleRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final VerificationTokenRepository tokenRepository;
|
||||
private final EmailService emailService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
// minutos de validez del token
|
||||
private static final long TOKEN_MINUTES = 60;
|
||||
|
||||
public SignupService(UserDao userRepository,
|
||||
RoleDao roleRepository,
|
||||
PasswordEncoder passwordEncoder,
|
||||
VerificationTokenRepository tokenRepository,
|
||||
EmailService emailService,
|
||||
MessageSource messageSource) {
|
||||
this.userRepository = userRepository;
|
||||
this.roleRepository = roleRepository;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.tokenRepository = tokenRepository;
|
||||
this.emailService = emailService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void register(SignupForm form, Locale locale) {
|
||||
if (!form.getPassword().equals(form.getPasswordConfirm())) {
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.password.mismatch", null, locale));
|
||||
}
|
||||
|
||||
String email = form.getUsername().trim().toLowerCase();
|
||||
|
||||
var existingLite = userRepository.findLiteByUserNameIgnoreCase(email);
|
||||
|
||||
if (existingLite.isPresent()) {
|
||||
var lite = existingLite.get();
|
||||
|
||||
boolean isDeleted = lite.getDeleted();
|
||||
if (isDeleted) {
|
||||
// caso: existe pero borrado
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.email.exists-archived", null, locale));
|
||||
} else {
|
||||
// caso: existe y no borrado
|
||||
throw new IllegalArgumentException(
|
||||
messageSource.getMessage("login.signup.error.email.exists", null, locale));
|
||||
}
|
||||
}
|
||||
|
||||
// Crear usuario deshabilitado
|
||||
User user = new User();
|
||||
user.setUserName(form.getUsername().trim().toLowerCase());
|
||||
user.setFullName(form.getName().trim());
|
||||
user.setPassword(passwordEncoder.encode(form.getPassword()));
|
||||
user.setEnabled(false);
|
||||
var roles = new HashSet<Role>();
|
||||
roles.add(roleRepository.findRoleByName("USER").orElseThrow());
|
||||
user.setRoles(roles);
|
||||
user = userRepository.save(user);
|
||||
|
||||
// Generar token
|
||||
var token = VerificationToken.create(user.getId(), TOKEN_MINUTES);
|
||||
tokenRepository.save(token);
|
||||
|
||||
// Construir URL absoluta para /verify
|
||||
String verifyUrl = ServletUriComponentsBuilder.fromCurrentContextPath()
|
||||
.path("/verify")
|
||||
.queryParam("token", token.getToken())
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
// Enviar correo
|
||||
Map<String, Object> model = new HashMap<>();
|
||||
model.put("verifyUrl", verifyUrl);
|
||||
model.put("minutes", TOKEN_MINUTES);
|
||||
emailService.sendVerificationEmail(
|
||||
user.getUserName(),
|
||||
user.getFullName(),
|
||||
verifyUrl,
|
||||
locale);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean verify(String tokenValue) {
|
||||
var tokenOpt = tokenRepository.findByToken(tokenValue);
|
||||
if (tokenOpt.isEmpty())
|
||||
return false;
|
||||
|
||||
var token = tokenOpt.get();
|
||||
if (token.isUsed() || token.isExpired())
|
||||
return false;
|
||||
|
||||
var user = userRepository.findById(token.getUserId())
|
||||
.orElseThrow(() -> new IllegalStateException("Usuario no encontrado para el token"));
|
||||
|
||||
user.setEnabled(true);
|
||||
userRepository.save(user);
|
||||
|
||||
token.setUsedAt(java.time.LocalDateTime.now());
|
||||
tokenRepository.save(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,54 @@
|
||||
package com.imprimelibros.erp.login;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
@Entity
|
||||
@Table(name = "verification_tokens", indexes = {
|
||||
@Index(name = "idx_verification_token_token", columnList = "token", unique = true)
|
||||
})
|
||||
public class VerificationToken {
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable=false, unique=true, length=64)
|
||||
private String token;
|
||||
|
||||
@Column(nullable=false)
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable=false)
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(nullable=false)
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
private LocalDateTime usedAt;
|
||||
|
||||
public static VerificationToken create(Long userId, long minutesValid) {
|
||||
VerificationToken t = new VerificationToken();
|
||||
t.token = UUID.randomUUID().toString().replace("-", "");
|
||||
t.userId = userId;
|
||||
t.createdAt = LocalDateTime.now();
|
||||
t.expiresAt = t.createdAt.plusMinutes(minutesValid);
|
||||
return t;
|
||||
}
|
||||
|
||||
// getters/setters
|
||||
public Long getId() { return id; }
|
||||
public String getToken() { return token; }
|
||||
public void setToken(String token) { this.token = token; }
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
public LocalDateTime getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||
public LocalDateTime getUsedAt() { return usedAt; }
|
||||
public void setUsedAt(LocalDateTime usedAt) { this.usedAt = usedAt; }
|
||||
|
||||
public boolean isUsed() { return usedAt != null; }
|
||||
public boolean isExpired() { return LocalDateTime.now().isAfter(expiresAt); }
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.imprimelibros.erp.login;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface VerificationTokenRepository extends JpaRepository<VerificationToken, Long> {
|
||||
Optional<VerificationToken> findByToken(String token);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.imprimelibros.erp.login.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
public class SignupForm {
|
||||
|
||||
@NotBlank(message = "{usuarios.error.correo}")
|
||||
@Email(message = "{usuarios.error.correo.invalido}")
|
||||
private String username;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.nombre}")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.contraseña}")
|
||||
@Size(min = 6, message = "{usuarios.error.contraseña.tamaño}")
|
||||
private String password;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.confirmPassword.requerida}")
|
||||
private String passwordConfirm;
|
||||
|
||||
// getters/setters
|
||||
public String getUsername() { return username; }
|
||||
public void setUsername(String u) { this.username = u; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String n) { this.name = n; }
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String p) { this.password = p; }
|
||||
public String getPasswordConfirm() { return passwordConfirm; }
|
||||
public void setPasswordConfirm(String pc) { this.passwordConfirm = pc; }
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package com.imprimelibros.erp.presupuesto;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@ -23,6 +22,7 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.http.MediaType;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.imprimelibros.erp.externalApi.skApiClient;
|
||||
import com.imprimelibros.erp.presupuesto.classes.ImagenPresupuesto;
|
||||
import com.imprimelibros.erp.presupuesto.classes.PresupuestoMaquetacion;
|
||||
@ -46,7 +46,7 @@ public class PresupuestoController {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PresupuestoController(ObjectMapper objectMapper){
|
||||
public PresupuestoController(ObjectMapper objectMapper) {
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@ -179,7 +179,10 @@ public class PresupuestoController {
|
||||
|
||||
// opciones gramaje interior
|
||||
resultado.putAll(presupuestoService.obtenerOpcionesGramajeInterior(presupuesto));
|
||||
List<String> opciones = (List<String>) resultado.get("opciones_gramaje_interior");
|
||||
|
||||
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_papel_interior"),
|
||||
new TypeReference<List<String>>() {
|
||||
});
|
||||
|
||||
if (opciones != null && !opciones.isEmpty()) {
|
||||
String gramajeActual = presupuesto.getGramajeInterior().toString();
|
||||
@ -207,7 +210,9 @@ public class PresupuestoController {
|
||||
}
|
||||
|
||||
Map<String, Object> resultado = presupuestoService.obtenerOpcionesGramajeInterior(presupuesto);
|
||||
List<String> opciones = (List<String>) resultado.get("opciones_gramaje_interior");
|
||||
List<String> opciones = new ObjectMapper().convertValue(resultado.get("opciones_gramaje_interior"),
|
||||
new TypeReference<List<String>>() {
|
||||
});
|
||||
|
||||
if (opciones != null && !opciones.isEmpty()) {
|
||||
String gramajeActual = presupuesto.getGramajeInterior().toString();
|
||||
@ -245,9 +250,12 @@ public class PresupuestoController {
|
||||
|
||||
Map<String, Object> resultado = new HashMap<>();
|
||||
Map<String, Object> papelesCubierta = presupuestoService.obtenerOpcionesPapelCubierta(presupuesto, locale);
|
||||
List<ImagenPresupuesto> opciones = (List<ImagenPresupuesto>) presupuestoService
|
||||
.obtenerOpcionesPapelCubierta(presupuesto, locale)
|
||||
.get("opciones_papel_cubierta");
|
||||
List<ImagenPresupuesto> opciones = new ObjectMapper().convertValue(
|
||||
presupuestoService
|
||||
.obtenerOpcionesPapelCubierta(presupuesto, locale)
|
||||
.get("opciones_papel_cubierta"),
|
||||
new TypeReference<List<ImagenPresupuesto>>() {
|
||||
});
|
||||
|
||||
if (opciones != null && opciones.stream().noneMatch(
|
||||
o -> o.getExtra_data().get("sk-id").equals(String.valueOf(presupuesto.getPapelCubiertaId())))) {
|
||||
@ -256,7 +264,10 @@ public class PresupuestoController {
|
||||
resultado.putAll(papelesCubierta);
|
||||
|
||||
resultado.putAll(presupuestoService.obtenerOpcionesGramajeCubierta(presupuesto));
|
||||
List<String> gramajesCubierta = (List<String>) resultado.get("opciones_gramaje_cubierta");
|
||||
List<String> gramajesCubierta = new ObjectMapper().convertValue(
|
||||
resultado.get("opciones_gramaje_cubierta"),
|
||||
new TypeReference<List<String>>() {
|
||||
});
|
||||
if (gramajesCubierta != null && !gramajesCubierta.isEmpty()) {
|
||||
String gramajeActual = presupuesto.getGramajeCubierta().toString();
|
||||
if (!gramajesCubierta.contains(gramajeActual)) {
|
||||
@ -305,7 +316,8 @@ public class PresupuestoController {
|
||||
if (!errores.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(errores);
|
||||
}
|
||||
String price = apiClient.getPrice(presupuestoService.toSkApiRequest(presupuesto));
|
||||
String price = apiClient.getPrice(presupuestoService.toSkApiRequest(presupuesto),
|
||||
presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
|
||||
if (price == null || price.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body("No se pudo obtener el precio. Intente nuevamente.");
|
||||
}
|
||||
|
||||
@ -6,14 +6,12 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.Locale;
|
||||
import java.text.NumberFormat;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@ -577,7 +575,7 @@ public class PresupuestoService {
|
||||
} else if (presupuestoTemp.getTipoImpresion() == Presupuesto.TipoImpresion.negro) {
|
||||
presupuestoTemp.setTipoImpresion(Presupuesto.TipoImpresion.negrohq);
|
||||
}
|
||||
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp));
|
||||
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuestoTemp), presupuestoTemp.getTipoEncuadernacion(), presupuestoTemp.getTipoCubierta());
|
||||
Double price_prototipo = 0.0;
|
||||
try {
|
||||
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
|
||||
@ -848,7 +846,7 @@ public class PresupuestoService {
|
||||
public HashMap<String, Object> calcularPresupuesto(Presupuesto presupuesto, Locale locale) {
|
||||
|
||||
HashMap<String, Object> price = new HashMap<>();
|
||||
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto));
|
||||
String priceStr = apiClient.getPrice(this.toSkApiRequest(presupuesto), presupuesto.getTipoEncuadernacion(), presupuesto.getTipoCubierta());
|
||||
|
||||
try {
|
||||
price = new ObjectMapper().readValue(priceStr, new TypeReference<>() {
|
||||
|
||||
@ -6,7 +6,6 @@ import com.imprimelibros.erp.i18n.TranslationService;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto;
|
||||
import org.springframework.context.MessageSource;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
package com.imprimelibros.erp.shared.validation;
|
||||
|
||||
import static java.lang.annotation.ElementType.TYPE;
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
|
||||
@Documented
|
||||
@Target(TYPE)
|
||||
@Retention(RUNTIME)
|
||||
@Constraint(validatedBy = NoRangeOverlapValidator.class)
|
||||
public @interface NoRangeOverlap {
|
||||
|
||||
// Campos obligatorios
|
||||
String min(); // nombre del campo min (Integer/Long/etc.)
|
||||
String max(); // nombre del campo max
|
||||
|
||||
// Campos opcionales
|
||||
String id() default "id"; // nombre del campo ID (para excluir self en update)
|
||||
String[] partitionBy() default {}; // ej. {"tipoEncuadernacion","tipoCubierta"}
|
||||
|
||||
// Soft delete opcional
|
||||
String deletedFlag() default ""; // ej. "deleted" (si vacío, no se aplica filtro)
|
||||
boolean deletedActiveValue() default false; // qué valor significa "activo" (normalmente false)
|
||||
|
||||
// Mensajes
|
||||
String message() default "{validation.range.overlaps}";
|
||||
String invalidRangeMessage() default "{validation.range.invalid}";
|
||||
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
package com.imprimelibros.erp.shared.validation;
|
||||
|
||||
import java.beans.IntrospectionException;
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import jakarta.persistence.FlushModeType;
|
||||
import jakarta.persistence.PersistenceContext;
|
||||
import jakarta.persistence.PersistenceUnit;
|
||||
import jakarta.persistence.criteria.*;
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class NoRangeOverlapValidator implements ConstraintValidator<NoRangeOverlap, Object> {
|
||||
|
||||
@PersistenceUnit
|
||||
private EntityManagerFactory emf;
|
||||
|
||||
private String minField;
|
||||
private String maxField;
|
||||
private String idField;
|
||||
private String[] partitionFields;
|
||||
private String deletedFlag;
|
||||
private boolean deletedActiveValue;
|
||||
private String message;
|
||||
private String invalidRangeMessage;
|
||||
|
||||
@Override
|
||||
public void initialize(NoRangeOverlap ann) {
|
||||
this.minField = ann.min();
|
||||
this.maxField = ann.max();
|
||||
this.idField = ann.id();
|
||||
this.partitionFields = ann.partitionBy();
|
||||
this.deletedFlag = ann.deletedFlag();
|
||||
this.deletedActiveValue = ann.deletedActiveValue();
|
||||
this.message = ann.message();
|
||||
this.invalidRangeMessage = ann.invalidRangeMessage();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object bean, ConstraintValidatorContext ctx) {
|
||||
if (bean == null)
|
||||
return true;
|
||||
EntityManager em = null;
|
||||
try {
|
||||
// EM aislado para evitar auto-flush durante la validación
|
||||
em = emf.createEntityManager();
|
||||
em.setFlushMode(FlushModeType.COMMIT);
|
||||
|
||||
Class<?> entityClass = bean.getClass();
|
||||
|
||||
Number min = (Number) read(bean, minField);
|
||||
Number max = (Number) read(bean, maxField);
|
||||
Object id = safeRead(bean, idField); // puede ser null en INSERT
|
||||
|
||||
if (min == null || max == null)
|
||||
return true;
|
||||
|
||||
if (min.longValue() > max.longValue()) {
|
||||
ctx.disableDefaultConstraintViolation();
|
||||
ctx.buildConstraintViolationWithTemplate(invalidRangeMessage)
|
||||
.addPropertyNode(maxField)
|
||||
.addConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
|
||||
CriteriaBuilder cb = em.getCriteriaBuilder();
|
||||
CriteriaQuery<Long> cq = cb.createQuery(Long.class);
|
||||
Root<?> root = cq.from(entityClass);
|
||||
|
||||
cq.select(cb.count(root));
|
||||
|
||||
Predicate pred = cb.conjunction();
|
||||
|
||||
if (id != null) {
|
||||
pred = cb.and(pred, cb.notEqual(root.get(idField), id));
|
||||
}
|
||||
|
||||
for (String pf : partitionFields) {
|
||||
Object val = read(bean, pf);
|
||||
pred = cb.and(pred, cb.equal(root.get(pf), val));
|
||||
}
|
||||
|
||||
if (!deletedFlag.isEmpty()) {
|
||||
pred = cb.and(pred, cb.equal(root.get(deletedFlag), deletedActiveValue));
|
||||
}
|
||||
|
||||
Expression<Number> eMin = root.get(minField);
|
||||
Expression<Number> eMax = root.get(maxField);
|
||||
|
||||
Predicate noOverlap = cb.or(
|
||||
cb.lt(eMax.as(Long.class), min.longValue()),
|
||||
cb.gt(eMin.as(Long.class), max.longValue()));
|
||||
Predicate overlap = cb.not(noOverlap);
|
||||
|
||||
cq.where(cb.and(pred, overlap));
|
||||
|
||||
Long count = em.createQuery(cq).getSingleResult();
|
||||
if (count != null && count > 0) {
|
||||
ctx.disableDefaultConstraintViolation();
|
||||
ctx.buildConstraintViolationWithTemplate(message)
|
||||
.addPropertyNode(minField).addConstraintViolation();
|
||||
ctx.buildConstraintViolationWithTemplate(message)
|
||||
.addPropertyNode(maxField).addConstraintViolation();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception ex) {
|
||||
// En caso de error inesperado, puedes loguear aquí
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private Object read(Object bean, String name) throws Exception {
|
||||
PropertyDescriptor pd = getPropertyDescriptor(bean.getClass(), name);
|
||||
Method getter = pd.getReadMethod();
|
||||
return getter.invoke(bean);
|
||||
}
|
||||
|
||||
private Object safeRead(Object bean, String name) {
|
||||
try {
|
||||
return read(bean, name);
|
||||
} catch (Exception ignore) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private PropertyDescriptor getPropertyDescriptor(Class<?> clazz, String name) throws IntrospectionException {
|
||||
return new PropertyDescriptor(name, clazz);
|
||||
}
|
||||
}
|
||||
70
src/main/java/com/imprimelibros/erp/users/Role.java
Normal file
70
src/main/java/com/imprimelibros/erp/users/Role.java
Normal file
@ -0,0 +1,70 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.GeneratedValue;
|
||||
import jakarta.persistence.GenerationType;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import org.hibernate.annotations.SQLRestriction;
|
||||
import jakarta.persistence.OneToMany;
|
||||
import jakarta.persistence.FetchType;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
|
||||
@Entity
|
||||
@Table(name = "roles")
|
||||
public class Role {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "name")
|
||||
private String name;
|
||||
|
||||
@JsonIgnore
|
||||
@SQLRestriction("deleted = false")
|
||||
@OneToMany(mappedBy = "role", fetch = FetchType.LAZY)
|
||||
private Set<UserRole> usersLink = new HashSet<>();
|
||||
|
||||
public Role() {
|
||||
}
|
||||
|
||||
public Role(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public Set<UserRole> getUsersLink() {
|
||||
return usersLink;
|
||||
}
|
||||
|
||||
public void setUsersLink(Set<UserRole> usersLink) {
|
||||
this.usersLink = usersLink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Role{" + "id=" + id + ", name='" + name + '\'' + '}';
|
||||
}
|
||||
}
|
||||
9
src/main/java/com/imprimelibros/erp/users/RoleDao.java
Normal file
9
src/main/java/com/imprimelibros/erp/users/RoleDao.java
Normal file
@ -0,0 +1,9 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface RoleDao {
|
||||
|
||||
Optional<Role> findRoleByName(String theRoleName);
|
||||
|
||||
}
|
||||
35
src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java
Normal file
35
src/main/java/com/imprimelibros/erp/users/RoleDaoImpl.java
Normal file
@ -0,0 +1,35 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.TypedQuery;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public class RoleDaoImpl implements RoleDao {
|
||||
|
||||
private EntityManager entityManager;
|
||||
|
||||
public RoleDaoImpl(EntityManager theEntityManager) {
|
||||
entityManager = theEntityManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<Role> findRoleByName(String theRoleName) {
|
||||
|
||||
// retrieve/read from database using name
|
||||
TypedQuery<Role> theQuery = entityManager.createQuery("from Role where name=:roleName", Role.class);
|
||||
theQuery.setParameter("roleName", theRoleName);
|
||||
|
||||
Role theRole = null;
|
||||
|
||||
try {
|
||||
theRole = theQuery.getSingleResult();
|
||||
} catch (Exception e) {
|
||||
theRole = null;
|
||||
}
|
||||
return Optional.ofNullable(theRole);
|
||||
}
|
||||
}
|
||||
262
src/main/java/com/imprimelibros/erp/users/User.java
Normal file
262
src/main/java/com/imprimelibros/erp/users/User.java
Normal file
@ -0,0 +1,262 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.hibernate.annotations.Formula;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.hibernate.annotations.SQLRestriction;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_users_username", columnNames = "username")
|
||||
})
|
||||
@SQLRestriction("deleted = false")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "fullname")
|
||||
@NotBlank(message = "{validation.required}")
|
||||
private String fullName;
|
||||
|
||||
@Column(name = "username", nullable = false, length = 190)
|
||||
@Email(message = "{validation.email}")
|
||||
@NotBlank(message = "{validation.required}")
|
||||
private String userName;
|
||||
|
||||
@Column(name = "password")
|
||||
@NotBlank(message = "{validation.required}")
|
||||
private String password;
|
||||
|
||||
@Column(name = "enabled")
|
||||
private boolean enabled;
|
||||
|
||||
@Column(name = "deleted", nullable = false)
|
||||
private boolean deleted = false;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
@Column(name = "deleted_by")
|
||||
private Long deletedBy;
|
||||
|
||||
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY, cascade = { CascadeType.PERSIST, CascadeType.MERGE,
|
||||
CascadeType.REMOVE }, orphanRemoval = true)
|
||||
@SQLRestriction("deleted = false")
|
||||
@JsonIgnore
|
||||
private Set<UserRole> rolesLink = new HashSet<>();
|
||||
|
||||
// SUPERADMIN=3, ADMIN=2, USER=1 (ajusta a tus nombres reales)
|
||||
@Formula("""
|
||||
(
|
||||
select coalesce(max(
|
||||
case r.name
|
||||
when 'SUPERADMIN' then 3
|
||||
when 'ADMIN' then 2
|
||||
else 1
|
||||
end
|
||||
), 0)
|
||||
from users_roles ur
|
||||
join roles r on r.id = ur.role_id
|
||||
where ur.user_id = id
|
||||
)
|
||||
""")
|
||||
private Integer roleRank;
|
||||
|
||||
@Formula("""
|
||||
(select group_concat(lower(r.name) order by r.name separator ', ')
|
||||
from users_roles ur join roles r on r.id = ur.role_id
|
||||
where ur.user_id = id)
|
||||
""")
|
||||
private String rolesConcat;
|
||||
|
||||
/* Constructors */
|
||||
public User() {
|
||||
}
|
||||
|
||||
public User(String fullName, String userName, String password, boolean enabled) {
|
||||
this.fullName = fullName;
|
||||
this.userName = userName;
|
||||
this.password = password;
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public User(String fullName, String userName, String password, boolean enabled,
|
||||
Set<UserRole> roles) {
|
||||
this.fullName = fullName;
|
||||
this.userName = userName;
|
||||
this.password = password;
|
||||
this.enabled = enabled;
|
||||
this.rolesLink = roles;
|
||||
}
|
||||
|
||||
/* Getters and Setters */
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
@Transient
|
||||
public Set<Role> getRoles() {
|
||||
return rolesLink.stream()
|
||||
.filter(ur -> !ur.isDeleted())
|
||||
.map(UserRole::getRole)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
@JsonProperty("roles")
|
||||
public List<String> getRoleNames() {
|
||||
return this.getRoles().stream()
|
||||
.map(Role::getName)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.toList();
|
||||
}
|
||||
|
||||
public void setRoles(Set<Role> desired) {
|
||||
if (desired == null)
|
||||
desired = Collections.emptySet();
|
||||
|
||||
// 1) ids deseados
|
||||
Set<Long> desiredIds = desired.stream()
|
||||
.map(Role::getId)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// 2) Soft-delete de vínculos activos que ya no se desean
|
||||
this.rolesLink.stream()
|
||||
.filter(ur -> !ur.isDeleted() && !desiredIds.contains(ur.getRole().getId()))
|
||||
.forEach(UserRole::softDelete);
|
||||
|
||||
// 3) Para cada rol deseado: si hay vínculo borrado => reactivar; si no existe
|
||||
// => crear
|
||||
for (Role role : desired) {
|
||||
// ya activo
|
||||
boolean activeExists = this.rolesLink.stream()
|
||||
.anyMatch(ur -> !ur.isDeleted() && ur.getRole().getId().equals(role.getId()));
|
||||
if (activeExists)
|
||||
continue;
|
||||
|
||||
// existe borrado => reactivar
|
||||
Optional<UserRole> deletedLink = this.rolesLink.stream()
|
||||
.filter(ur -> ur.isDeleted() && ur.getRole().getId().equals(role.getId()))
|
||||
.findFirst();
|
||||
|
||||
if (deletedLink.isPresent()) {
|
||||
UserRole ur = deletedLink.get();
|
||||
ur.setDeleted(false);
|
||||
ur.setDeletedAt(null);
|
||||
} else {
|
||||
// crear nuevo vínculo
|
||||
UserRole ur = new UserRole(this, role);
|
||||
this.rolesLink.add(ur);
|
||||
// si tienes la colección inversa en Role:
|
||||
role.getUsersLink().add(ur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Integer getRoleRank() {
|
||||
return roleRank;
|
||||
}
|
||||
|
||||
public String getRolesConcat() {
|
||||
return rolesConcat;
|
||||
}
|
||||
|
||||
public boolean isDeleted() {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public void setDeleted(boolean deleted) {
|
||||
this.deleted = deleted;
|
||||
}
|
||||
|
||||
public LocalDateTime getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(LocalDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
|
||||
public Long getDeletedBy() {
|
||||
return deletedBy;
|
||||
}
|
||||
|
||||
public void setDeletedBy(Long deletedBy) {
|
||||
this.deletedBy = deletedBy;
|
||||
}
|
||||
|
||||
public Set<UserRole> getRolesLink() {
|
||||
return rolesLink;
|
||||
}
|
||||
|
||||
public void setRolesLink(Set<UserRole> rolesLink) {
|
||||
this.rolesLink = rolesLink;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User{" +
|
||||
"id=" + id +
|
||||
", fullName='" + fullName + '\'' +
|
||||
", userName='" + userName + '\'' +
|
||||
", enabled=" + enabled +
|
||||
", roles=" + getRoles() +
|
||||
'}';
|
||||
}
|
||||
|
||||
}
|
||||
349
src/main/java/com/imprimelibros/erp/users/UserController.java
Normal file
349
src/main/java/com/imprimelibros/erp/users/UserController.java
Normal file
@ -0,0 +1,349 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import com.imprimelibros.erp.datatables.DataTablesResponse;
|
||||
import com.imprimelibros.erp.i18n.TranslationService;
|
||||
import com.imprimelibros.erp.users.validation.UserForm;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
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.stereotype.Controller;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.validation.BindingResult;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import com.imprimelibros.erp.datatables.DataTablesRequest;
|
||||
import com.imprimelibros.erp.datatables.DataTablesParser;
|
||||
import com.imprimelibros.erp.config.Sanitizer;
|
||||
import com.imprimelibros.erp.datatables.DataTable;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
@Controller
|
||||
@PreAuthorize("hasRole('ADMIN') or hasRole('SUPERADMIN')")
|
||||
@RequestMapping("/users")
|
||||
public class UserController {
|
||||
|
||||
private UserDao repo;
|
||||
private RoleDao roleRepo;
|
||||
private MessageSource messageSource;
|
||||
private Sanitizer sanitizer;
|
||||
private PasswordEncoder passwordEncoder;
|
||||
private TranslationService translationService;
|
||||
|
||||
public UserController(UserDao repo, UserService userService, MessageSource messageSource, Sanitizer sanitizer,
|
||||
PasswordEncoder passwordEncoder, RoleDao roleRepo, TranslationService translationService) {
|
||||
this.repo = repo;
|
||||
this.messageSource = messageSource;
|
||||
this.sanitizer = sanitizer;
|
||||
this.roleRepo = roleRepo;
|
||||
this.passwordEncoder = passwordEncoder;
|
||||
this.translationService = translationService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String list(Model model, Authentication authentication, Locale locale) {
|
||||
|
||||
List<String> keys = List.of(
|
||||
"usuarios.delete.title",
|
||||
"usuarios.delete.text",
|
||||
"usuarios.eliminar",
|
||||
"usuarios.delete.button",
|
||||
"app.yes",
|
||||
"app.cancelar",
|
||||
"usuarios.delete.ok.title",
|
||||
"usuarios.delete.ok.text");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
|
||||
return "imprimelibros/users/users-list";
|
||||
}
|
||||
|
||||
// IMPORTANTE: asegúrate de que el controller es @RestController O anota el
|
||||
// método con @ResponseBody.
|
||||
@GetMapping(value = "/datatable", produces = "application/json")
|
||||
@ResponseBody
|
||||
public DataTablesResponse<Map<String, Object>> datatable(HttpServletRequest request, Authentication authentication,
|
||||
Locale locale) {
|
||||
|
||||
DataTablesRequest dt = DataTablesParser.from(request); //
|
||||
|
||||
// OJO: en la whitelist mete solo columnas "reales" y escalares (no relaciones).
|
||||
// Si 'role' es relación, sácalo de aquí:
|
||||
List<String> searchable = List.of("fullName", "userName", "enabled", "rolesConcat"); // <- busca por roles de
|
||||
// verdad
|
||||
List<String> orderable = List.of("id", "fullName", "userName", "enabled", "roleRank"); // <- permite ordenar por
|
||||
// estas
|
||||
// columnas
|
||||
|
||||
Specification<User> base = (root, query, cb) -> cb.conjunction();
|
||||
long total = repo.count();
|
||||
|
||||
return DataTable
|
||||
.of(repo, User.class, dt, searchable) // 'searchable' en DataTable.java
|
||||
// edita columnas "reales":
|
||||
.orderable(orderable)
|
||||
.edit("enabled", (User u) -> {
|
||||
if (u.isEnabled()) {
|
||||
return "<span class=\"badge bg-success\" >"
|
||||
+ messageSource.getMessage("usuarios.tabla.activo", null, locale) + "</span>";
|
||||
} else {
|
||||
return "<span class=\"badge bg-danger\" >"
|
||||
+ messageSource.getMessage("usuarios.tabla.inactivo", null, locale) + "</span>";
|
||||
}
|
||||
})
|
||||
// acciones virtuales:
|
||||
.add("roles", (User u) -> u.getRoles().stream()
|
||||
.map(Role::getName)
|
||||
.map(String::toLowerCase)
|
||||
.map(rol -> "<span class=\"badge bg-primary\">" +
|
||||
messageSource.getMessage("usuarios.rol." + rol, null, locale) + "</span>")
|
||||
.collect(Collectors.joining(" ")))
|
||||
.add("actions", (user) -> {
|
||||
|
||||
boolean isSuperAdmin = authentication.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals("ROLE_SUPERADMIN"));
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
|
||||
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
|
||||
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
|
||||
+
|
||||
" </div>";
|
||||
} else {
|
||||
// Admin editando otro admin o usuario normal: puede editarse y eliminarse
|
||||
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
|
||||
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
|
||||
+ "\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
|
||||
+
|
||||
" <a href=\"javascript:void(0);\" data-id=\"" + user.getId()
|
||||
+ "\" class=\"link-danger btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>\n"
|
||||
+
|
||||
" </div>";
|
||||
}
|
||||
})
|
||||
.where(base)
|
||||
// Filtros custom:
|
||||
.filter((builder, req) -> {
|
||||
// f_enabled: 'true' | 'false' | ''
|
||||
String fEnabled = Optional.ofNullable(req.raw.get("f_enabled")).orElse("").trim();
|
||||
if (!fEnabled.isEmpty()) {
|
||||
boolean enabledVal = Boolean.parseBoolean(fEnabled);
|
||||
builder.add((root, q, cb) -> cb.equal(root.get("enabled"), enabledVal));
|
||||
}
|
||||
|
||||
// f_role: 'USER' | 'ADMIN' | 'SUPERADMIN' | ''
|
||||
String fRole = Optional.ofNullable(req.raw.get("f_role")).orElse("").trim();
|
||||
if (!fRole.isEmpty()) {
|
||||
builder.add((root, q, cb) -> {
|
||||
// join a roles; marca la query como distinct para evitar duplicados
|
||||
var r = root.join("roles", jakarta.persistence.criteria.JoinType.LEFT);
|
||||
q.distinct(true);
|
||||
return cb.equal(r.get("name"), fRole);
|
||||
});
|
||||
}
|
||||
})
|
||||
.toJson(total);
|
||||
}
|
||||
|
||||
@GetMapping("form")
|
||||
public String getForm(@RequestParam(required = false) Long id,
|
||||
@ModelAttribute("user") UserForm form,
|
||||
BindingResult binding,
|
||||
Model model,
|
||||
HttpServletResponse response,
|
||||
Locale locale) {
|
||||
|
||||
if (id != null) {
|
||||
var opt = repo.findById(id);
|
||||
if (opt.isEmpty()) {
|
||||
binding.reject("usuarios.error.noEncontrado",
|
||||
messageSource.getMessage("usuarios.error.noEncontrado", null, locale));
|
||||
response.setStatus(404);
|
||||
model.addAttribute("action", "/users/" + id);
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
User u = opt.get();
|
||||
// map ENTIDAD -> DTO (¡no metas la entidad en "user"!)
|
||||
form.setId(u.getId());
|
||||
form.setFullName(u.getFullName());
|
||||
form.setUserName(u.getUserName());
|
||||
form.setEnabled(u.isEnabled());
|
||||
form.setRoleName(u.getRoles().stream().findFirst().map(Role::getName).orElse("USER"));
|
||||
form.setPassword(null);
|
||||
form.setConfirmPassword(null);
|
||||
|
||||
model.addAttribute("action", "/users/" + id);
|
||||
} else {
|
||||
// Crear: valores por defecto
|
||||
form.setEnabled(true);
|
||||
model.addAttribute("action", "/users");
|
||||
}
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String create(
|
||||
@Validated(UserForm.Create.class) @ModelAttribute("user") UserForm form,
|
||||
BindingResult binding,
|
||||
Model model,
|
||||
HttpServletResponse response,
|
||||
Locale locale) {
|
||||
|
||||
String normalized = sanitizer.plain(form.getUserName().trim());
|
||||
|
||||
if (repo.existsByUserNameIgnoreCase(normalized)) {
|
||||
binding.rejectValue("userName", "validation.unique",
|
||||
messageSource.getMessage("usuarios.error.duplicado", null, locale));
|
||||
}
|
||||
|
||||
var optRole = roleRepo.findRoleByName(form.getRoleName());
|
||||
if (optRole.isEmpty()) {
|
||||
binding.rejectValue("roleName", "usuarios.errores.rol.invalido",
|
||||
messageSource.getMessage("usuarios.error.rol", null, locale));
|
||||
}
|
||||
|
||||
if (binding.hasErrors()) {
|
||||
response.setStatus(422); // <- clave
|
||||
model.addAttribute("action", "/users");
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
User u = new User();
|
||||
u.setFullName(sanitizer.plain(form.getFullName()));
|
||||
u.setUserName(normalized.toLowerCase());
|
||||
u.setPassword(passwordEncoder.encode(form.getPassword()));
|
||||
java.util.Set<Role> roles = new java.util.HashSet<>();
|
||||
roles.add(optRole.get());
|
||||
u.setRoles(roles);
|
||||
u.setEnabled(Boolean.TRUE.equals(form.getEnabled()));
|
||||
try {
|
||||
repo.save(u);
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException ex) {
|
||||
// carrera contra otra inserción: vuelve como error de campo
|
||||
binding.rejectValue("userName", "validation.unique",
|
||||
messageSource.getMessage("usuarios.error.duplicado", null, locale));
|
||||
response.setStatus(422);
|
||||
model.addAttribute("action", "/users");
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
response.setStatus(204);
|
||||
return null;
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public String edit(
|
||||
@PathVariable Long id,
|
||||
@Validated(UserForm.Update.class) @ModelAttribute("user") UserForm 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));
|
||||
}
|
||||
|
||||
String normalized = sanitizer.plain(form.getUserName()).trim();
|
||||
if (repo.existsByUserNameIgnoreCaseAndIdNot(normalized, id)) {
|
||||
binding.rejectValue("userName", "validation.unique",
|
||||
messageSource.getMessage("usuarios.error.duplicado", null, locale));
|
||||
}
|
||||
|
||||
var optRole = roleRepo.findRoleByName(form.getRoleName());
|
||||
if (optRole.isEmpty()) {
|
||||
binding.rejectValue("roleName", "usuarios.errores.rol.invalido",
|
||||
messageSource.getMessage("usuarios.error.rol", null, locale));
|
||||
}
|
||||
|
||||
if (binding.hasErrors()) {
|
||||
response.setStatus(422);
|
||||
model.addAttribute("action", "/users/" + id);
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
var u = uOpt.get();
|
||||
u.setFullName(sanitizer.plain(form.getFullName()).trim());
|
||||
u.setUserName(normalized.toLowerCase());
|
||||
if (form.getPassword() != null && !form.getPassword().isBlank()) {
|
||||
u.setPassword(passwordEncoder.encode(form.getPassword()));
|
||||
}
|
||||
u.setRoles(new java.util.HashSet<>(java.util.List.of(optRole.get())));
|
||||
u.setEnabled(Boolean.TRUE.equals(form.getEnabled()));
|
||||
try {
|
||||
repo.save(u);
|
||||
} catch (org.springframework.dao.DataIntegrityViolationException ex) {
|
||||
binding.rejectValue("userName", "validation.unique",
|
||||
messageSource.getMessage("usuarios.error.duplicado", null, locale));
|
||||
response.setStatus(422);
|
||||
model.addAttribute("action", "/users/" + id);
|
||||
return "imprimelibros/users/user-form :: userForm";
|
||||
}
|
||||
|
||||
response.setStatus(204);
|
||||
return null;
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@Transactional
|
||||
public ResponseEntity<?> delete(@PathVariable Long id, Authentication auth, Locale locale) {
|
||||
return repo.findById(id).map(u -> {
|
||||
if (auth != null && u.getUserName().equalsIgnoreCase(auth.getName())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("message", messageSource.getMessage("usuarios.error.delete-self", null, locale)));
|
||||
}
|
||||
try {
|
||||
Long currentUserId = null;
|
||||
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
|
||||
currentUserId = udi.getId();
|
||||
} else if (auth != null) {
|
||||
currentUserId = repo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); // fallback
|
||||
}
|
||||
|
||||
u.setDeleted(true);
|
||||
u.setDeletedAt(LocalDateTime.now());
|
||||
u.setDeletedBy(currentUserId);
|
||||
|
||||
// Soft-delete de los vínculos (si usas cascade REMOVE + @SQLDelete en UserRole,
|
||||
// podrías omitir este foreach y dejar que JPA lo haga)
|
||||
u.getRolesLink().forEach(UserRole::softDelete);
|
||||
|
||||
repo.save(u); // ← NO delete(); guardamos el soft delete con deleted_by relleno
|
||||
return ResponseEntity.ok(Map.of("message",
|
||||
messageSource.getMessage("usuarios.exito.eliminado", null, locale)));
|
||||
} catch (Exception ex) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("message",
|
||||
messageSource.getMessage("usuarios.error.delete-internal-error", null, locale)));
|
||||
}
|
||||
}).orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("message", messageSource.getMessage("usuarios.error.not-found", null, locale))));
|
||||
}
|
||||
}
|
||||
57
src/main/java/com/imprimelibros/erp/users/UserDao.java
Normal file
57
src/main/java/com/imprimelibros/erp/users/UserDao.java
Normal file
@ -0,0 +1,57 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
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 org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.data.jpa.repository.EntityGraph;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.lang.Nullable;
|
||||
|
||||
@Repository
|
||||
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||
|
||||
// Aplicamos EntityGraph a la versión con Specification+Pageable
|
||||
@Override
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
@NonNull
|
||||
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
|
||||
|
||||
Optional<User> findByUserNameIgnoreCase(String userName);
|
||||
|
||||
boolean existsByUserNameIgnoreCase(String userName);
|
||||
|
||||
// Para comprobar si existe al hacer signup
|
||||
@Query(value = """
|
||||
SELECT id, deleted, enabled
|
||||
FROM users
|
||||
WHERE LOWER(username) = LOWER(:userName)
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
|
||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||
|
||||
// Nuevo: para login/negocio "activo"
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
|
||||
|
||||
// Para poder restaurar, necesitas leer ignorando @Where (native):
|
||||
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
|
||||
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
|
||||
|
||||
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
|
||||
List<User> findAllDeleted();
|
||||
|
||||
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
|
||||
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import org.springframework.security.core.GrantedAuthority;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
|
||||
/**
|
||||
* Adaptador de la entidad User a Spring Security.
|
||||
*/
|
||||
public class UserDetailsImpl implements UserDetails {
|
||||
|
||||
private final User user;
|
||||
private final java.util.Collection<? extends GrantedAuthority> authorities;
|
||||
|
||||
public UserDetailsImpl(User user) {
|
||||
this.user = user;
|
||||
this.authorities = user.getRoles().stream()
|
||||
.map(Role::getName)
|
||||
.filter(java.util.Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.map(String::toUpperCase)
|
||||
.map(name -> new SimpleGrantedAuthority("ROLE_" + name))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.util.Collection<? extends GrantedAuthority> getAuthorities() {
|
||||
return authorities; // no volvemos a tocar user.getRoles() fuera de sesión
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getPassword() {
|
||||
return user.getPassword(); // debe estar encriptado (BCrypt)
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getUsername() {
|
||||
return user.getUserName();
|
||||
}
|
||||
|
||||
public String getFullname() {
|
||||
|
||||
return user.getFullName();
|
||||
}
|
||||
|
||||
public String getRole() {
|
||||
return user.getRoles().stream()
|
||||
.map(r -> r.getName()) // "ADMIN", "USER", ...
|
||||
.findFirst()
|
||||
.orElse("-");
|
||||
}
|
||||
|
||||
/** (Opcional) Todos los roles “limpios” por si quieres listarlos. */
|
||||
public java.util.Set<String> getRoleNames() {
|
||||
return user.getRoles().stream()
|
||||
.map(r -> r.getName())
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonExpired() {
|
||||
return true; // puedes añadir lógica si quieres
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isAccountNonLocked() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCredentialsNonExpired() {
|
||||
return true; // igual que arriba
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEnabled() {
|
||||
return user.isEnabled() && !user.isDeleted();
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return user.getId();
|
||||
}
|
||||
|
||||
}
|
||||
9
src/main/java/com/imprimelibros/erp/users/UserLite.java
Normal file
9
src/main/java/com/imprimelibros/erp/users/UserLite.java
Normal file
@ -0,0 +1,9 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
// Proyección para consultas ligeras de usuarios (id, enabled, deleted)
|
||||
public interface UserLite {
|
||||
|
||||
Long getId();
|
||||
Boolean getDeleted();
|
||||
Boolean getEnabled();
|
||||
}
|
||||
121
src/main/java/com/imprimelibros/erp/users/UserRole.java
Normal file
121
src/main/java/com/imprimelibros/erp/users/UserRole.java
Normal file
@ -0,0 +1,121 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import org.hibernate.annotations.SQLDelete;
|
||||
import org.hibernate.annotations.SQLRestriction;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users_roles", uniqueConstraints = {
|
||||
@UniqueConstraint(name = "ux_users_roles_active", columnNames = { "user_id", "role_id", "deleted" })
|
||||
})
|
||||
@SQLDelete(sql = "UPDATE users_roles SET deleted = TRUE, deleted_at = NOW() WHERE id = ?")
|
||||
@SQLRestriction("deleted = false")
|
||||
public class UserRole {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
// FK a users
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "FK_users_roles_user"))
|
||||
private User user;
|
||||
|
||||
// FK a roles
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "role_id", nullable = false, foreignKey = @ForeignKey(name = "FK_users_roles_role"))
|
||||
private Role role;
|
||||
|
||||
@Column(nullable = false)
|
||||
private boolean deleted = false;
|
||||
|
||||
@Column(name = "deleted_at")
|
||||
private LocalDateTime deletedAt;
|
||||
|
||||
protected UserRole() {
|
||||
}
|
||||
|
||||
public UserRole(User user, Role role) {
|
||||
this.user = user;
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
/* ---- helpers ---- */
|
||||
public void softDelete() {
|
||||
this.deleted = true;
|
||||
this.deletedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o)
|
||||
return true;
|
||||
if (!(o instanceof UserRole other))
|
||||
return false;
|
||||
|
||||
Long u1 = this.getUser() != null ? this.getUser().getId() : null;
|
||||
Long r1 = this.getRole() != null ? this.getRole().getId() : null;
|
||||
Long u2 = other.getUser() != null ? other.getUser().getId() : null;
|
||||
Long r2 = other.getRole() != null ? other.getRole().getId() : null;
|
||||
|
||||
// igualdad por clave lógica (user_id, role_id) cuando existen
|
||||
if (u1 != null && r1 != null && u2 != null && r2 != null) {
|
||||
return u1.equals(u2) && r1.equals(r2);
|
||||
}
|
||||
// fallback: identidad por id si está asignado
|
||||
if (this.getId() != null && other.getId() != null) {
|
||||
return this.getId().equals(other.getId());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
Long u = this.getUser() != null ? this.getUser().getId() : null;
|
||||
Long r = this.getRole() != null ? this.getRole().getId() : null;
|
||||
if (u != null && r != null) {
|
||||
return java.util.Objects.hash(u, r);
|
||||
}
|
||||
return java.util.Objects.hash(getId());
|
||||
}
|
||||
|
||||
/* ---- getters/setters ---- */
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public User getUser() {
|
||||
return user;
|
||||
}
|
||||
|
||||
public void setUser(User user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public Role getRole() {
|
||||
return role;
|
||||
}
|
||||
|
||||
public void setRole(Role role) {
|
||||
this.role = role;
|
||||
}
|
||||
|
||||
public boolean isDeleted() {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
public void setDeleted(boolean deleted) {
|
||||
this.deleted = deleted;
|
||||
}
|
||||
|
||||
public LocalDateTime getDeletedAt() {
|
||||
return deletedAt;
|
||||
}
|
||||
|
||||
public void setDeletedAt(LocalDateTime deletedAt) {
|
||||
this.deletedAt = deletedAt;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
|
||||
public interface UserService extends UserDetailsService {
|
||||
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.imprimelibros.erp.users;
|
||||
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private UserDao userDao;
|
||||
|
||||
public UserServiceImpl(UserDao userDao) {
|
||||
this.userDao = userDao;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserDetails loadUserByUsername(String username) {
|
||||
User user = userDao.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(username)
|
||||
.orElseThrow(() -> new UsernameNotFoundException("No existe usuario activo: " + username));
|
||||
return new UserDetailsImpl(user);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
package com.imprimelibros.erp.users.validation;
|
||||
|
||||
import jakarta.validation.Constraint;
|
||||
import jakarta.validation.Payload;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import static java.lang.annotation.ElementType.TYPE;
|
||||
import static java.lang.annotation.RetentionPolicy.RUNTIME;
|
||||
|
||||
@Target(TYPE)
|
||||
@Retention(RUNTIME)
|
||||
@Constraint(validatedBy = PasswordsMatchValidator.class)
|
||||
public @interface PasswordsMatch {
|
||||
String message() default "{usuarios.error.password-coinciden}";
|
||||
Class<?>[] groups() default {};
|
||||
Class<? extends Payload>[] payload() default {};
|
||||
String password();
|
||||
String confirmPassword();
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package com.imprimelibros.erp.users.validation;
|
||||
|
||||
import jakarta.validation.ConstraintValidator;
|
||||
import jakarta.validation.ConstraintValidatorContext;
|
||||
import java.beans.PropertyDescriptor;
|
||||
|
||||
public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, Object> {
|
||||
private String passwordField;
|
||||
private String confirmPasswordField;
|
||||
|
||||
@Override
|
||||
public void initialize(PasswordsMatch constraint) {
|
||||
this.passwordField = constraint.password();
|
||||
this.confirmPasswordField = constraint.confirmPassword();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isValid(Object value, ConstraintValidatorContext context) {
|
||||
try {
|
||||
Object password = new PropertyDescriptor(passwordField, value.getClass())
|
||||
.getReadMethod().invoke(value);
|
||||
Object confirm = new PropertyDescriptor(confirmPasswordField, value.getClass())
|
||||
.getReadMethod().invoke(value);
|
||||
if (password == null && confirm == null) return true;
|
||||
return password != null && password.equals(confirm);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package com.imprimelibros.erp.users.validation;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* DTO del formulario de creación/edición de usuarios
|
||||
* (No ensucia la entidad y permite validaciones específicas de UI)
|
||||
*/
|
||||
@PasswordsMatch(password = "password", confirmPassword = "confirmPassword", groups = UserForm.Create.class)
|
||||
public class UserForm {
|
||||
|
||||
/** Grupos de validación */
|
||||
public interface Create {
|
||||
}
|
||||
|
||||
public interface Update {
|
||||
}
|
||||
|
||||
private Long id;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.nombre}", groups = { Create.class, Update.class })
|
||||
private String fullName;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.email}", groups = { Create.class, Update.class })
|
||||
@Email(message = "{usuarios.error.email.formato}", groups = { Create.class, Update.class })
|
||||
private String userName;
|
||||
|
||||
@NotBlank(message = "{usuarios.error.rol}", groups = { Create.class, Update.class })
|
||||
@Pattern(regexp = "USER|ADMIN|SUPERADMIN", message = "{usuarios.error.rol.invalido}", groups = { Create.class,
|
||||
Update.class })
|
||||
private String roleName;
|
||||
|
||||
// Obligatoria solo al crear
|
||||
@NotBlank(message = "{usuarios.error.password.requerida}", groups = Create.class)
|
||||
@Size(min = 6, message = "{usuarios.error.password.min}", groups = Create.class)
|
||||
private String password;
|
||||
|
||||
// Validada por @PasswordsMatch (y requerida al crear)
|
||||
@NotBlank(message = "{usuarios.error.confirmPassword.requerida}", groups = Create.class)
|
||||
private String confirmPassword;
|
||||
|
||||
@NotNull(groups = { Create.class, Update.class })
|
||||
private Boolean enabled;
|
||||
|
||||
// ===== Getters / Setters =====
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getFullName() {
|
||||
return fullName;
|
||||
}
|
||||
|
||||
public void setFullName(String fullName) {
|
||||
this.fullName = fullName;
|
||||
}
|
||||
|
||||
public String getUserName() {
|
||||
return userName;
|
||||
}
|
||||
|
||||
public void setUserName(String userName) {
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
public String getRoleName() {
|
||||
return roleName;
|
||||
}
|
||||
|
||||
public void setRoleName(String roleName) {
|
||||
this.roleName = roleName;
|
||||
}
|
||||
|
||||
public String getPassword() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public void setPassword(String password) {
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
public String getConfirmPassword() {
|
||||
return confirmPassword;
|
||||
}
|
||||
|
||||
public void setConfirmPassword(String confirmPassword) {
|
||||
this.confirmPassword = confirmPassword;
|
||||
}
|
||||
|
||||
public Boolean getEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(Boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,7 @@ spring.datasource.username=imprimelibros_user
|
||||
spring.datasource.password=om91irrDctd
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
#spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=true
|
||||
|
||||
|
||||
@ -47,3 +47,30 @@ spring.web.resources.chain.enabled=true
|
||||
spring.web.resources.chain.strategy.content.enabled=true
|
||||
spring.web.resources.chain.strategy.content.paths=/assets/**
|
||||
|
||||
|
||||
#
|
||||
# Session timeout
|
||||
#
|
||||
server.servlet.session.timeout=30m
|
||||
|
||||
security.rememberme.key=N`BY^YRVO:/\H$hsKxNq
|
||||
|
||||
#
|
||||
# Enable HiddenHttpMethodFilter to support PUT and DELETE methods in forms
|
||||
#
|
||||
spring.mvc.hiddenmethod.filter.enabled=true
|
||||
|
||||
#
|
||||
# Email
|
||||
#
|
||||
spring.mail.host=smtp.ionos.es
|
||||
spring.mail.port=587
|
||||
spring.mail.username=no-reply@imprimelibros.com
|
||||
spring.mail.password=%j4Su*#ZcjRDYsa$
|
||||
spring.mail.properties.mail.smtp.auth=true
|
||||
spring.mail.properties.mail.smtp.starttls.enable=true
|
||||
|
||||
#
|
||||
# Remove JSESSIONID from URL
|
||||
#
|
||||
server.servlet.session.persistent=false
|
||||
@ -6,4 +6,13 @@ app.cancelar=Cancelar
|
||||
app.guardar=Guardar
|
||||
app.editar=Editar
|
||||
app.eliminar=Eliminar
|
||||
app.imprimir=Imprimir
|
||||
app.imprimir=Imprimir
|
||||
|
||||
app.bienvenido=Bienvenido
|
||||
app.perfil=Perfil
|
||||
app.mensajes=Mensajes
|
||||
app.logout=Cerrar sesión
|
||||
|
||||
app.sidebar.inicio=Inicio
|
||||
app.sidebar.usuarios=Usuarios
|
||||
app.sidebar.configuracion=Configuración
|
||||
0
src/main/resources/i18n/auth_en.properties
Normal file
0
src/main/resources/i18n/auth_en.properties
Normal file
2
src/main/resources/i18n/auth_es.properties
Normal file
2
src/main/resources/i18n/auth_es.properties
Normal file
@ -0,0 +1,2 @@
|
||||
auth.reset.request.success=Si existe una cuenta asociada, se han enviado las instrucciones.
|
||||
auth.reset.form.passwordsMismatch=Las contraseñas no coinciden.
|
||||
1
src/main/resources/i18n/email_en.properties
Normal file
1
src/main/resources/i18n/email_en.properties
Normal file
@ -0,0 +1 @@
|
||||
|
||||
23
src/main/resources/i18n/email_es.properties
Normal file
23
src/main/resources/i18n/email_es.properties
Normal file
@ -0,0 +1,23 @@
|
||||
email.greeting=Hola
|
||||
email.verify.title=Verifica tu correo
|
||||
email.verify.body=Haz clic en el siguiente botón para verificar tu correo electrónico:
|
||||
email.verify.button=Verificar cuenta
|
||||
email.verify.link-instruction=Si no funciona, copia y pega esta URL en tu navegador:
|
||||
email.verify.expiration=Este enlace caduca en {0} minutos.
|
||||
email.verify.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje.
|
||||
|
||||
email.reset-password.title=Restablecer contraseña
|
||||
email.reset-password.body=Haz clic en el siguiente botón para restablecer tu contraseña:
|
||||
email.reset-password.button=Restablecer contraseña
|
||||
email.reset-password.link-instruction=Si no funciona, copia y pega esta URL en tu navegador:
|
||||
email.reset-password.expiration=Este enlace caduca en {0} minutos.
|
||||
email.reset-password.ignoreMessage=Si no solicitaste este cambio, puedes ignorar este mensaje.
|
||||
|
||||
email.reset.title=Restablecer tu contraseña
|
||||
email.reset.hello=Hola,
|
||||
email.reset.instructions=Has solicitado restablecer tu contraseña.
|
||||
email.reset.button=Restablecer contraseña
|
||||
email.reset.ignore=Si no solicitaste este cambio, puedes ignorar este correo.
|
||||
email.verify.expiration=Este enlace caduca en {0} minutos.
|
||||
|
||||
email.footer=Imprimelibros - Todos los derechos reservados.
|
||||
@ -1 +1,42 @@
|
||||
login.login=Iniciar sesión
|
||||
login.login=Iniciar sesión
|
||||
login.welcome=Bienvenido
|
||||
login.subtitle=Inicia sesión para continuar:
|
||||
login.signup-subtitle=Regístrate para continuar:
|
||||
login.email=Correo electrónico
|
||||
login.password=Contraseña
|
||||
login.confirm-password=Confirmar contraseña
|
||||
login.forgotPassword=¿Olvidaste tu contraseña?
|
||||
login.rememberMe=Recuérdame
|
||||
login.submit=Enviar
|
||||
login.error=Correo electrónico o contraseña incorrectos.
|
||||
login.slogan=imprimelibros.com<br>Especialistas en impresión de libros
|
||||
login.email-placeholder=Introduce tu correo electrónico
|
||||
login.password-placeholder=Introduce tu contraseña
|
||||
login.new-account=¿No tienes una cuenta?
|
||||
login.sign-up=Regístrate
|
||||
login.sign-up-button=Crear cuenta
|
||||
login.sign-up.title=Crear una cuenta
|
||||
login.sign-up.name=Nombre completo
|
||||
login.password-recovery.title=Recuperar contraseña
|
||||
login.password-recovery.button=Recuperar contraseña
|
||||
login.password-recovery.email-info=Se te ha enviado un correo con instrucciones para restablecer tu contraseña.
|
||||
login.change-password.title=Cambiar contraseña
|
||||
login.change-password.subtitle=Introduce tu nueva contraseña a continuación.
|
||||
login.change-password.new-password=Nueva contraseña
|
||||
login.change-password.confirm-password=Confirmar nueva contraseña
|
||||
login.change-password.button=Cambiar contraseña
|
||||
|
||||
login.error=Credenciales inválidas
|
||||
login.signup.error.email.exists=El correo electrónico ya está en uso.
|
||||
login.signup.error.email.exists-archived=El correo pertenece a una cuenta archivada. Por favor, contacta con soporte.
|
||||
login.signup.error.password.mismatch=Las contraseñas no coinciden.
|
||||
login.signup.error.review=Por favor, revisa el formulario.
|
||||
login.signup.error.token.invalid=Enlace inválido o caducado. Solicita uno nuevo.
|
||||
|
||||
login.password-reset.error=Las contraseñas no coinciden o son demasiado cortas.
|
||||
login.password-reset.error-link=El enlace no es válido o ha expirado.
|
||||
|
||||
login.signup.success=Cuenta creada con éxito. Por favor, revisa tu correo para activar tu cuenta.
|
||||
login.signup.success.verified=¡Cuenta verificada! Ya puedes iniciar sesión.
|
||||
|
||||
login.password-reset.success=Contraseña cambiada correctamente. Ya puedes iniciar sesión.
|
||||
@ -0,0 +1 @@
|
||||
|
||||
35
src/main/resources/i18n/margenesPresupuesto_es.properties
Normal file
35
src/main/resources/i18n/margenesPresupuesto_es.properties
Normal file
@ -0,0 +1,35 @@
|
||||
margenes-presupuesto.titulo=Márgenes de presupuesto
|
||||
margenes-presupuesto.breadcrumb=Márgenes de presupuesto
|
||||
margenes-presupuesto.add=Añadir
|
||||
margenes-presupuesto.nuevo=Nuevo margen
|
||||
margenes-presupuesto.editar=Editar margen
|
||||
margenes-presupuesto.eliminar=Eliminar
|
||||
|
||||
margenes-presupuesto.tabla.id=ID
|
||||
margenes-presupuesto.tabla.tipo_encuadernacion=Tipo encuadernación
|
||||
margenes-presupuesto.tabla.tipo_cubierta=Tipo cubierta
|
||||
margenes-presupuesto.tabla.tirada_minima=Tirada Mín.
|
||||
margenes-presupuesto.tabla.tirada_maxima=Tirada Máx.
|
||||
margenes-presupuesto.tabla.margen_minimo=Margen Mín.
|
||||
margenes-presupuesto.tabla.margen_maximo=Margen Máx.
|
||||
margenes-presupuesto.tabla.acciones=Acciones
|
||||
|
||||
margenes-presupuesto.form.tipo_encuadernacion=Tipo de encuadernación
|
||||
margenes-presupuesto.form.tipo_cubierta=Tipo de cubierta
|
||||
margenes-presupuesto.form.tirada_minima=Tirada mínima
|
||||
margenes-presupuesto.form.tirada_maxima=Tirada máxima
|
||||
margenes-presupuesto.form.margen_minimo=Margen mínimo (%)
|
||||
margenes-presupuesto.form.margen_maximo=Margen máximo (%)
|
||||
|
||||
margenes-presupuesto.todos=Todos
|
||||
|
||||
margenes-presupuesto.delete.title=Eliminar margen
|
||||
margenes-presupuesto.delete.button=Si, ELIMINAR
|
||||
margenes-presupuesto.delete.text=¿Está seguro de que desea eliminar este margen?<br>Esta acción no se puede deshacer.
|
||||
margenes-presupuesto.delete.ok.title=Margen eliminado
|
||||
margenes-presupuesto.delete.ok.text=El margen ha sido eliminado con éxito.
|
||||
|
||||
margenes-presupuesto.exito.eliminado=Margen eliminado con éxito.
|
||||
|
||||
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.
|
||||
@ -42,6 +42,7 @@ presupuesto.grapado-descripcion=Grapado (entre 12 y 40 páginas)
|
||||
presupuesto.espiral=Espiral
|
||||
presupuesto.espiral-descripcion=Espiral (a partir de 20 páginas)
|
||||
presupuesto.wire-o=Wire-O
|
||||
presupuesto.wireo=Wire-O
|
||||
presupuesto.wire-o-descripcion=Wire-O (a partir de 20 páginas)
|
||||
presupuesto.encuadernacion-descripcion=Seleccione la encuadernación del libro
|
||||
presupuesto.continuar-interior=Continuar a diseño interior
|
||||
@ -71,8 +72,11 @@ presupuesto.plantilla-cubierta-text=Recuerde que la cubierta es el conjunto form
|
||||
presupuesto.tipo-cubierta=Tipo de cubierta
|
||||
presupuesto.tipo-cubierta-descripcion=Seleccione el tipo de cubierta y sus opciones
|
||||
presupuesto.tapa-blanda=Tapa blanda
|
||||
presupuesto.tapaBlanda=Tapa blanda
|
||||
presupuesto.tapa-dura=Tapa dura
|
||||
presupuesto.tapaDura=Tapa dura
|
||||
presupuesto.tapa-dura-lomo-redondo=Tapa dura lomo redondo
|
||||
presupuesto.tapaDuraLomoRedondo=Tapa dura lomo redondo
|
||||
presupuesto.sin-solapas=Sin solapas
|
||||
presupuesto.con-solapas=Con solapas
|
||||
presupuesto.impresion-cubierta=Impresión de cubierta
|
||||
|
||||
1
src/main/resources/i18n/users_en.properties
Normal file
1
src/main/resources/i18n/users_en.properties
Normal file
@ -0,0 +1 @@
|
||||
|
||||
56
src/main/resources/i18n/users_es.properties
Normal file
56
src/main/resources/i18n/users_es.properties
Normal file
@ -0,0 +1,56 @@
|
||||
usuarios.titulo=Usuarios
|
||||
usuarios.nuevo=Nuevo usuario
|
||||
usuarios.editar=Editar usuario
|
||||
usuarios.add=Añadir usuario
|
||||
usuarios.eliminar=Eliminar usuario
|
||||
usuarios.confirmarEliminar=¿Está seguro de que desea eliminar este usuario?
|
||||
usuarios.guardar=Guardar
|
||||
usuarios.todos=Todos
|
||||
|
||||
usuarios.tabla.id=ID
|
||||
usuarios.tabla.nombre=Nombre
|
||||
usuarios.tabla.email=Correo electrónico
|
||||
usuarios.tabla.rol=Rol
|
||||
usuarios.tabla.estado=Estado
|
||||
usuarios.tabla.acciones=Acciones
|
||||
usuarios.tabla.activo=Activo
|
||||
usuarios.tabla.inactivo=Inactivo
|
||||
|
||||
usuarios.form.nombre=Nombre completo
|
||||
usuarios.form.email=Correo electrónico
|
||||
usuarios.form.password=Contraseña
|
||||
usuarios.form.confirmarPassword=Confirmar contraseña
|
||||
usuarios.form.rol=Rol
|
||||
usuarios.form.estado=Estado
|
||||
|
||||
usuarios.rol.user=Usuario
|
||||
usuarios.rol.admin=Administrador
|
||||
usuarios.rol.superadmin=Super Administrador
|
||||
|
||||
usuarios.error.duplicado=Ya existe un usuario con este correo electrónico.
|
||||
usuarios.error.general=Se ha producido un error al procesar la solicitud. Por favor, inténtelo de nuevo más tarde.
|
||||
usuarios.error.noEncontrado=Usuario no encontrado.
|
||||
|
||||
usuarios.error.nombre=El nombre es obligatorio.
|
||||
usuarios.error.email=El correo electrónico es obligatorio.
|
||||
usuarios.error.email.formato=El correo electrónico no es válido.
|
||||
usuarios.error.rol=El rol seleccionado no es válido.
|
||||
usuarios.error.password.requerida=La contraseña es obligatoria.
|
||||
usuarios.error.password.min=La contraseña debe tener al menos 6 caracteres.
|
||||
usuarios.error.confirmPassword.requerida=La confirmación de la contraseña es obligatoria.
|
||||
usuarios.error.password-coinciden=Las contraseñas no coinciden.
|
||||
usuarios.error.delete-relational-data=No se puede eliminar el usuario porque tiene datos relacionados.
|
||||
usuarios.error.delete-internal-error=No se puede eliminar: error interno.
|
||||
usuarios.error.delete-not-found=No se puede eliminar: usuario no encontrado.
|
||||
usuarios.error.delete-self=No se puede eliminar a sí mismo.
|
||||
|
||||
usuarios.error.no-activo=No existe un usuario activo con este correo electrónico.
|
||||
|
||||
usuarios.exito.creado=Usuario creado con éxito.
|
||||
usuarios.exito.actualizado=Usuario actualizado con éxito.
|
||||
usuarios.exito.eliminado=Usuario eliminado con éxito.
|
||||
usuarios.delete.title=Eliminar usuario
|
||||
usuarios.delete.button=Si, ELIMINAR
|
||||
usuarios.delete.text=¿Está seguro de que desea eliminar al usuario?<br>Esta acción no se puede deshacer.
|
||||
usuarios.delete.ok.title=Usuario eliminado
|
||||
usuarios.delete.ok.text=El usuario ha sido eliminado con éxito.
|
||||
@ -4,5 +4,9 @@ validation.min=El valor mínimo es {value}
|
||||
validation.max=El valor máximo es {value}
|
||||
validation.typeMismatchMsg=Tipo de dato no válido
|
||||
validation.patternMsg=El formato no es válido
|
||||
|
||||
|
||||
validation.unique=El valor ya existe y debe ser único
|
||||
validation.email=El correo electrónico no es válido
|
||||
validation.range.overlaps=El rango se solapa con otro existente.
|
||||
validation.range.invalid=El valor máximo debe ser mayor o igual que el mínimo.
|
||||
validation.range.invalid2=Rango no válido.
|
||||
validation.db=Error de base de datos: {message}
|
||||
|
||||
@ -3460,7 +3460,7 @@ File: Main Css File
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
opacity: 0.7;
|
||||
opacity: 0.4;
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
@ -12672,15 +12672,16 @@ span.flatpickr-weekday {
|
||||
}
|
||||
|
||||
.auth-one-bg {
|
||||
background-image: url("../images/auth-one-bg.jpg");
|
||||
/*background-image: url("../images/auth-one-bg.jpg");*/
|
||||
background-image: url("../images/img-auth.jpg");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
}
|
||||
.auth-one-bg .bg-overlay {
|
||||
/*.auth-one-bg .bg-overlay {
|
||||
background: -webkit-gradient(linear, left top, right top, from(#4a62fe), to(#687cfe));
|
||||
background: linear-gradient(to right, #4a62fe, #687cfe);
|
||||
opacity: 0.9;
|
||||
}
|
||||
}*/
|
||||
|
||||
.shape {
|
||||
position: absolute;
|
||||
@ -12725,15 +12726,18 @@ span.flatpickr-weekday {
|
||||
padding: 2px 16px;
|
||||
}
|
||||
|
||||
/* Imagen de fondo completa */
|
||||
.auth-bg-cover {
|
||||
background: linear-gradient(-45deg, #432874 50%, #984c0c);
|
||||
background: url("../images/imprimelibros/cover-login.svg") center center / cover no-repeat;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* El overlay que tapa la imagen */
|
||||
.auth-bg-cover > .bg-overlay {
|
||||
background-image: url("../images/cover-pattern.png");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
opacity: 1;
|
||||
background-color: transparent;
|
||||
/*background: none !important; /* quítalo si no quieres oscuridad */
|
||||
/* O bien hazlo más sutil, ejemplo: */
|
||||
background: rgba(0,0,0,0.10) !important;
|
||||
}
|
||||
.auth-bg-cover .footer {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
92
src/main/resources/static/assets/css/email.css
Normal file
92
src/main/resources/static/assets/css/email.css
Normal file
@ -0,0 +1,92 @@
|
||||
/* ===========================
|
||||
Email base
|
||||
=========================== */
|
||||
|
||||
/* Evita modo oscuro forzado */
|
||||
:root {
|
||||
color-scheme: only light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background-color: #f5f7fb !important;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Contenedor principal */
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
background-color: #f5f7fb !important;
|
||||
padding: 20px 10px;
|
||||
}
|
||||
|
||||
/* Cuerpo centrado */
|
||||
.email-body {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Tarjeta del correo */
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Encabezado */
|
||||
.email-header {
|
||||
background-color: #f0f0f0;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-header img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Contenido */
|
||||
.email-content {
|
||||
padding: 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.email-footer {
|
||||
background-color: #f9f9f9;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #777;
|
||||
}
|
||||
|
||||
.email-footer a {
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Botones */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 20px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
background-color: #2563eb;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
BIN
src/main/resources/static/assets/images/cover-bg-login.png
Normal file
BIN
src/main/resources/static/assets/images/cover-bg-login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 925 KiB |
BIN
src/main/resources/static/assets/images/img-auth.jpg
Normal file
BIN
src/main/resources/static/assets/images/img-auth.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1012 KiB |
@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 6860 4000">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: url(#Degradado_sin_nombre_5);
|
||||
}
|
||||
|
||||
.st0, .st1, .st2 {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: url(#Degradado_sin_nombre_50);
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: url(#Degradado_sin_nombre_46);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="Degradado_sin_nombre_5" data-name="Degradado sin nombre 5" x1="-671.7" y1="-3131.4" x2="7415.5" y2="2118.3" gradientTransform="translate(171.7 2310.8) scale(.9)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff"/>
|
||||
<stop offset="1" stop-color="#b5c5c6"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Degradado_sin_nombre_46" data-name="Degradado sin nombre 46" x1="408.2" y1="1897.1" x2="7495.3" y2="4618.6" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#b5c5c6"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="Degradado_sin_nombre_50" data-name="Degradado sin nombre 50" x1="2641.5" y1="2000" x2="6860" y2="2000" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#b5c5c6"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon class="st0" points="0 0 6860 0 6860 4000 0 4000 0 0"/>
|
||||
<g class="st3">
|
||||
<path class="st2" d="M6860,2115v1885H0c863.4-2588.5,4628.1-623.4,6860-1885h0Z"/>
|
||||
</g>
|
||||
<g class="st3">
|
||||
<path class="st1" d="M2641.5,0h4218.5v4000C5495.1,1201.7,3984.8,2928.1,2641.5,0Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
BIN
src/main/resources/static/assets/images/logo-light-email.png
Normal file
BIN
src/main/resources/static/assets/images/logo-light-email.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
@ -1,7 +1,7 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const DEFAULT_LANG = "es";
|
||||
const DEFAULT_LANG = "es-ES";
|
||||
|
||||
function getCurrentLang() {
|
||||
// Viene del servidor (Thymeleaf): <html th:lang="${#locale.language}">
|
||||
@ -11,7 +11,7 @@
|
||||
function setFlag(lang) {
|
||||
const img = document.getElementById("header-lang-img");
|
||||
if (!img) return;
|
||||
img.src = (lang === "en")
|
||||
img.src = (lang === "en-GB")
|
||||
? "/assets/images/flags/gb.svg"
|
||||
: "/assets/images/flags/spain.svg";
|
||||
}
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
(() => {
|
||||
// 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('#margenes-datatable', {
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
orderCellsTop: true,
|
||||
stateSave: true,
|
||||
pageLength: 50,
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
ajax: {
|
||||
url: '/configuracion/margenes-presupuesto/datatable',
|
||||
method: 'GET',
|
||||
data: function (d) {
|
||||
d.f_encuadernacion = $('#search-encuadernacion').val() || ''; // 'USER' | 'ADMIN' | 'SUPERADMIN' | ''
|
||||
d.f_cubierta = $('#search-cubierta').val() || ''; // 'true' | 'false' | ''
|
||||
}
|
||||
},
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'id', name: 'id', orderable: true },
|
||||
{ data: 'tipoEncuadernacion', name: 'tipoEncuadernacion', orderable: true },
|
||||
{ data: 'tipoCubierta', name: 'tipoCubierta', orderable: true },
|
||||
{ data: 'tiradaMin', name: 'tiradaMin', orderable: true },
|
||||
{ data: 'tiradaMax', name: 'tiradaMax', orderable: true },
|
||||
{ data: 'margenMax', name: 'margenMax', orderable: true },
|
||||
{ data: 'margenMin', name: 'margenMin', orderable: true },
|
||||
{ data: 'actions', name: 'actions' }
|
||||
],
|
||||
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
|
||||
});
|
||||
|
||||
table.on("keyup", ".margenes-presupuesto-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(this.value).draw();
|
||||
}
|
||||
});
|
||||
|
||||
table.on("change", ".margenes-presupuesto-select-filter", function () {
|
||||
table.draw();
|
||||
});
|
||||
|
||||
const modalEl = document.getElementById('margenesPresupuestoFormModal');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
|
||||
// Abrir "Crear"
|
||||
$('#addButton').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
$.get('/configuracion/margenes-presupuesto/form', function (html) {
|
||||
$('#margenesPresupuestoModalBody').html(html);
|
||||
const title = $('#margenesPresupuestoModalBody #margenesPresupuestoForm').data('add');
|
||||
$('#margenesPresupuestoModal .modal-title').text(title);
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Abrir "Editar"
|
||||
$(document).on('click', '.btn-edit-margen', 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(['margenes-presupuesto.delete.title']) || 'Eliminar margen',
|
||||
html: window.languageBundle.get(['margenes-presupuesto.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(['margenes-presupuesto.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>');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
@ -1,7 +1,9 @@
|
||||
window.languageBundle.get = function (key, ...params) {
|
||||
let text = this[key] || key;
|
||||
params.forEach((val, i) => {
|
||||
text = text.replace(`{${i}}`, val);
|
||||
});
|
||||
return text;
|
||||
if (window.languageBundle) {
|
||||
window.languageBundle.get = function (key, ...params) {
|
||||
let text = this[key] || key;
|
||||
params.forEach((val, i) => {
|
||||
text = text.replace(`{${i}}`, val);
|
||||
});
|
||||
return text;
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,163 @@
|
||||
$(() => {
|
||||
const language = document.documentElement.lang || 'es-ES';
|
||||
// CSRF global para AJAX
|
||||
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
|
||||
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
|
||||
if (csrfToken && csrfHeader) {
|
||||
$.ajaxSetup({
|
||||
beforeSend: function (xhr) {
|
||||
xhr.setRequestHeader(csrfHeader, csrfToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const table = new DataTable('#users-datatable', {
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
orderCellsTop: true,
|
||||
pageLength: 50,
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
ajax: {
|
||||
url: '/users/datatable',
|
||||
method: 'GET',
|
||||
data: function (d) {
|
||||
d.f_role = $('#search-role').val() || ''; // 'USER' | 'ADMIN' | 'SUPERADMIN' | ''
|
||||
d.f_enabled = $('#search-status').val() || ''; // 'true' | 'false' | ''
|
||||
}
|
||||
},
|
||||
order: [[0, 'asc']],
|
||||
columns: [
|
||||
{ data: 'id', name: 'id', orderable: true },
|
||||
{ data: 'fullName', name: 'fullName', orderable: true },
|
||||
{ data: 'userName', name: 'userName', orderable: true },
|
||||
{ data: 'roles', name: 'roleRank' },
|
||||
{ data: 'enabled', name: 'enabled', searchable: false },
|
||||
{ data: 'actions', name: 'actions' }
|
||||
],
|
||||
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
|
||||
});
|
||||
|
||||
table.on("keyup", ".user-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(this.value).draw();
|
||||
}
|
||||
});
|
||||
|
||||
table.on("change", ".user-filter-select", function() {
|
||||
table.draw();
|
||||
});
|
||||
|
||||
const modalEl = document.getElementById('userFormModal');
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
|
||||
|
||||
// Abrir "Crear"
|
||||
$('#addUserButton').on('click', (e) => {
|
||||
e.preventDefault();
|
||||
$.get('/users/form', function (html) {
|
||||
$('#userModalBody').html(html);
|
||||
const title = $('#userModalBody #userForm').data('add');
|
||||
$('#userFormModal .modal-title').text(title);
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Abrir "Editar"
|
||||
$(document).on('click', '.btn-edit-user', function (e) {
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
$.get('/users/form', { id }, function (html) {
|
||||
$('#userModalBody').html(html);
|
||||
const title = $('#userModalBody #userForm').data('edit');
|
||||
$('#userFormModal .modal-title').text(title);
|
||||
modal.show();
|
||||
});
|
||||
});
|
||||
|
||||
// Botón "Eliminar"
|
||||
$(document).on('click', '.btn-delete-user', function (e) {
|
||||
e.preventDefault();
|
||||
const id = $(this).data('id');
|
||||
|
||||
Swal.fire({
|
||||
title: window.languageBundle.get(['usuarios.delete.title']) || 'Eliminar usuario',
|
||||
html: window.languageBundle.get(['usuarios.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(['usuarios.delete.button']) || 'Eliminar',
|
||||
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
|
||||
}).then((result) => {
|
||||
if (!result.isConfirmed) return;
|
||||
|
||||
$.ajax({
|
||||
url: '/users/' + id,
|
||||
type: 'DELETE',
|
||||
success: function () {
|
||||
Swal.fire({
|
||||
icon: 'success', title: window.languageBundle.get(['usuarios.delete.ok.title']) || 'Eliminado',
|
||||
text: window.languageBundle.get(['usuarios.delete.ok.text']) || 'El usuario ha sido eliminado con éxito.',
|
||||
showConfirmButton: true,
|
||||
customClass: {
|
||||
confirmButton: 'btn btn-secondary w-xs mt-2',
|
||||
},
|
||||
});
|
||||
$('#users-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', '#userForm', 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="userForm"') !== -1 && html.indexOf('<html') === -1) {
|
||||
$('#userModalBody').html(html);
|
||||
const isEdit = $('#userModalBody #userForm input[name="_method"][value="PUT"]').length > 0;
|
||||
const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add');
|
||||
$('#userFormModal .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) {
|
||||
$('#userModalBody').html(xhr.responseText);
|
||||
const isEdit = $('#userModalBody #userForm input[name="_method"][value="PUT"]').length > 0;
|
||||
const title = $('#userModalBody #userForm').data(isEdit ? 'edit' : 'add');
|
||||
$('#userFormModal .modal-title').text(title);
|
||||
return;
|
||||
}
|
||||
// Fallback
|
||||
$('#userModalBody').html('<div class="p-3 text-danger">Error inesperado.</div>');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
5
src/main/resources/static/assets/libs/datatables/dataTables.bootstrap5.min.css
vendored
Normal file
5
src/main/resources/static/assets/libs/datatables/dataTables.bootstrap5.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
src/main/resources/static/assets/libs/datatables/dataTables.bootstrap5.min.js
vendored
Normal file
4
src/main/resources/static/assets/libs/datatables/dataTables.bootstrap5.min.js
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
/*! DataTables Bootstrap 5 integration
|
||||
* © SpryMedia Ltd - datatables.net/license
|
||||
*/
|
||||
!function(n){var o,a;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(e){return n(e,window,document)}):"object"==typeof exports?(o=require("jquery"),a=function(e,t){t.fn.dataTable||require("datatables.net")(e,t)},"undefined"==typeof window?module.exports=function(e,t){return e=e||window,t=t||o(e),a(e,t),n(t,0,e.document)}:(a(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(d,e,t){"use strict";var n=d.fn.dataTable;return d.extend(!0,n.defaults,{renderer:"bootstrap"}),d.extend(!0,n.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"}}),n.ext.renderer.pagingButton.bootstrap=function(e,t,n,o,a){var r=["dt-paging-button","page-item"],o=(o&&r.push("active"),a&&r.push("disabled"),d("<li>").addClass(r.join(" ")));return{display:o,clicker:d("<a>",{href:a?null:"#",class:"page-link"}).html(n).appendTo(o)}},n.ext.renderer.pagingContainer.bootstrap=function(e,t){return d("<ul/>").addClass("pagination").append(t)},n.ext.renderer.layout.bootstrap=function(e,t,n){var o=d("<div/>",{class:n.full?"row mt-2 justify-content-md-center":"row mt-2 justify-content-between"}).appendTo(t);d.each(n,function(e,t){e=t.table?"col-12":"start"===e?"col-md-auto me-auto":"end"===e?"col-md-auto ms-auto":"col-md";d("<div/>",{id:t.id||null,class:e+" "+(t.className||"")}).append(t.contents).appendTo(o)})},n});
|
||||
624
src/main/resources/static/assets/libs/datatables/datatables.css
Normal file
624
src/main/resources/static/assets/libs/datatables/datatables.css
Normal file
@ -0,0 +1,624 @@
|
||||
/*
|
||||
* This combined file was created by the DataTables downloader builder:
|
||||
* https://datatables.net/download
|
||||
*
|
||||
* To rebuild or modify this file with the latest versions of the included
|
||||
* software please visit:
|
||||
* https://datatables.net/download/#bs5/jq-3.7.0/dt-2.3.4
|
||||
*
|
||||
* Included libraries:
|
||||
* jQuery 3.7.0, DataTables 2.3.4
|
||||
*/
|
||||
|
||||
:root {
|
||||
--dt-row-selected: 13, 110, 253;
|
||||
--dt-row-selected-text: 255, 255, 255;
|
||||
--dt-row-selected-link: 228, 228, 228;
|
||||
--dt-row-stripe: 0, 0, 0;
|
||||
--dt-row-hover: 0, 0, 0;
|
||||
--dt-column-ordering: 0, 0, 0;
|
||||
--dt-header-align-items: center;
|
||||
--dt-header-vertical-align: middle;
|
||||
--dt-html-background: white;
|
||||
}
|
||||
:root.dark {
|
||||
--dt-html-background: rgb(33, 37, 41);
|
||||
}
|
||||
|
||||
table.dataTable tbody td.dt-control {
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
table.dataTable tbody td.dt-control:before {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
content: "";
|
||||
border-top: 5px solid transparent;
|
||||
border-left: 10px solid rgba(0, 0, 0, 0.5);
|
||||
border-bottom: 5px solid transparent;
|
||||
border-right: 0px solid transparent;
|
||||
}
|
||||
table.dataTable tbody tr.dt-hasChild td.dt-control:before {
|
||||
border-top: 10px solid rgba(0, 0, 0, 0.5);
|
||||
border-left: 5px solid transparent;
|
||||
border-bottom: 0px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
}
|
||||
table.dataTable tfoot:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
html.dark table.dataTable td.dt-control:before,
|
||||
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
|
||||
:root[data-theme=dark] table.dataTable td.dt-control:before {
|
||||
border-left-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
|
||||
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
|
||||
border-top-color: rgba(255, 255, 255, 0.5);
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
div.dt-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
div.dt-scroll-body thead tr,
|
||||
div.dt-scroll-body tfoot tr {
|
||||
height: 0;
|
||||
}
|
||||
div.dt-scroll-body thead tr th, div.dt-scroll-body thead tr td,
|
||||
div.dt-scroll-body tfoot tr th,
|
||||
div.dt-scroll-body tfoot tr td {
|
||||
height: 0 !important;
|
||||
padding-top: 0px !important;
|
||||
padding-bottom: 0px !important;
|
||||
border-top-width: 0px !important;
|
||||
border-bottom-width: 0px !important;
|
||||
}
|
||||
div.dt-scroll-body thead tr th div.dt-scroll-sizing, div.dt-scroll-body thead tr td div.dt-scroll-sizing,
|
||||
div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,
|
||||
div.dt-scroll-body tfoot tr td div.dt-scroll-sizing {
|
||||
height: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
table.dataTable thead > tr > th:active,
|
||||
table.dataTable thead > tr > td:active {
|
||||
outline: none;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before {
|
||||
position: absolute;
|
||||
display: block;
|
||||
bottom: 50%;
|
||||
content: "\25B2";
|
||||
content: "\25B2"/"";
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 50%;
|
||||
content: "\25BC";
|
||||
content: "\25BC"/"";
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order,
|
||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order,
|
||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 24px;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-orderable-desc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:after, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
||||
left: 0;
|
||||
opacity: 0.125;
|
||||
line-height: 9px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-asc, table.dataTable thead > tr > th.dt-orderable-desc,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc {
|
||||
cursor: pointer;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-asc:hover, table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
||||
table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
||||
table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
||||
outline: 2px solid rgba(0, 0, 0, 0.05);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-ordering-asc span.dt-column-order:before, table.dataTable thead > tr > th.dt-ordering-desc span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.dt-ordering-asc span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-ordering-desc span.dt-column-order:after {
|
||||
opacity: 0.6;
|
||||
}
|
||||
table.dataTable thead > tr > th.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) span.dt-column-order:empty, table.dataTable thead > tr > th.sorting_desc_disabled span.dt-column-order:after, table.dataTable thead > tr > th.sorting_asc_disabled span.dt-column-order:before,
|
||||
table.dataTable thead > tr > td.dt-orderable-none:not(.dt-ordering-asc, .dt-ordering-desc) span.dt-column-order:empty,
|
||||
table.dataTable thead > tr > td.sorting_desc_disabled span.dt-column-order:after,
|
||||
table.dataTable thead > tr > td.sorting_asc_disabled span.dt-column-order:before {
|
||||
display: none;
|
||||
}
|
||||
table.dataTable thead > tr > th:active,
|
||||
table.dataTable thead > tr > td:active {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
table.dataTable thead > tr > th div.dt-column-header,
|
||||
table.dataTable thead > tr > th div.dt-column-footer,
|
||||
table.dataTable thead > tr > td div.dt-column-header,
|
||||
table.dataTable thead > tr > td div.dt-column-footer,
|
||||
table.dataTable tfoot > tr > th div.dt-column-header,
|
||||
table.dataTable tfoot > tr > th div.dt-column-footer,
|
||||
table.dataTable tfoot > tr > td div.dt-column-header,
|
||||
table.dataTable tfoot > tr > td div.dt-column-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: var(--dt-header-align-items);
|
||||
gap: 4px;
|
||||
}
|
||||
table.dataTable thead > tr > th div.dt-column-header span.dt-column-title,
|
||||
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title,
|
||||
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title,
|
||||
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title,
|
||||
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title,
|
||||
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title,
|
||||
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title,
|
||||
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title {
|
||||
flex-grow: 1;
|
||||
}
|
||||
table.dataTable thead > tr > th div.dt-column-header span.dt-column-title:empty,
|
||||
table.dataTable thead > tr > th div.dt-column-footer span.dt-column-title:empty,
|
||||
table.dataTable thead > tr > td div.dt-column-header span.dt-column-title:empty,
|
||||
table.dataTable thead > tr > td div.dt-column-footer span.dt-column-title:empty,
|
||||
table.dataTable tfoot > tr > th div.dt-column-header span.dt-column-title:empty,
|
||||
table.dataTable tfoot > tr > th div.dt-column-footer span.dt-column-title:empty,
|
||||
table.dataTable tfoot > tr > td div.dt-column-header span.dt-column-title:empty,
|
||||
table.dataTable tfoot > tr > td div.dt-column-footer span.dt-column-title:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
div.dt-scroll-body > table.dataTable > thead > tr > th,
|
||||
div.dt-scroll-body > table.dataTable > thead > tr > td {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:root.dark table.dataTable thead > tr > th.dt-orderable-asc:hover, :root.dark table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
||||
:root.dark table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
||||
:root.dark table.dataTable thead > tr > td.dt-orderable-desc:hover,
|
||||
:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-asc:hover,
|
||||
:root[data-bs-theme=dark] table.dataTable thead > tr > th.dt-orderable-desc:hover,
|
||||
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-asc:hover,
|
||||
:root[data-bs-theme=dark] table.dataTable thead > tr > td.dt-orderable-desc:hover {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
div.dt-processing {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 200px;
|
||||
margin-left: -100px;
|
||||
margin-top: -22px;
|
||||
text-align: center;
|
||||
padding: 2px;
|
||||
z-index: 10;
|
||||
}
|
||||
div.dt-processing > div:last-child {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 15px;
|
||||
margin: 1em auto;
|
||||
}
|
||||
div.dt-processing > div:last-child > div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: rgb(13, 110, 253);
|
||||
background: rgb(var(--dt-row-selected));
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
div.dt-processing > div:last-child > div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: datatables-loader-1 0.6s infinite;
|
||||
}
|
||||
div.dt-processing > div:last-child > div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: datatables-loader-2 0.6s infinite;
|
||||
}
|
||||
div.dt-processing > div:last-child > div:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: datatables-loader-2 0.6s infinite;
|
||||
}
|
||||
div.dt-processing > div:last-child > div:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: datatables-loader-3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes datatables-loader-1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
@keyframes datatables-loader-3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes datatables-loader-2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
||||
table.dataTable.nowrap th, table.dataTable.nowrap td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.dataTable th,
|
||||
table.dataTable td {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
table.dataTable th.dt-type-numeric, table.dataTable th.dt-type-date,
|
||||
table.dataTable td.dt-type-numeric,
|
||||
table.dataTable td.dt-type-date {
|
||||
text-align: right;
|
||||
}
|
||||
table.dataTable th.dt-type-numeric div.dt-column-header,
|
||||
table.dataTable th.dt-type-numeric div.dt-column-footer, table.dataTable th.dt-type-date div.dt-column-header,
|
||||
table.dataTable th.dt-type-date div.dt-column-footer,
|
||||
table.dataTable td.dt-type-numeric div.dt-column-header,
|
||||
table.dataTable td.dt-type-numeric div.dt-column-footer,
|
||||
table.dataTable td.dt-type-date div.dt-column-header,
|
||||
table.dataTable td.dt-type-date div.dt-column-footer {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
table.dataTable th.dt-left,
|
||||
table.dataTable td.dt-left {
|
||||
text-align: left;
|
||||
}
|
||||
table.dataTable th.dt-left div.dt-column-header,
|
||||
table.dataTable th.dt-left div.dt-column-footer,
|
||||
table.dataTable td.dt-left div.dt-column-header,
|
||||
table.dataTable td.dt-left div.dt-column-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
table.dataTable th.dt-center,
|
||||
table.dataTable td.dt-center {
|
||||
text-align: center;
|
||||
}
|
||||
table.dataTable th.dt-right,
|
||||
table.dataTable td.dt-right {
|
||||
text-align: right;
|
||||
}
|
||||
table.dataTable th.dt-right div.dt-column-header,
|
||||
table.dataTable th.dt-right div.dt-column-footer,
|
||||
table.dataTable td.dt-right div.dt-column-header,
|
||||
table.dataTable td.dt-right div.dt-column-footer {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
table.dataTable th.dt-justify,
|
||||
table.dataTable td.dt-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
table.dataTable th.dt-justify div.dt-column-header,
|
||||
table.dataTable th.dt-justify div.dt-column-footer,
|
||||
table.dataTable td.dt-justify div.dt-column-header,
|
||||
table.dataTable td.dt-justify div.dt-column-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
table.dataTable th.dt-nowrap,
|
||||
table.dataTable td.dt-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.dataTable th.dt-empty,
|
||||
table.dataTable td.dt-empty {
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.dataTable thead th,
|
||||
table.dataTable thead td,
|
||||
table.dataTable tfoot th,
|
||||
table.dataTable tfoot td {
|
||||
text-align: left;
|
||||
vertical-align: var(--dt-header-vertical-align);
|
||||
}
|
||||
table.dataTable thead th.dt-head-left,
|
||||
table.dataTable thead td.dt-head-left,
|
||||
table.dataTable tfoot th.dt-head-left,
|
||||
table.dataTable tfoot td.dt-head-left {
|
||||
text-align: left;
|
||||
}
|
||||
table.dataTable thead th.dt-head-left div.dt-column-header,
|
||||
table.dataTable thead th.dt-head-left div.dt-column-footer,
|
||||
table.dataTable thead td.dt-head-left div.dt-column-header,
|
||||
table.dataTable thead td.dt-head-left div.dt-column-footer,
|
||||
table.dataTable tfoot th.dt-head-left div.dt-column-header,
|
||||
table.dataTable tfoot th.dt-head-left div.dt-column-footer,
|
||||
table.dataTable tfoot td.dt-head-left div.dt-column-header,
|
||||
table.dataTable tfoot td.dt-head-left div.dt-column-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
table.dataTable thead th.dt-head-center,
|
||||
table.dataTable thead td.dt-head-center,
|
||||
table.dataTable tfoot th.dt-head-center,
|
||||
table.dataTable tfoot td.dt-head-center {
|
||||
text-align: center;
|
||||
}
|
||||
table.dataTable thead th.dt-head-right,
|
||||
table.dataTable thead td.dt-head-right,
|
||||
table.dataTable tfoot th.dt-head-right,
|
||||
table.dataTable tfoot td.dt-head-right {
|
||||
text-align: right;
|
||||
}
|
||||
table.dataTable thead th.dt-head-right div.dt-column-header,
|
||||
table.dataTable thead th.dt-head-right div.dt-column-footer,
|
||||
table.dataTable thead td.dt-head-right div.dt-column-header,
|
||||
table.dataTable thead td.dt-head-right div.dt-column-footer,
|
||||
table.dataTable tfoot th.dt-head-right div.dt-column-header,
|
||||
table.dataTable tfoot th.dt-head-right div.dt-column-footer,
|
||||
table.dataTable tfoot td.dt-head-right div.dt-column-header,
|
||||
table.dataTable tfoot td.dt-head-right div.dt-column-footer {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
table.dataTable thead th.dt-head-justify,
|
||||
table.dataTable thead td.dt-head-justify,
|
||||
table.dataTable tfoot th.dt-head-justify,
|
||||
table.dataTable tfoot td.dt-head-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
table.dataTable thead th.dt-head-justify div.dt-column-header,
|
||||
table.dataTable thead th.dt-head-justify div.dt-column-footer,
|
||||
table.dataTable thead td.dt-head-justify div.dt-column-header,
|
||||
table.dataTable thead td.dt-head-justify div.dt-column-footer,
|
||||
table.dataTable tfoot th.dt-head-justify div.dt-column-header,
|
||||
table.dataTable tfoot th.dt-head-justify div.dt-column-footer,
|
||||
table.dataTable tfoot td.dt-head-justify div.dt-column-header,
|
||||
table.dataTable tfoot td.dt-head-justify div.dt-column-footer {
|
||||
flex-direction: row;
|
||||
}
|
||||
table.dataTable thead th.dt-head-nowrap,
|
||||
table.dataTable thead td.dt-head-nowrap,
|
||||
table.dataTable tfoot th.dt-head-nowrap,
|
||||
table.dataTable tfoot td.dt-head-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.dataTable tbody th.dt-body-left,
|
||||
table.dataTable tbody td.dt-body-left {
|
||||
text-align: left;
|
||||
}
|
||||
table.dataTable tbody th.dt-body-center,
|
||||
table.dataTable tbody td.dt-body-center {
|
||||
text-align: center;
|
||||
}
|
||||
table.dataTable tbody th.dt-body-right,
|
||||
table.dataTable tbody td.dt-body-right {
|
||||
text-align: right;
|
||||
}
|
||||
table.dataTable tbody th.dt-body-justify,
|
||||
table.dataTable tbody td.dt-body-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
table.dataTable tbody th.dt-body-nowrap,
|
||||
table.dataTable tbody td.dt-body-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/*! Bootstrap 5 integration for DataTables
|
||||
*
|
||||
* ©2020 SpryMedia Ltd, all rights reserved.
|
||||
* License: MIT datatables.net/license/mit
|
||||
*/
|
||||
table.table.dataTable {
|
||||
clear: both;
|
||||
margin-bottom: 0;
|
||||
max-width: none;
|
||||
border-spacing: 0;
|
||||
}
|
||||
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
|
||||
box-shadow: none;
|
||||
}
|
||||
table.table.dataTable > :not(caption) > * > * {
|
||||
background-color: var(--bs-table-bg);
|
||||
}
|
||||
table.table.dataTable > tbody > tr {
|
||||
background-color: transparent;
|
||||
}
|
||||
table.table.dataTable > tbody > tr.selected > * {
|
||||
box-shadow: inset 0 0 0 9999px rgb(13, 110, 253);
|
||||
box-shadow: inset 0 0 0 9999px rgb(var(--dt-row-selected));
|
||||
color: rgb(255, 255, 255);
|
||||
color: rgb(var(--dt-row-selected-text));
|
||||
}
|
||||
table.table.dataTable > tbody > tr.selected a {
|
||||
color: rgb(228, 228, 228);
|
||||
color: rgb(var(--dt-row-selected-link));
|
||||
}
|
||||
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1) > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05);
|
||||
}
|
||||
table.table.dataTable.table-striped > tbody > tr:nth-of-type(2n+1).selected > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.95);
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95);
|
||||
}
|
||||
table.table.dataTable.table-hover > tbody > tr:hover > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075);
|
||||
}
|
||||
table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
|
||||
box-shadow: inset 0 0 0 9999px rgba(13, 110, 253, 0.975);
|
||||
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
|
||||
}
|
||||
|
||||
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
||||
margin-right: 1em;
|
||||
}
|
||||
div.dt-container div.dt-layout-end > *:not(:first-child) {
|
||||
margin-left: 1em;
|
||||
}
|
||||
div.dt-container div.dt-layout-full {
|
||||
width: 100%;
|
||||
}
|
||||
div.dt-container div.dt-layout-full > *:only-child {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
div.dt-container div.dt-layout-table > div {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
div.dt-container div.dt-layout-start > *:not(:last-child) {
|
||||
margin-right: 0;
|
||||
}
|
||||
div.dt-container div.dt-layout-end > *:not(:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
div.dt-container {
|
||||
position: relative;
|
||||
}
|
||||
div.dt-container div.dt-length label {
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
div.dt-container div.dt-length select {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
div.dt-container div.dt-search {
|
||||
text-align: right;
|
||||
}
|
||||
div.dt-container div.dt-search label {
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
div.dt-container div.dt-search input {
|
||||
margin-left: 0.5em;
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
}
|
||||
div.dt-container div.dt-paging {
|
||||
margin: 0;
|
||||
}
|
||||
div.dt-container div.dt-paging ul.pagination {
|
||||
margin: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
div.dt-container div.dt-row {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
div.dt-scroll-head table.dataTable {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
div.dt-scroll-body {
|
||||
border-bottom-color: var(--bs-border-color);
|
||||
border-bottom-width: var(--bs-border-width);
|
||||
border-bottom-style: solid;
|
||||
}
|
||||
div.dt-scroll-body > table {
|
||||
border-top: none;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
div.dt-scroll-body > table > tbody > tr:first-child {
|
||||
border-top-width: 0;
|
||||
}
|
||||
div.dt-scroll-body > table > thead > tr {
|
||||
border-width: 0 !important;
|
||||
}
|
||||
div.dt-scroll-body > table > tbody > tr:last-child > * {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
div.dt-scroll-foot > .dt-scroll-footInner {
|
||||
box-sizing: content-box;
|
||||
}
|
||||
div.dt-scroll-foot > .dt-scroll-footInner > table {
|
||||
margin-top: 0 !important;
|
||||
border-top: none;
|
||||
}
|
||||
div.dt-scroll-foot > .dt-scroll-footInner > table > tfoot > tr:first-child {
|
||||
border-top-width: 0 !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 767px) {
|
||||
div.dt-container div.dt-length,
|
||||
div.dt-container div.dt-search,
|
||||
div.dt-container div.dt-info,
|
||||
div.dt-container div.dt-paging {
|
||||
text-align: center;
|
||||
}
|
||||
div.dt-container .row {
|
||||
--bs-gutter-y: 0.5rem;
|
||||
}
|
||||
div.dt-container div.dt-paging ul.pagination {
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
table.dataTable.table-sm > thead > tr th.dt-orderable-asc, table.dataTable.table-sm > thead > tr th.dt-orderable-desc, table.dataTable.table-sm > thead > tr th.dt-ordering-asc, table.dataTable.table-sm > thead > tr th.dt-ordering-desc,
|
||||
table.dataTable.table-sm > thead > tr td.dt-orderable-asc,
|
||||
table.dataTable.table-sm > thead > tr td.dt-orderable-desc,
|
||||
table.dataTable.table-sm > thead > tr td.dt-ordering-asc,
|
||||
table.dataTable.table-sm > thead > tr td.dt-ordering-desc {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
table.dataTable.table-sm > thead > tr th.dt-orderable-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-orderable-desc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-asc span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-ordering-desc span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-orderable-asc span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-orderable-desc span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-ordering-asc span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-ordering-desc span.dt-column-order {
|
||||
right: 0.25rem;
|
||||
}
|
||||
table.dataTable.table-sm > thead > tr th.dt-type-date span.dt-column-order, table.dataTable.table-sm > thead > tr th.dt-type-numeric span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-type-date span.dt-column-order,
|
||||
table.dataTable.table-sm > thead > tr td.dt-type-numeric span.dt-column-order {
|
||||
left: 0.25rem;
|
||||
}
|
||||
|
||||
div.dt-scroll-head table.table-bordered {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
div.table-responsive > div.dt-container > div.row {
|
||||
margin: 0;
|
||||
}
|
||||
div.table-responsive > div.dt-container > div.row > div[class^=col-]:first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
div.table-responsive > div.dt-container > div.row > div[class^=col-]:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
:root[data-bs-theme=dark] {
|
||||
--dt-row-hover: 255, 255, 255;
|
||||
--dt-row-stripe: 255, 255, 255;
|
||||
--dt-column-ordering: 255, 255, 255;
|
||||
}
|
||||
|
||||
|
||||
24907
src/main/resources/static/assets/libs/datatables/datatables.js
Normal file
24907
src/main/resources/static/assets/libs/datatables/datatables.js
Normal file
File diff suppressed because it is too large
Load Diff
15
src/main/resources/static/assets/libs/datatables/datatables.min.css
vendored
Normal file
15
src/main/resources/static/assets/libs/datatables/datatables.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
22
src/main/resources/static/assets/libs/datatables/datatables.min.js
vendored
Normal file
22
src/main/resources/static/assets/libs/datatables/datatables.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
275
src/main/resources/static/assets/libs/datatables/i18n/de-DE.json
Normal file
275
src/main/resources/static/assets/libs/datatables/i18n/de-DE.json
Normal file
@ -0,0 +1,275 @@
|
||||
{
|
||||
"aria": {
|
||||
"paginate": {
|
||||
"first": "Erste",
|
||||
"last": "Letzte",
|
||||
"next": "Nächste",
|
||||
"previous": "Vorherige"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Abbrechen",
|
||||
"fill": "Alle Zellen mit <i>%d<i> füllen<\/i><\/i>",
|
||||
"fillHorizontal": "Alle horizontalen Zellen füllen",
|
||||
"fillVertical": "Alle vertikalen Zellen füllen",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Aktionen <span class=\"ui-button-icon-primary ui-icon ui-icon-triangle-1-s\"><\/span>",
|
||||
"colvis": "Spaltensichtbarkeit",
|
||||
"colvisRestore": "Sichtbarkeit wiederherstellen",
|
||||
"copy": "Kopieren",
|
||||
"copyKeys": "Taste <i>STRG<\\\/i> oder <i>⌘<\\\/i> + <i>C<\\\/i> drücken um die Tabelle<br \/>in den Zwischenspeicher zu kopieren.<br \/><br \/>Um den Vorgang abzubrechen, Nachricht anklicken oder Escape-Taste drücken.<\/i><\/i><\/i>",
|
||||
"copySuccess": {
|
||||
"_": "%d Zeilen kopiert",
|
||||
"1": "1 Zeile kopiert"
|
||||
},
|
||||
"copyTitle": "In Zwischenablage kopieren",
|
||||
"createState": "Ansicht erstellen",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Zeige %d Zeilen",
|
||||
"-1": "Alle Zeilen anzeigen",
|
||||
"1": "Zeigt 1 Zeile"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Drucken",
|
||||
"removeAllStates": "Alle Ansichten entfernen",
|
||||
"removeState": "Entfernen",
|
||||
"renameState": "Umbenennen",
|
||||
"savedStates": "Gespeicherte Ansicht",
|
||||
"stateRestore": "Ansicht %d",
|
||||
"updateState": "Aktualisieren"
|
||||
},
|
||||
"columnControl": {
|
||||
"colVis": "Sichtbarkeit der Spalte",
|
||||
"colVisDropdown": "Sichtbarkeit der Spalte",
|
||||
"dropdown": "Mehr...",
|
||||
"list": {
|
||||
"all": "Alle auswählen",
|
||||
"none": "Nichts auswählen",
|
||||
"search": "Suche..."
|
||||
},
|
||||
"orderAddAsc": "Aufsteigende Sortierung hinzufügen",
|
||||
"orderAddDesc": "Absteigende Sortierung hinzufügen",
|
||||
"orderAsc": "Aufsteigend sortieren",
|
||||
"orderDesc": "Absteigend sortieren",
|
||||
"orderRemove": "Aus Sortierung löschen",
|
||||
"reorder": "Spalten neu sortieren",
|
||||
"reorderLeft": "Spalte nach links verschieben",
|
||||
"reorderRight": "Spalte nach rechts verschieben",
|
||||
"searchClear": "Suche leeren",
|
||||
"searchDropdown": "Suchen"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "Vormittag",
|
||||
"1": "Nachmittag"
|
||||
},
|
||||
"hours": "Stunden",
|
||||
"minutes": "Minuten",
|
||||
"months": {
|
||||
"0": "Januar",
|
||||
"1": "Februar",
|
||||
"10": "November",
|
||||
"11": "Dezember",
|
||||
"2": "März",
|
||||
"3": "April",
|
||||
"4": "Mai",
|
||||
"5": "Juni",
|
||||
"6": "Juli",
|
||||
"7": "August",
|
||||
"8": "September",
|
||||
"9": "Oktober"
|
||||
},
|
||||
"next": "Nachher",
|
||||
"previous": "Vorher",
|
||||
"seconds": "Sekunden",
|
||||
"unknown": "Unbekannt",
|
||||
"weekdays": {
|
||||
"0": "Sonntag",
|
||||
"1": "Montag",
|
||||
"2": "Dienstag",
|
||||
"3": "Mittwoch",
|
||||
"4": "Donnerstag",
|
||||
"5": "Freitag",
|
||||
"6": "Samstag"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Schließen",
|
||||
"create": {
|
||||
"button": "Neu",
|
||||
"submit": "Erstellen",
|
||||
"title": "Neuen Eintrag erstellen"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Bearbeiten",
|
||||
"submit": "Bearbeiten",
|
||||
"title": "Eintrag bearbeiten"
|
||||
},
|
||||
"error": {
|
||||
"system": "Ein Systemfehler ist aufgetreten"
|
||||
},
|
||||
"multi": {
|
||||
"info": "Die ausgewählten Elemente enthalten mehrere Werte für dieses Feld. Um alle Elemente für dieses Feld zu bearbeiten und auf denselben Wert zu setzen, hier klicken oder tippen, andernfalls behalten diese ihre individuellen Werte bei.",
|
||||
"noMulti": "Dieses Feld kann nur einzeln bearbeitet werden, nicht als Teil einer Mengen-Änderung.",
|
||||
"restore": "Änderungen zurücksetzen",
|
||||
"title": "Mehrere Werte"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Entfernen",
|
||||
"confirm": {
|
||||
"_": "Sollen %d Zeilen gelöscht werden?",
|
||||
"1": "Soll diese Zeile gelöscht werden?"
|
||||
},
|
||||
"submit": "Entfernen",
|
||||
"title": "Entfernen"
|
||||
}
|
||||
},
|
||||
"emptyTable": "Keine Daten in der Tabelle vorhanden",
|
||||
"info": "_START_ bis _END_ von _TOTAL_ Einträgen",
|
||||
"infoEmpty": "Keine Daten vorhanden",
|
||||
"infoFiltered": "(gefiltert von _MAX_ Einträgen)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": ".",
|
||||
"lengthLabels": {
|
||||
"-1": "Alle"
|
||||
},
|
||||
"lengthMenu": "_MENU_ Zeilen anzeigen",
|
||||
"loadingRecords": "Wird geladen ..",
|
||||
"orderClear": "Sortierung leeren",
|
||||
"processing": "Bitte warten ..",
|
||||
"search": "Suche:",
|
||||
"searchBuilder": {
|
||||
"add": "Bedingung hinzufügen",
|
||||
"button": {
|
||||
"_": "Such-Baukasten (%d)",
|
||||
"0": "Such-Baukasten"
|
||||
},
|
||||
"clearAll": "Alle entfernen",
|
||||
"condition": "Bedingung",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "enthält",
|
||||
"empty": "ist leer",
|
||||
"equals": "ist gleich",
|
||||
"not": "ist ungleich",
|
||||
"notEmpty": "ist nicht leer",
|
||||
"without": "aber nicht"
|
||||
},
|
||||
"date": {
|
||||
"after": "Nach",
|
||||
"before": "Vor",
|
||||
"between": "Zwischen",
|
||||
"empty": "Leer",
|
||||
"equals": "Gleich",
|
||||
"not": "Nicht",
|
||||
"notBetween": "Nicht zwischen",
|
||||
"notEmpty": "Nicht leer"
|
||||
},
|
||||
"number": {
|
||||
"between": "Zwischen",
|
||||
"empty": "Leer",
|
||||
"equals": "Entspricht",
|
||||
"gt": "Größer als",
|
||||
"gte": "Größer als oder gleich",
|
||||
"lt": "Kleiner als",
|
||||
"lte": "Kleiner als oder gleich",
|
||||
"not": "Nicht",
|
||||
"notBetween": "Nicht zwischen",
|
||||
"notEmpty": "Nicht leer"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Beinhaltet",
|
||||
"empty": "Leer",
|
||||
"endsWith": "Endet mit",
|
||||
"equals": "Entspricht",
|
||||
"not": "Nicht",
|
||||
"notContains": "enthält nicht",
|
||||
"notEmpty": "Nicht leer",
|
||||
"notEndsWith": "endet nicht mit",
|
||||
"notStartsWith": "startet nicht mit",
|
||||
"startsWith": "Startet mit"
|
||||
}
|
||||
},
|
||||
"data": "Daten",
|
||||
"deleteTitle": "Filterregel entfernen",
|
||||
"leftTitle": "Äußere Kriterien",
|
||||
"logicAnd": "Und",
|
||||
"logicOr": "Oder",
|
||||
"rightTitle": "Innere Kriterien",
|
||||
"search": "Suche",
|
||||
"title": {
|
||||
"_": "Such-Baukasten (%d)",
|
||||
"0": "Such-Baukasten"
|
||||
},
|
||||
"value": "Wert"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Leeren",
|
||||
"collapse": {
|
||||
"_": "Suchmasken (%d)",
|
||||
"0": "Suchmasken"
|
||||
},
|
||||
"collapseMessage": "Alle einklappen",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyMessage": "<em>Leer<\/em>",
|
||||
"emptyPanes": "Keine Suchmasken",
|
||||
"loadMessage": "Lade Suchmasken ..",
|
||||
"showMessage": "zeige Alle",
|
||||
"title": "Aktive Filter: %d"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d Zellen ausgewählt",
|
||||
"0": "",
|
||||
"1": "1 Zelle ausgewählt"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d Spalten ausgewählt",
|
||||
"0": "",
|
||||
"1": "1 Spalte ausgewählt"
|
||||
},
|
||||
"rows": {
|
||||
"_": "%d Zeilen ausgewählt",
|
||||
"0": "",
|
||||
"1": "1 Zeile ausgewählt"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Erstellen",
|
||||
"columns": {
|
||||
"search": "Spalten Suche",
|
||||
"visible": "Spalten Sichtbarkeit"
|
||||
},
|
||||
"name": "Name:",
|
||||
"order": "Sortieren",
|
||||
"paging": "Seiten",
|
||||
"scroller": "Scroll Position",
|
||||
"search": "Suche",
|
||||
"searchBuilder": "Such-Baukasten",
|
||||
"select": "Auswahl",
|
||||
"title": "Neue Ansicht erstellen",
|
||||
"toggleLabel": "Inkludiert:"
|
||||
},
|
||||
"duplicateError": "Eine Ansicht mit diesem Namen existiert bereits.",
|
||||
"emptyError": "Name darf nicht leer sein.",
|
||||
"emptyStates": "Keine gespeicherten Ansichten",
|
||||
"removeConfirm": "Sicher dass %s entfernt werden soll?",
|
||||
"removeError": "Entfernen der Ansicht fehlgeschlagen.",
|
||||
"removeJoiner": " und ",
|
||||
"removeSubmit": "Entfernen",
|
||||
"removeTitle": "Ansicht entfernen",
|
||||
"renameButton": "Umbenennen",
|
||||
"renameLabel": "Neuer Name für %s:",
|
||||
"renameTitle": "Ansicht umbenennen"
|
||||
},
|
||||
"thousands": ".",
|
||||
"zeroRecords": "Keine passenden Einträge gefunden"
|
||||
}
|
||||
248
src/main/resources/static/assets/libs/datatables/i18n/en-GB.json
Normal file
248
src/main/resources/static/assets/libs/datatables/i18n/en-GB.json
Normal file
@ -0,0 +1,248 @@
|
||||
{
|
||||
"aria": {
|
||||
"paginate": {
|
||||
"first": "First",
|
||||
"last": "Last",
|
||||
"next": "Next",
|
||||
"previous": "Previous"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Cancel",
|
||||
"fill": "Fill all cells with <i>%d<\/i>",
|
||||
"fillHorizontal": "Fill cells horizontally",
|
||||
"fillVertical": "Fill cells vertically",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Collection <span class='ui-button-icon-primary ui-icon ui-icon-triangle-1-s'\/>",
|
||||
"colvis": "Column Visibility",
|
||||
"colvisRestore": "Restore visibility",
|
||||
"copy": "Copy",
|
||||
"copyKeys": "Press ctrl or u2318 + C to copy the table data to your system clipboard.<br><br>To cancel, click this message or press escape.",
|
||||
"copySuccess": {
|
||||
"_": "Copied %d rows to clipboard",
|
||||
"1": "Copied 1 row to clipboard"
|
||||
},
|
||||
"copyTitle": "Copy to Clipboard",
|
||||
"createState": "Create State",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Show %d rows",
|
||||
"-1": "Show all rows"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Print",
|
||||
"removeAllStates": "Remove All States",
|
||||
"removeState": "Remove",
|
||||
"renameState": "Rename",
|
||||
"savedStates": "Saved States",
|
||||
"stateRestore": "State %d",
|
||||
"updateState": "Update"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "am",
|
||||
"1": "pm"
|
||||
},
|
||||
"hours": "Hour",
|
||||
"minutes": "Minute",
|
||||
"months": {
|
||||
"0": "January",
|
||||
"1": "February",
|
||||
"10": "November",
|
||||
"11": "December",
|
||||
"2": "March",
|
||||
"3": "April",
|
||||
"4": "May",
|
||||
"5": "June",
|
||||
"6": "July",
|
||||
"7": "August",
|
||||
"8": "September",
|
||||
"9": "October"
|
||||
},
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"seconds": "Second",
|
||||
"unknown": "-",
|
||||
"weekdays": {
|
||||
"0": "Sun",
|
||||
"1": "Mon",
|
||||
"2": "Tue",
|
||||
"3": "Wed",
|
||||
"4": "Thu",
|
||||
"5": "Fri",
|
||||
"6": "Sat"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Close",
|
||||
"create": {
|
||||
"button": "New",
|
||||
"submit": "Create",
|
||||
"title": "Create new entry"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Edit",
|
||||
"submit": "Update",
|
||||
"title": "Edit Entry"
|
||||
},
|
||||
"error": {
|
||||
"system": "A system error has occurred (<a target=\"\\\" rel=\"nofollow\" href=\"\\\">More information<\/a>)."
|
||||
},
|
||||
"multi": {
|
||||
"info": "The selected items contain different values for this input. To edit and set all items for this input to the same value, click or tap here, otherwise they will retain their individual values.",
|
||||
"noMulti": "This input can be edited individually, but not part of a group. ",
|
||||
"restore": "Undo Changes",
|
||||
"title": "Multiple Values"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Delete",
|
||||
"confirm": {
|
||||
"_": "Are you sure you wish to delete %d rows?",
|
||||
"1": "Are you sure you wish to delete 1 row?"
|
||||
},
|
||||
"submit": "Delete",
|
||||
"title": "Delete"
|
||||
}
|
||||
},
|
||||
"emptyTable": "No data available in table",
|
||||
"info": "Showing _START_ to _END_ of _TOTAL_ entries",
|
||||
"infoEmpty": "Showing 0 to 0 of 0 entries",
|
||||
"infoFiltered": "(filtered from _MAX_ total entries)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": ",",
|
||||
"lengthMenu": "Show _MENU_ entries",
|
||||
"loadingRecords": "Loading...",
|
||||
"processing": "Processing...",
|
||||
"search": "Search:",
|
||||
"searchBuilder": {
|
||||
"add": "Add Condition",
|
||||
"button": {
|
||||
"_": "Search Builder (%d)",
|
||||
"0": "Search Builder"
|
||||
},
|
||||
"clearAll": "Clear All",
|
||||
"condition": "Condition",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "Contains",
|
||||
"empty": "Empty",
|
||||
"equals": "Equals",
|
||||
"not": "Not",
|
||||
"notEmpty": "Not Empty",
|
||||
"without": "Without"
|
||||
},
|
||||
"date": {
|
||||
"after": "After",
|
||||
"before": "Before",
|
||||
"between": "Between",
|
||||
"empty": "Empty",
|
||||
"equals": "Equals",
|
||||
"not": "Not",
|
||||
"notBetween": "Not Between",
|
||||
"notEmpty": "Not Empty"
|
||||
},
|
||||
"number": {
|
||||
"between": "Between",
|
||||
"empty": "Empty",
|
||||
"equals": "Equals",
|
||||
"gt": "Greater Than",
|
||||
"gte": "Greater Than Equal To",
|
||||
"lt": "Less Than",
|
||||
"lte": "Less Than Equal To",
|
||||
"not": "Not",
|
||||
"notBetween": "Not Between",
|
||||
"notEmpty": "Not Empty"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Contains",
|
||||
"empty": "Empty",
|
||||
"endsWith": "Ends With",
|
||||
"equals": "Equals",
|
||||
"not": "Not",
|
||||
"notContains": "Does Not Contain",
|
||||
"notEmpty": "Not Empty",
|
||||
"notEndsWith": "Does Not End With",
|
||||
"notStartsWith": "Does Not Start With",
|
||||
"startsWith": "Starts With"
|
||||
}
|
||||
},
|
||||
"data": "Data",
|
||||
"deleteTitle": "Delete filtering rule",
|
||||
"leftTitle": "Outdent Criteria",
|
||||
"logicAnd": "And",
|
||||
"logicOr": "Or",
|
||||
"rightTitle": "Indent Criteria",
|
||||
"title": {
|
||||
"_": "Search Builder (%d)",
|
||||
"0": "Search Builder"
|
||||
},
|
||||
"value": "Value"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Clear All",
|
||||
"collapse": {
|
||||
"_": "SearchPanes (%d)",
|
||||
"0": "SearchPanes"
|
||||
},
|
||||
"collapseMessage": "Collapse All",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyPanes": "No SearchPanes",
|
||||
"loadMessage": "Loading SearchPanes",
|
||||
"showMessage": "Show All",
|
||||
"title": "Filters Active - %d"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d cells selected",
|
||||
"0": "",
|
||||
"1": "1 cell selected"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d columns selected",
|
||||
"0": "",
|
||||
"1": "1 column selected"
|
||||
},
|
||||
"rows": {
|
||||
"_": "%d rows selected",
|
||||
"0": "",
|
||||
"1": "1 row selected"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Create",
|
||||
"columns": {
|
||||
"search": "Column Search",
|
||||
"visible": "Column Visibility"
|
||||
},
|
||||
"name": "Name:",
|
||||
"order": "Sorting",
|
||||
"paging": "Paging",
|
||||
"scroller": "Scroll Position",
|
||||
"search": "Search",
|
||||
"searchBuilder": "SearchBuilder",
|
||||
"select": "Select",
|
||||
"title": "Create New State",
|
||||
"toggleLabel": "Includes:"
|
||||
},
|
||||
"duplicateError": "A state with this name already exists.",
|
||||
"emptyError": "Name cannot be empty.",
|
||||
"emptyStates": "No saved states",
|
||||
"removeConfirm": "Are you sure you want to remove %s?",
|
||||
"removeError": "Failed to remove state.",
|
||||
"removeJoiner": " and ",
|
||||
"removeSubmit": "Remove",
|
||||
"removeTitle": "Remove State",
|
||||
"renameButton": "Rename",
|
||||
"renameLabel": "New Name for %s:",
|
||||
"renameTitle": "Rename State"
|
||||
},
|
||||
"thousands": ",",
|
||||
"zeroRecords": "No matching records found"
|
||||
}
|
||||
313
src/main/resources/static/assets/libs/datatables/i18n/es-ES.json
Normal file
313
src/main/resources/static/assets/libs/datatables/i18n/es-ES.json
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
"aria": {
|
||||
"orderable": "Activar para ordenar",
|
||||
"orderableRemove": "Activar para quitar ordenación",
|
||||
"orderableReverse": "Activar para ordenar de forma inversa",
|
||||
"paginate": {
|
||||
"first": "Primero",
|
||||
"last": "Último",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Cancelar",
|
||||
"fill": "Rellene todas las celdas con <i>%d<\/i>",
|
||||
"fillHorizontal": "Rellenar celdas horizontalmente",
|
||||
"fillVertical": "Rellenar celdas verticalmente",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Colección",
|
||||
"colvis": "Visibilidad",
|
||||
"colvisRestore": "Restaurar visibilidad",
|
||||
"copy": "Copiar",
|
||||
"copyKeys": "Presione ctrl o u2318 + C para copiar los datos de la tabla al portapapeles del sistema. <br \/> <br \/> Para cancelar, haga clic en este mensaje o presione escape.",
|
||||
"copySuccess": {
|
||||
"_": "Copiadas %ds filas al portapapeles",
|
||||
"1": "Copiada 1 fila al portapapeles"
|
||||
},
|
||||
"copyTitle": "Copiar al portapapeles",
|
||||
"createState": "Crear Estado",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Mostrar %d filas",
|
||||
"-1": "Mostrar todas las filas",
|
||||
"1": "Mostrar 1 fila"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Imprimir",
|
||||
"removeAllStates": "Remover Estados",
|
||||
"removeState": "Remover",
|
||||
"renameState": "Cambiar nombre",
|
||||
"savedStates": "Estados Guardados",
|
||||
"stateRestore": "Estado %d",
|
||||
"updateState": "Actualizar"
|
||||
},
|
||||
"columnControl": {
|
||||
"buttons": {
|
||||
"searchClear": "Borrar búsqueda"
|
||||
},
|
||||
"colVis": "Visibilidad",
|
||||
"colVisDropdown": "Desplegable visibilidad",
|
||||
"dropdown": "Desplegable",
|
||||
"list": {
|
||||
"all": "Añadir",
|
||||
"empty": "Vacío",
|
||||
"none": "Ninguno",
|
||||
"search": "Buscar.."
|
||||
},
|
||||
"orderAddAsc": "Añadir a ordenación ascendente",
|
||||
"orderAddDesc": "Añadir a ordenación descencente",
|
||||
"orderAsc": "Ordenar ascendentemente",
|
||||
"orderClear": "Borrar ordenación",
|
||||
"orderDesc": "Ordenar descendentemente",
|
||||
"orderRemove": "Borrar de ordenación",
|
||||
"reorder": "Reordenar",
|
||||
"reorderLeft": "Mover a la izquierda",
|
||||
"reorderRight": "Mover a la derecha",
|
||||
"search": {
|
||||
"datetime": {
|
||||
"empty": "Vacío",
|
||||
"equal": "Igual a",
|
||||
"greater": "Mayor que",
|
||||
"less": "Menor que",
|
||||
"notEmpty": "No vacío",
|
||||
"notEqual": "Diferente de"
|
||||
},
|
||||
"number": {
|
||||
"empty": "Vacío",
|
||||
"equal": "Igual a",
|
||||
"greater": "Mayor que",
|
||||
"greaterOrEqual": "Mayor o igual a",
|
||||
"less": "Menor que",
|
||||
"lessOrEqual": "Menor o igual a",
|
||||
"notEmpty": "No vacío",
|
||||
"notEqual": "Diferente de"
|
||||
},
|
||||
"text": {
|
||||
"contains": "Contiene",
|
||||
"empty": "Vacío",
|
||||
"ends": "Finaliza con",
|
||||
"equal": "Igual a",
|
||||
"notContains": "no contiene",
|
||||
"notEmpty": "No vacío",
|
||||
"notEqual": "Diferente de",
|
||||
"starts": "Empieza con"
|
||||
}
|
||||
},
|
||||
"searchClear": "Borrar búsqueda",
|
||||
"searchDropdown": "Buscar"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "AM",
|
||||
"1": "PM"
|
||||
},
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"months": {
|
||||
"0": "Enero",
|
||||
"1": "Febrero",
|
||||
"10": "Noviembre",
|
||||
"11": "Diciembre",
|
||||
"2": "Marzo",
|
||||
"3": "Abril",
|
||||
"4": "Mayo",
|
||||
"5": "Junio",
|
||||
"6": "Julio",
|
||||
"7": "Agosto",
|
||||
"8": "Septiembre",
|
||||
"9": "Octubre"
|
||||
},
|
||||
"next": "Próximo",
|
||||
"previous": "Anterior",
|
||||
"seconds": "Segundos",
|
||||
"unknown": "-",
|
||||
"weekdays": {
|
||||
"0": "Dom",
|
||||
"1": "Lun",
|
||||
"2": "Mar",
|
||||
"3": "Mié",
|
||||
"4": "Jue",
|
||||
"5": "Vie",
|
||||
"6": "Sáb"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Cerrar",
|
||||
"create": {
|
||||
"button": "Nuevo",
|
||||
"submit": "Crear",
|
||||
"title": "Crear Nuevo Registro"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Editar",
|
||||
"submit": "Actualizar",
|
||||
"title": "Editar Registro"
|
||||
},
|
||||
"error": {
|
||||
"system": "Ha ocurrido un error en el sistema (<a target=\"\\\" rel=\"\\ nofollow\" href=\"\\\">Más información<\\\/a>).<\/a>"
|
||||
},
|
||||
"multi": {
|
||||
"info": "Los elementos seleccionados contienen diferentes valores para este registro. Para editar y establecer todos los elementos de este registro con el mismo valor, haga clic o pulse aquí, de lo contrario conservarán sus valores individuales.",
|
||||
"noMulti": "Este registro puede ser editado individualmente, pero no como parte de un grupo.",
|
||||
"restore": "Deshacer Cambios",
|
||||
"title": "Múltiples Valores"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Eliminar",
|
||||
"confirm": {
|
||||
"_": "¿Está seguro de que desea eliminar %d filas?",
|
||||
"1": "¿Está seguro de que desea eliminar 1 fila?"
|
||||
},
|
||||
"submit": "Eliminar",
|
||||
"title": "Eliminar Registro"
|
||||
}
|
||||
},
|
||||
"emptyTable": "Ningún dato disponible en esta tabla",
|
||||
"info": "Mostrando _START_ a _END_ de _TOTAL_ registros",
|
||||
"infoEmpty": "Mostrando registros del 0 al 0 de un total de 0 registros",
|
||||
"infoFiltered": "(filtrado de un total de _MAX_ registros)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": ".",
|
||||
"lengthLabels": {
|
||||
"-1": "Todas"
|
||||
},
|
||||
"lengthMenu": "Mostrar _MENU_ registros",
|
||||
"loadingRecords": "Cargando...",
|
||||
"orderClear": "Limpiar ordenación de toda la tabla",
|
||||
"processing": "Procesando...",
|
||||
"search": "Buscar:",
|
||||
"searchBuilder": {
|
||||
"add": "Añadir condición",
|
||||
"button": {
|
||||
"_": "Constructor de búsqueda (%d)",
|
||||
"0": "Constructor de búsqueda"
|
||||
},
|
||||
"clearAll": "Borrar todo",
|
||||
"condition": "Condición",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "Contiene",
|
||||
"empty": "Vacío",
|
||||
"equals": "Igual",
|
||||
"not": "Diferente de",
|
||||
"notEmpty": "No Vacío",
|
||||
"without": "Sin"
|
||||
},
|
||||
"date": {
|
||||
"after": "Después",
|
||||
"before": "Antes",
|
||||
"between": "Entre",
|
||||
"empty": "Vacío",
|
||||
"equals": "Igual a",
|
||||
"not": "Diferente de",
|
||||
"notBetween": "No entre",
|
||||
"notEmpty": "No Vacío"
|
||||
},
|
||||
"number": {
|
||||
"between": "Entre",
|
||||
"empty": "Vacío",
|
||||
"equals": "Igual a",
|
||||
"gt": "Mayor a",
|
||||
"gte": "Mayor o igual a",
|
||||
"lt": "Menor que",
|
||||
"lte": "Menor o igual que",
|
||||
"not": "Diferente de",
|
||||
"notBetween": "No entre",
|
||||
"notEmpty": "No vacío"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Contiene",
|
||||
"empty": "Vacío",
|
||||
"endsWith": "Termina en",
|
||||
"equals": "Igual a",
|
||||
"not": "Diferente de",
|
||||
"notContains": "No Contiene",
|
||||
"notEmpty": "No Vacío",
|
||||
"notEndsWith": "No termina con",
|
||||
"notStartsWith": "No empieza con",
|
||||
"startsWith": "Empieza con"
|
||||
}
|
||||
},
|
||||
"data": "Data",
|
||||
"deleteTitle": "Eliminar regla de filtrado",
|
||||
"leftTitle": "Criterios anulados",
|
||||
"logicAnd": "Y",
|
||||
"logicOr": "O",
|
||||
"rightTitle": "Criterios de sangría",
|
||||
"search": "Buscar",
|
||||
"title": {
|
||||
"_": "Constructor de búsqueda (%d)",
|
||||
"0": "Constructor de búsqueda"
|
||||
},
|
||||
"value": "Valor"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Borrar todo",
|
||||
"collapse": {
|
||||
"_": "Paneles de búsqueda (%d)",
|
||||
"0": "Paneles de búsqueda"
|
||||
},
|
||||
"collapseMessage": "Colapsar Todo",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyMessage": "vacío",
|
||||
"emptyPanes": "Sin paneles de búsqueda",
|
||||
"loadMessage": "Cargando paneles de búsqueda",
|
||||
"showMessage": "Mostrar Todo",
|
||||
"title": "Filtros Activos - %d"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d celdas seleccionadas",
|
||||
"0": "",
|
||||
"1": "1 celda seleccionada"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d columnas seleccionadas",
|
||||
"0": "",
|
||||
"1": "1 columna seleccionada"
|
||||
},
|
||||
"rows": {
|
||||
"_": "%d filas seleccionadas",
|
||||
"0": "",
|
||||
"1": "1 fila seleccionada"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Crear",
|
||||
"columns": {
|
||||
"search": "Búsqueda de Columna",
|
||||
"visible": "Visibilidad de Columna"
|
||||
},
|
||||
"name": "Nombre:",
|
||||
"order": "Clasificación",
|
||||
"paging": "Paginación",
|
||||
"scroller": "Posición de desplazamiento",
|
||||
"search": "Búsqueda",
|
||||
"searchBuilder": "Búsqueda avanzada",
|
||||
"select": "Seleccionar",
|
||||
"title": "Crear Nuevo Estado",
|
||||
"toggleLabel": "Incluir:"
|
||||
},
|
||||
"duplicateError": "Ya existe un Estado con este nombre.",
|
||||
"emptyError": "El nombre no puede estar vacío.",
|
||||
"emptyStates": "No hay Estados guardados",
|
||||
"removeConfirm": "¿Seguro que quiere eliminar %s?",
|
||||
"removeError": "Error al eliminar el Estado",
|
||||
"removeJoiner": "y",
|
||||
"removeSubmit": "Eliminar",
|
||||
"removeTitle": "Remover Estado",
|
||||
"renameButton": "Cambiar Nombre",
|
||||
"renameLabel": "Nuevo nombre para %s:",
|
||||
"renameTitle": "Cambiar Nombre Estado"
|
||||
},
|
||||
"thousands": ".",
|
||||
"zeroRecords": "No se encontraron resultados"
|
||||
}
|
||||
313
src/main/resources/static/assets/libs/datatables/i18n/fr-FR.json
Normal file
313
src/main/resources/static/assets/libs/datatables/i18n/fr-FR.json
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
"aria": {
|
||||
"orderable": "Activer pour trier",
|
||||
"orderableRemove": "Activer pour supprimer le tri",
|
||||
"orderableReverse": "Activer pour inverser le tri",
|
||||
"paginate": {
|
||||
"first": "Première",
|
||||
"last": "Dernière",
|
||||
"next": "Suivante",
|
||||
"previous": "Précédente"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Annuler",
|
||||
"fill": "Remplir toutes les cellules avec <i>%d<\/i>",
|
||||
"fillHorizontal": "Remplir les cellules horizontalement",
|
||||
"fillVertical": "Remplir les cellules verticalement",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Collection",
|
||||
"colvis": "Visibilité colonnes",
|
||||
"colvisRestore": "Rétablir visibilité",
|
||||
"copy": "Copier",
|
||||
"copyKeys": "Appuyez sur ctrl ou u2318 + C pour copier les données du tableau dans votre presse-papier.",
|
||||
"copySuccess": {
|
||||
"_": "%d lignes copiées dans le presse-papier",
|
||||
"1": "1 ligne copiée dans le presse-papier"
|
||||
},
|
||||
"copyTitle": "Copier dans le presse-papier",
|
||||
"createState": "Créer un état",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Afficher %d lignes",
|
||||
"-1": "Afficher toutes les lignes",
|
||||
"1": "Afficher 1 ligne"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Imprimer",
|
||||
"removeAllStates": "Supprimer tous les états",
|
||||
"removeState": "Supprimer",
|
||||
"renameState": "Renommer",
|
||||
"savedStates": "États sauvegardés",
|
||||
"stateRestore": "État %d",
|
||||
"updateState": "Mettre à jour"
|
||||
},
|
||||
"columnControl": {
|
||||
"buttons": {
|
||||
"searchClear": "Effacer la recherche"
|
||||
},
|
||||
"colVis": "Visibilité colonnes",
|
||||
"colVisDropdown": "Visibilité colonnes",
|
||||
"dropdown": "Plus...",
|
||||
"list": {
|
||||
"all": "Tout sélectionner",
|
||||
"empty": "Vide",
|
||||
"none": "Désélectionner",
|
||||
"search": "Rechercher..."
|
||||
},
|
||||
"orderAddAsc": "Ajouter tri croissant",
|
||||
"orderAddDesc": "Ajouter tri décroissant",
|
||||
"orderAsc": "Tri croissant",
|
||||
"orderClear": "Effacer le tri",
|
||||
"orderDesc": "Tri décroissant",
|
||||
"orderRemove": "Supprimer du tri",
|
||||
"reorder": "Réorganiser les colonnes",
|
||||
"reorderLeft": "Déplacer la colonne vers la gauche",
|
||||
"reorderRight": "Déplacer la colonne vers la droite",
|
||||
"search": {
|
||||
"datetime": {
|
||||
"empty": "Vide",
|
||||
"equal": "Égal à",
|
||||
"greater": "Après le",
|
||||
"less": "Avant le",
|
||||
"notEmpty": "Non vide",
|
||||
"notEqual": "Différent de"
|
||||
},
|
||||
"number": {
|
||||
"empty": "Vide",
|
||||
"equal": "Égal à",
|
||||
"greater": "Supérieur à",
|
||||
"greaterOrEqual": "Supérieur ou égal à",
|
||||
"less": "Inférieur à",
|
||||
"lessOrEqual": "Inférieur ou égal à",
|
||||
"notEmpty": "Non vide",
|
||||
"notEqual": "Différent de"
|
||||
},
|
||||
"text": {
|
||||
"contains": "Contient",
|
||||
"empty": "Vide",
|
||||
"ends": "Se termine par",
|
||||
"equal": "Égal à",
|
||||
"notContains": "Ne contient pas",
|
||||
"notEmpty": "Non vide",
|
||||
"notEqual": "Différent de",
|
||||
"starts": "Commence par"
|
||||
}
|
||||
},
|
||||
"searchClear": "Effacer la recherche",
|
||||
"searchDropdown": "Rechercher"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "am",
|
||||
"1": "pm"
|
||||
},
|
||||
"hours": "Heures",
|
||||
"minutes": "Minutes",
|
||||
"months": {
|
||||
"0": "Janvier",
|
||||
"1": "Février",
|
||||
"10": "Novembre",
|
||||
"11": "Décembre",
|
||||
"2": "Mars",
|
||||
"3": "Avril",
|
||||
"4": "Mai",
|
||||
"5": "Juin",
|
||||
"6": "Juillet",
|
||||
"7": "Août",
|
||||
"8": "Septembre",
|
||||
"9": "Octobre"
|
||||
},
|
||||
"next": "Suivant",
|
||||
"previous": "Précédent",
|
||||
"seconds": "Secondes",
|
||||
"unknown": "-",
|
||||
"weekdays": {
|
||||
"0": "Dim",
|
||||
"1": "Lun",
|
||||
"2": "Mar",
|
||||
"3": "Mer",
|
||||
"4": "Jeu",
|
||||
"5": "Ven",
|
||||
"6": "Sam"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Fermer",
|
||||
"create": {
|
||||
"button": "Nouveau",
|
||||
"submit": "Créer",
|
||||
"title": "Créer une nouvelle entrée"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Editer",
|
||||
"submit": "Mettre à jour",
|
||||
"title": "Editer Entrée"
|
||||
},
|
||||
"error": {
|
||||
"system": "Une erreur système s'est produite (<a target=\"\\\" rel=\"nofollow\" href=\"\\\">Plus d'information<\/a>)."
|
||||
},
|
||||
"multi": {
|
||||
"info": "Les éléments sélectionnés contiennent différentes valeurs pour cette entrée. Pour modifier et définir tous les éléments de cette entrée à la même valeur, cliquez ou tapez ici, sinon ils conserveront leurs valeurs individuelles.",
|
||||
"noMulti": "Ce champ peut être modifié individuellement, mais ne fait pas partie d'un groupe. ",
|
||||
"restore": "Annuler les modifications",
|
||||
"title": "Valeurs multiples"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Supprimer",
|
||||
"confirm": {
|
||||
"_": "Êtes-vous sûr de vouloir supprimer %d lignes ?",
|
||||
"1": "Êtes-vous sûr de vouloir supprimer 1 ligne ?"
|
||||
},
|
||||
"submit": "Supprimer",
|
||||
"title": "Supprimer"
|
||||
}
|
||||
},
|
||||
"emptyTable": "Aucune donnée disponible dans le tableau",
|
||||
"info": "Affichage de _START_ à _END_ sur _TOTAL_ entrées",
|
||||
"infoEmpty": "Affichage de 0 à 0 sur 0 entrées",
|
||||
"infoFiltered": "(filtrées depuis un total de _MAX_ entrées)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": " ",
|
||||
"lengthLabels": {
|
||||
"-1": "Tout"
|
||||
},
|
||||
"lengthMenu": "Afficher _MENU_ entrées",
|
||||
"loadingRecords": "Chargement...",
|
||||
"orderClear": "Effacer le tri",
|
||||
"processing": "Traitement...",
|
||||
"search": "Rechercher :",
|
||||
"searchBuilder": {
|
||||
"add": "Ajouter une condition",
|
||||
"button": {
|
||||
"_": "Recherche avancée (%d)",
|
||||
"0": "Recherche avancée"
|
||||
},
|
||||
"clearAll": "Effacer tout",
|
||||
"condition": "Condition",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "Contient",
|
||||
"empty": "Vide",
|
||||
"equals": "Égal à",
|
||||
"not": "Différent de",
|
||||
"notEmpty": "Non vide",
|
||||
"without": "Sans"
|
||||
},
|
||||
"date": {
|
||||
"after": "Après le",
|
||||
"before": "Avant le",
|
||||
"between": "Entre",
|
||||
"empty": "Vide",
|
||||
"equals": "Égal à",
|
||||
"not": "Différent de",
|
||||
"notBetween": "Pas entre",
|
||||
"notEmpty": "Non vide"
|
||||
},
|
||||
"number": {
|
||||
"between": "Entre",
|
||||
"empty": "Vide",
|
||||
"equals": "Égal à",
|
||||
"gt": "Supérieur à",
|
||||
"gte": "Supérieur ou égal à",
|
||||
"lt": "Inférieur à",
|
||||
"lte": "Inférieur ou égal à",
|
||||
"not": "Différent de",
|
||||
"notBetween": "Pas entre",
|
||||
"notEmpty": "Non vide"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Contient",
|
||||
"empty": "Vide",
|
||||
"endsWith": "Se termine par",
|
||||
"equals": "Égal à",
|
||||
"not": "Différent de",
|
||||
"notContains": "Ne contient pas",
|
||||
"notEmpty": "Non vide",
|
||||
"notEndsWith": "Ne termine pas par",
|
||||
"notStartsWith": "Ne commence pas par",
|
||||
"startsWith": "Commence par"
|
||||
}
|
||||
},
|
||||
"data": "Donnée",
|
||||
"deleteTitle": "Supprimer la règle de filtrage",
|
||||
"leftTitle": "Désindenter le critère",
|
||||
"logicAnd": "Et",
|
||||
"logicOr": "Ou",
|
||||
"rightTitle": "Indenter le critère",
|
||||
"search": "Rechercher",
|
||||
"title": {
|
||||
"_": "Recherche avancée (%d)",
|
||||
"0": "Recherche avancée"
|
||||
},
|
||||
"value": "Valeur"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Effacer tout",
|
||||
"collapse": {
|
||||
"_": "Volet de recherche (%d)",
|
||||
"0": "Volet de recherche"
|
||||
},
|
||||
"collapseMessage": "Réduire tout",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyMessage": "<em>vide<\/em>",
|
||||
"emptyPanes": "Pas de volet de recherche",
|
||||
"loadMessage": "Chargement du volet de recherche...",
|
||||
"showMessage": "Montrer tout",
|
||||
"title": "Filtres actifs - %d"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d cellules sélectionnées",
|
||||
"0": "",
|
||||
"1": "1 cellule sélectionnée"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d colonnes sélectionnées",
|
||||
"0": "",
|
||||
"1": "1 colonne sélectionnée"
|
||||
},
|
||||
"rows": {
|
||||
"_": "%d lignes sélectionnées",
|
||||
"0": "",
|
||||
"1": "1 ligne sélectionnée"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Créer",
|
||||
"columns": {
|
||||
"search": "Recherche par colonne",
|
||||
"visible": "Visibilité des colonnes"
|
||||
},
|
||||
"name": "Nom :",
|
||||
"order": "Tri",
|
||||
"paging": "Pagination",
|
||||
"scroller": "Position du défilement",
|
||||
"search": "Recherche",
|
||||
"searchBuilder": "Recherche avancée",
|
||||
"select": "Sélection",
|
||||
"title": "Créer un nouvel état",
|
||||
"toggleLabel": "Inclus :"
|
||||
},
|
||||
"duplicateError": "Il existe déjà un état avec ce nom.",
|
||||
"emptyError": "Le nom ne peut pas être vide.",
|
||||
"emptyStates": "Aucun état sauvegardé",
|
||||
"removeConfirm": "Voulez vous vraiment supprimer %s ?",
|
||||
"removeError": "Échec de la suppression de l'état.",
|
||||
"removeJoiner": "et",
|
||||
"removeSubmit": "Supprimer",
|
||||
"removeTitle": "Supprimer l'état",
|
||||
"renameButton": "Renommer",
|
||||
"renameLabel": "Nouveau nom pour %s :",
|
||||
"renameTitle": "Renommer l'état"
|
||||
},
|
||||
"thousands": " ",
|
||||
"zeroRecords": "Aucune entrée correspondante trouvée"
|
||||
}
|
||||
248
src/main/resources/static/assets/libs/datatables/i18n/pt-BR.json
Normal file
248
src/main/resources/static/assets/libs/datatables/i18n/pt-BR.json
Normal file
@ -0,0 +1,248 @@
|
||||
{
|
||||
"aria": {
|
||||
"paginate": {
|
||||
"first": "Primeiro",
|
||||
"last": "Último",
|
||||
"next": "Próximo",
|
||||
"previous": "Anterior"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Cancelar",
|
||||
"fill": "Preencher todas as células com",
|
||||
"fillHorizontal": "Preencher células horizontalmente",
|
||||
"fillVertical": "Preencher células verticalmente",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Coleção <span class=\"ui-button-icon-primary ui-icon ui-icon-triangle-1-s\"><\/span>",
|
||||
"colvis": "Visibilidade da Coluna",
|
||||
"colvisRestore": "Restaurar Visibilidade",
|
||||
"copy": "Copiar",
|
||||
"copyKeys": "Pressione ctrl ou u2318 + C para copiar os dados da tabela para a área de transferência do sistema. Para cancelar, clique nesta mensagem ou pressione Esc..",
|
||||
"copySuccess": {
|
||||
"_": "%d linhas copiadas com sucesso",
|
||||
"1": "Uma linha copiada com sucesso"
|
||||
},
|
||||
"copyTitle": "Copiar para a Área de Transferência",
|
||||
"createState": "Criar estado",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Mostrar %d registros",
|
||||
"-1": "Mostrar todos os registros"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Imprimir",
|
||||
"removeAllStates": "Remover todos os estados",
|
||||
"removeState": "Remover",
|
||||
"renameState": "Renomear",
|
||||
"savedStates": "Estados salvos",
|
||||
"stateRestore": "Estado %d",
|
||||
"updateState": "Atualizar"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "am",
|
||||
"1": "pm"
|
||||
},
|
||||
"hours": "Hora",
|
||||
"minutes": "Minuto",
|
||||
"months": {
|
||||
"0": "Janeiro",
|
||||
"1": "Fevereiro",
|
||||
"10": "Novembro",
|
||||
"11": "Dezembro",
|
||||
"2": "Março",
|
||||
"3": "Abril",
|
||||
"4": "Maio",
|
||||
"5": "Junho",
|
||||
"6": "Julho",
|
||||
"7": "Agosto",
|
||||
"8": "Setembro",
|
||||
"9": "Outubro"
|
||||
},
|
||||
"next": "Próximo",
|
||||
"previous": "Anterior",
|
||||
"seconds": "Segundo",
|
||||
"unknown": "-",
|
||||
"weekdays": {
|
||||
"0": "Dom",
|
||||
"1": "Seg",
|
||||
"2": "Ter",
|
||||
"3": "Qua",
|
||||
"4": "Qui",
|
||||
"5": "Sex",
|
||||
"6": "Sáb"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Fechar",
|
||||
"create": {
|
||||
"button": "Novo",
|
||||
"submit": "Criar",
|
||||
"title": "Criar novo registro"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Editar",
|
||||
"submit": "Atualizar",
|
||||
"title": "Editar registro"
|
||||
},
|
||||
"error": {
|
||||
"system": "Ocorreu um erro no sistema (<a target=\"\\\" rel=\"nofollow\" href=\"\\\">Mais informações<\/a>)."
|
||||
},
|
||||
"multi": {
|
||||
"info": "Os itens selecionados contêm valores diferentes para esta entrada. Para editar e definir todos os itens para esta entrada com o mesmo valor, clique ou toque aqui, caso contrário, eles manterão seus valores individuais.",
|
||||
"noMulti": "Essa entrada pode ser editada individualmente, mas não como parte do grupo",
|
||||
"restore": "Desfazer alterações",
|
||||
"title": "Multiplos valores"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Remover",
|
||||
"confirm": {
|
||||
"_": "Tem certeza que quer deletar %d linhas?",
|
||||
"1": "Tem certeza que quer deletar 1 linha?"
|
||||
},
|
||||
"submit": "Remover",
|
||||
"title": "Remover registro"
|
||||
}
|
||||
},
|
||||
"emptyTable": "Nenhum registro encontrado",
|
||||
"info": "Mostrando de _START_ até _END_ de _TOTAL_ registros",
|
||||
"infoEmpty": "Mostrando 0 até 0 de 0 registro(s)",
|
||||
"infoFiltered": "(Filtrados de _MAX_ registros)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": ".",
|
||||
"lengthMenu": "Exibir _MENU_ resultados por página",
|
||||
"loadingRecords": "Carregando...",
|
||||
"processing": "Carregando...",
|
||||
"search": "Pesquisar",
|
||||
"searchBuilder": {
|
||||
"add": "Adicionar Condição",
|
||||
"button": {
|
||||
"_": "Construtor de Pesquisa (%d)",
|
||||
"0": "Construtor de Pesquisa"
|
||||
},
|
||||
"clearAll": "Limpar Tudo",
|
||||
"condition": "Condição",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "Contém",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual à",
|
||||
"not": "Não",
|
||||
"notEmpty": "Não vazio",
|
||||
"without": "Não possui"
|
||||
},
|
||||
"date": {
|
||||
"after": "Depois",
|
||||
"before": "Antes",
|
||||
"between": "Entre",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual",
|
||||
"not": "Não",
|
||||
"notBetween": "Não Entre",
|
||||
"notEmpty": "Não Vazio"
|
||||
},
|
||||
"number": {
|
||||
"between": "Entre",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual",
|
||||
"gt": "Maior Que",
|
||||
"gte": "Maior ou Igual a",
|
||||
"lt": "Menor Que",
|
||||
"lte": "Menor ou Igual a",
|
||||
"not": "Não",
|
||||
"notBetween": "Não Entre",
|
||||
"notEmpty": "Não Vazio"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Contém",
|
||||
"empty": "Vazio",
|
||||
"endsWith": "Termina Com",
|
||||
"equals": "Igual",
|
||||
"not": "Não",
|
||||
"notContains": "Não contém",
|
||||
"notEmpty": "Não Vazio",
|
||||
"notEndsWith": "Não termina com",
|
||||
"notStartsWith": "Não começa com",
|
||||
"startsWith": "Começa Com"
|
||||
}
|
||||
},
|
||||
"data": "Data",
|
||||
"deleteTitle": "Excluir regra de filtragem",
|
||||
"leftTitle": "Critérios Externos",
|
||||
"logicAnd": "E",
|
||||
"logicOr": "Ou",
|
||||
"rightTitle": "Critérios Internos",
|
||||
"title": {
|
||||
"_": "Construtor de Pesquisa (%d)",
|
||||
"0": "Construtor de Pesquisa"
|
||||
},
|
||||
"value": "Valor"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Limpar Tudo",
|
||||
"collapse": {
|
||||
"_": "Painéis de Pesquisa (%d)",
|
||||
"0": "Painéis de Pesquisa"
|
||||
},
|
||||
"collapseMessage": "Fechar todos",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyPanes": "Nenhum Painel de Pesquisa",
|
||||
"loadMessage": "Carregando Painéis de Pesquisa...",
|
||||
"showMessage": "Mostrar todos",
|
||||
"title": "Filtros Ativos"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d células selecionadas",
|
||||
"0": "",
|
||||
"1": "1 célula selecionada"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d colunas selecionadas",
|
||||
"0": "",
|
||||
"1": "1 coluna selecionada"
|
||||
},
|
||||
"rows": {
|
||||
"_": "Selecionado %d linhas",
|
||||
"0": "",
|
||||
"1": "Selecionado 1 linha"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Criar",
|
||||
"columns": {
|
||||
"search": "Busca de colunas",
|
||||
"visible": "Visibilidade da coluna"
|
||||
},
|
||||
"name": "Nome:",
|
||||
"order": "Ordernar",
|
||||
"paging": "Paginação",
|
||||
"scroller": "Posição da barra de rolagem",
|
||||
"search": "Busca",
|
||||
"searchBuilder": "Mecanismo de busca",
|
||||
"select": "Selecionar",
|
||||
"title": "Criar novo estado",
|
||||
"toggleLabel": "Inclui:"
|
||||
},
|
||||
"duplicateError": "Já existe um estado com esse nome!",
|
||||
"emptyError": "Não pode ser vazio!",
|
||||
"emptyStates": "Nenhum estado salvo",
|
||||
"removeConfirm": "Confirma remover %s?",
|
||||
"removeError": "Falha ao remover estado!",
|
||||
"removeJoiner": "e",
|
||||
"removeSubmit": "Remover",
|
||||
"removeTitle": "Remover estado",
|
||||
"renameButton": "Renomear",
|
||||
"renameLabel": "Novo nome para %s:",
|
||||
"renameTitle": "Renomear estado"
|
||||
},
|
||||
"thousands": ".",
|
||||
"zeroRecords": "Nenhum registro encontrado"
|
||||
}
|
||||
313
src/main/resources/static/assets/libs/datatables/i18n/pt-PT.json
Normal file
313
src/main/resources/static/assets/libs/datatables/i18n/pt-PT.json
Normal file
@ -0,0 +1,313 @@
|
||||
{
|
||||
"aria": {
|
||||
"orderable": "Ativar para ordenar",
|
||||
"orderableRemove": "Ativar para remover ordenação",
|
||||
"orderableReverse": "Ativar para inverter ordenação",
|
||||
"paginate": {
|
||||
"first": "Primeiro",
|
||||
"last": "Último",
|
||||
"next": "Seguinte",
|
||||
"previous": "Anterior"
|
||||
}
|
||||
},
|
||||
"autoFill": {
|
||||
"cancel": "Cancelar",
|
||||
"fill": "Preencher",
|
||||
"fillHorizontal": "Preencher células na horizontal",
|
||||
"fillVertical": "Preencher células na vertical",
|
||||
"info": ""
|
||||
},
|
||||
"buttons": {
|
||||
"collection": "Coleção",
|
||||
"colvis": "Visibilidade de colunas",
|
||||
"colvisRestore": "Restaurar visibilidade",
|
||||
"copy": "Copiar",
|
||||
"copyKeys": "Pressionar CTRL ou u2318 + C para copiar a informação para a área de transferência.<br \/><br \/>Para cancelar, clique nesta mensagem ou pressione ESC.",
|
||||
"copySuccess": {
|
||||
"_": "%ds linhas copiadas para a área de transferência",
|
||||
"1": "Uma linha copiada para a área de transferência"
|
||||
},
|
||||
"copyTitle": "Copiar para a área de transferência",
|
||||
"createState": "Criar Estado",
|
||||
"csv": "CSV",
|
||||
"excel": "Excel",
|
||||
"pageLength": {
|
||||
"_": "Mostrar %d linhas",
|
||||
"-1": "Mostrar todas as linhas",
|
||||
"1": "Mostrar 1 linha"
|
||||
},
|
||||
"pdf": "PDF",
|
||||
"print": "Imprimir",
|
||||
"removeAllStates": "Remover Todos os Estados",
|
||||
"removeState": "Remover",
|
||||
"renameState": "Renomear",
|
||||
"savedStates": "Estados Gravados",
|
||||
"stateRestore": "Estado %d",
|
||||
"updateState": "Atualizar"
|
||||
},
|
||||
"columnControl": {
|
||||
"buttons": {
|
||||
"searchClear": "Limpar pesquisa"
|
||||
},
|
||||
"colVis": "Visibilidade da coluna",
|
||||
"colVisDropdown": "Visibilidade da coluna",
|
||||
"dropdown": "Mostrar mais...",
|
||||
"list": {
|
||||
"all": "Todos",
|
||||
"empty": "Vazio",
|
||||
"none": "Nenhum",
|
||||
"search": "Pesquisar..."
|
||||
},
|
||||
"orderAddAsc": "Adicionar à ordem crescente",
|
||||
"orderAddDesc": "Adicionar à ordem decrescente",
|
||||
"orderAsc": "Ordem crescente",
|
||||
"orderClear": "Remover ordenação",
|
||||
"orderDesc": "Ordem decrescente",
|
||||
"orderRemove": "Remover ordenação",
|
||||
"reorder": "Reordenar",
|
||||
"reorderLeft": "Mover para a esquerda",
|
||||
"reorderRight": "Mover para a direita",
|
||||
"search": {
|
||||
"datetime": {
|
||||
"empty": "Vazio",
|
||||
"equal": "Igual a",
|
||||
"greater": "Posterior a",
|
||||
"less": "Anterior a",
|
||||
"notEmpty": "Não está vazio",
|
||||
"notEqual": "Diferente de"
|
||||
},
|
||||
"number": {
|
||||
"empty": "Vazio",
|
||||
"equal": "Igual a",
|
||||
"greater": "Maior que",
|
||||
"greaterOrEqual": "Maior ou igual a",
|
||||
"less": "Menor que",
|
||||
"lessOrEqual": "Menor ou igual a",
|
||||
"notEmpty": "Não está vazio",
|
||||
"notEqual": "Diferente de"
|
||||
},
|
||||
"text": {
|
||||
"contains": "Contém",
|
||||
"empty": "Vazio",
|
||||
"ends": "Termina em",
|
||||
"equal": "Igual a",
|
||||
"notContains": "Não contém",
|
||||
"notEmpty": "Não está vazio",
|
||||
"notEqual": "Diferente de",
|
||||
"starts": "Começa por"
|
||||
}
|
||||
},
|
||||
"searchClear": "Limpar pesquisa",
|
||||
"searchDropdown": "Pesquisar"
|
||||
},
|
||||
"datetime": {
|
||||
"amPm": {
|
||||
"0": "am",
|
||||
"1": "pm"
|
||||
},
|
||||
"hours": "Horas",
|
||||
"minutes": "Minutos",
|
||||
"months": {
|
||||
"0": "Janeiro",
|
||||
"1": "Fevereiro",
|
||||
"10": "Novembro",
|
||||
"11": "Dezembro",
|
||||
"2": "Março",
|
||||
"3": "Abril",
|
||||
"4": "Maio",
|
||||
"5": "Junho",
|
||||
"6": "Julho",
|
||||
"7": "Agosto",
|
||||
"8": "Setembro",
|
||||
"9": "Outubro"
|
||||
},
|
||||
"next": "Próximo",
|
||||
"previous": "Anterior",
|
||||
"seconds": "Segundos",
|
||||
"unknown": "-",
|
||||
"weekdays": {
|
||||
"0": "Dom",
|
||||
"1": "Seg",
|
||||
"2": "Ter",
|
||||
"3": "Qua",
|
||||
"4": "Qui",
|
||||
"5": "Sex",
|
||||
"6": "Sáb"
|
||||
}
|
||||
},
|
||||
"decimal": "",
|
||||
"editor": {
|
||||
"close": "Fechar",
|
||||
"create": {
|
||||
"button": "Novo",
|
||||
"submit": "Criar",
|
||||
"title": "Criar novo registo"
|
||||
},
|
||||
"edit": {
|
||||
"button": "Editar",
|
||||
"submit": "Atualizar",
|
||||
"title": "Editar registo"
|
||||
},
|
||||
"error": {
|
||||
"system": "Ocorreu um erro no sistema"
|
||||
},
|
||||
"multi": {
|
||||
"info": "Os itens selecionados contêm valores diferentes para esta entrada. Para editar e definir todos os itens nesta entrada com o mesmo valor, clique ou toque aqui, caso contrário eles manterão os seus valores individuais.",
|
||||
"noMulti": "Este campo pode ser editado individualmente mas não pode ser editado em grupo",
|
||||
"restore": "Desfazer alterações",
|
||||
"title": "Múltiplos valores"
|
||||
},
|
||||
"remove": {
|
||||
"button": "Remover",
|
||||
"confirm": {
|
||||
"_": "Tem a certeza que pretende eliminar %d entradas?",
|
||||
"1": "Tem a certeza que pretende eliminar esta entrada?"
|
||||
},
|
||||
"submit": "Remover",
|
||||
"title": "Remover"
|
||||
}
|
||||
},
|
||||
"emptyTable": "Não foi encontrado nenhum registo",
|
||||
"info": "Mostrando os registos _START_ a _END_ num total de _TOTAL_",
|
||||
"infoEmpty": "Mostrando 0 registos num total de 0",
|
||||
"infoFiltered": "(filtrado num total de _MAX_ registos)",
|
||||
"infoPostFix": "",
|
||||
"infoThousands": ".",
|
||||
"lengthLabels": {
|
||||
"-1": "Todas"
|
||||
},
|
||||
"lengthMenu": "Mostrar _MENU_ registos",
|
||||
"loadingRecords": "A carregar...",
|
||||
"orderClear": "Remover ordenação",
|
||||
"processing": "A processar...",
|
||||
"search": "Procurar:",
|
||||
"searchBuilder": {
|
||||
"add": "Adicionar condição",
|
||||
"button": {
|
||||
"_": "Construtor de pesquisa (%d)",
|
||||
"0": "Construtor de pesquisa"
|
||||
},
|
||||
"clearAll": "Limpar tudo",
|
||||
"condition": "Condição",
|
||||
"conditions": {
|
||||
"array": {
|
||||
"contains": "Contém",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual",
|
||||
"not": "Diferente",
|
||||
"notEmpty": "Não está vazio",
|
||||
"without": "Sem"
|
||||
},
|
||||
"date": {
|
||||
"after": "Depois",
|
||||
"before": "Antes",
|
||||
"between": "Entre",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual",
|
||||
"not": "Diferente",
|
||||
"notBetween": "Não está entre",
|
||||
"notEmpty": "Não está vazio"
|
||||
},
|
||||
"number": {
|
||||
"between": "Entre",
|
||||
"empty": "Vazio",
|
||||
"equals": "Igual",
|
||||
"gt": "Maior que",
|
||||
"gte": "Maior ou igual a",
|
||||
"lt": "Menor que",
|
||||
"lte": "Menor ou igual a",
|
||||
"not": "Diferente",
|
||||
"notBetween": "Não está entre",
|
||||
"notEmpty": "Não está vazio"
|
||||
},
|
||||
"string": {
|
||||
"contains": "Contém",
|
||||
"empty": "Vazio",
|
||||
"endsWith": "Termina em",
|
||||
"equals": "Igual",
|
||||
"not": "Diferente",
|
||||
"notContains": "Não contém",
|
||||
"notEmpty": "Não está vazio",
|
||||
"notEndsWith": "Não termina com",
|
||||
"notStartsWith": "Não começa com",
|
||||
"startsWith": "Começa em"
|
||||
}
|
||||
},
|
||||
"data": "Dados",
|
||||
"deleteTitle": "Excluir condição de filtragem",
|
||||
"leftTitle": "Excluir critério",
|
||||
"logicAnd": "E",
|
||||
"logicOr": "Ou",
|
||||
"rightTitle": "Incluir critério",
|
||||
"search": "Pesquisar",
|
||||
"title": {
|
||||
"_": "Construtor de pesquisa (%d)",
|
||||
"0": "Construtor de pesquisa"
|
||||
},
|
||||
"value": "Valor"
|
||||
},
|
||||
"searchPanes": {
|
||||
"clearMessage": "Limpar tudo",
|
||||
"collapse": {
|
||||
"_": "Painéis de pesquisa (%d)",
|
||||
"0": "Painéis de pesquisa"
|
||||
},
|
||||
"collapseMessage": "Ocultar Todos",
|
||||
"count": "{total}",
|
||||
"countFiltered": "{shown} ({total})",
|
||||
"emptyMessage": "<em>Vazio<\/em>",
|
||||
"emptyPanes": "Sem painéis de pesquisa",
|
||||
"loadMessage": "A carregar painéis de pesquisa",
|
||||
"showMessage": "Mostrar todos",
|
||||
"title": "Filtros ativos"
|
||||
},
|
||||
"searchPlaceholder": "",
|
||||
"select": {
|
||||
"cells": {
|
||||
"_": "%d células selecionadas",
|
||||
"0": "",
|
||||
"1": "1 célula selecionada"
|
||||
},
|
||||
"columns": {
|
||||
"_": "%d colunas selecionadas",
|
||||
"0": "",
|
||||
"1": "1 coluna selecionada"
|
||||
},
|
||||
"rows": {
|
||||
"_": "%d linhas selecionadas",
|
||||
"0": "",
|
||||
"1": "%d linha selecionada"
|
||||
}
|
||||
},
|
||||
"stateRestore": {
|
||||
"creationModal": {
|
||||
"button": "Criar",
|
||||
"columns": {
|
||||
"search": "Pesquisa por Colunas",
|
||||
"visible": "Visibilidade das Colunas"
|
||||
},
|
||||
"name": "Nome:",
|
||||
"order": "Ordenar",
|
||||
"paging": "Paginação",
|
||||
"scroller": "Posição da barra de Scroll",
|
||||
"search": "Pesquisa",
|
||||
"searchBuilder": "Pesquisa Avançada",
|
||||
"select": "Selecionar",
|
||||
"title": "Criar Novo Estado",
|
||||
"toggleLabel": "Incluir:"
|
||||
},
|
||||
"duplicateError": "Já existe um estado com o mesmo nome",
|
||||
"emptyError": "Nome não pode ser vazio",
|
||||
"emptyStates": "Não existem estados gravados",
|
||||
"removeConfirm": "Deseja mesmo remover o estado %s?",
|
||||
"removeError": "Erro ao remover o estado.",
|
||||
"removeJoiner": " e ",
|
||||
"removeSubmit": "Apagar",
|
||||
"removeTitle": "Apagar Estado",
|
||||
"renameButton": "Renomear",
|
||||
"renameLabel": "Novo nome para %s:",
|
||||
"renameTitle": "Renomear Estado"
|
||||
},
|
||||
"thousands": ".",
|
||||
"zeroRecords": "Não foram encontrados resultados"
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
<div th:fragment="margenesPresupuestoForm">
|
||||
<form id="margenesPresupuestoForm" novalidate th:action="${action}" th:object="${margenPresupuesto}" method="post" th:data-add="#{margenesPresupuesto.add}"
|
||||
th:data-edit="#{margenesPresupuesto.editar}">
|
||||
|
||||
<input type="hidden" name="_method" value="PUT" th:if="${margenPresupuesto.id != null}" />
|
||||
|
||||
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
|
||||
<div th:each="e : ${#fields.globalErrors()}" th:text="${e}"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.tipo_encuadernacion}" for="tipo_encuadernacion">Tipo de Encuadernación</label>
|
||||
<select class="form-control" id="tipo_encuadernacion" th:field="*{tipoEncuadernacion}" required
|
||||
th:classappend="${#fields.hasErrors('tipoEncuadernacion')} ? ' is-invalid'">
|
||||
<option value="fresado" th:text="#{presupuesto.fresado}" selected>Fresado</option>
|
||||
<option value="cosido" th:text="#{presupuesto.cosido}">Cosido</option>
|
||||
<option value="espiral" th:text="#{presupuesto.espiral}">Espiral</option>
|
||||
<option value="wireo" th:text="#{presupuesto.wireo}">Wire-O</option>
|
||||
<option value="grapado" th:text="#{presupuesto.grapado}">Grapado</option>
|
||||
</select>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoEncuadernacion')}" th:errors="*{tipoEncuadernacion}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.tipo_cubierta}" for="tipo_cubierta">Tipo de Cubierta</label>
|
||||
<select class="form-control" id="tipo_cubierta" th:field="*{tipoCubierta}" required
|
||||
th:classappend="${#fields.hasErrors('tipoCubierta')} ? ' is-invalid'">
|
||||
<option value="tapaBlanda" th:text="#{presupuesto.tapaBlanda}" selected>Tapa Blanda</option>
|
||||
<option value="tapaDura" th:text="#{presupuesto.tapaDura}">Tapa Dura</option>
|
||||
<option value="tapaDuraLomoRedondo" th:text="#{presupuesto.tapaDuraLomoRedondo}">Tapa Dura Lomo Redondo</option>
|
||||
</select>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('tipoCubierta')}" th:errors="*{tipoCubierta}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.tirada_minima}" for="tirada_minima">Tirada Mínima</label>
|
||||
<input type="number" class="form-control" id="tirada_minima" th:field="*{tiradaMin}" min="1"
|
||||
th:classappend="${#fields.hasErrors('tiradaMin')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('tiradaMin')}" th:errors="*{tiradaMin}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.tirada_maxima}" for="tirada_maxima">Tirada Máxima</label>
|
||||
<input type="number" class="form-control" id="tirada_maxima" th:field="*{tiradaMax}" min="1"
|
||||
th:classappend="${#fields.hasErrors('tiradaMax')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('tiradaMax')}" th:errors="*{tiradaMax}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.margen_maximo}" for="margen_maximo">Margen Máximo (%)</label>
|
||||
<input type="number" class="form-control" id="margen_maximo" th:field="*{margenMax}" min="0" max="100" step="0.01"
|
||||
th:classappend="${#fields.hasErrors('margenMax')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('margenMax')}" th:errors="*{margenMax}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{margenes-presupuesto.form.margen_minimo}" for="margen_minimo">Margen Mínimo (%)</label>
|
||||
<input type="number" class="form-control" id="margen_minimo" th:field="*{margenMin}" min="0" max="100" step="0.01"
|
||||
th:classappend="${#fields.hasErrors('margenMin')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('margenMin')}" th:errors="*{margenMin}">Error</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-3 justified-content-center d-flex">
|
||||
<button type="submit" class="btn btn-secondary" th:text="#{usuarios.guardar}">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,108 @@
|
||||
<!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('margenesPresupuestoFormModal', 'margenes-presupuesto.add', 'modal-md', 'margenesPresupuestoModalBody')">
|
||||
</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="#{margenes-presupuesto.breadcrumb}">Márgenes de presupuesto</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<button type="button" class="btn btn-secondary mb-3" id="addButton">
|
||||
<i class="ri-add-line align-bottom me-1"></i> <span th:text="#{margenes-presupuesto.add}">Añadir</span>
|
||||
</button>
|
||||
|
||||
<table id="margenes-datatable" class="table table-striped table-nowrap responsive w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.id}">ID</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.tipo_encuadernacion}">Tipo encuadernación</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.tipo_cubierta}">Tipo cubierta</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.tirada_minima}">Tirada Mín.</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.tirada_maxima}">Tirada Máx.</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.margen_maximo}">Margen Máx.</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.margen_minimo}">Margen Mín.</th>
|
||||
<th scope="col" th:text="#{margenes-presupuesto.tabla.acciones}">Acciones</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="id" /></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm margenes-presupuesto-select-filter" id="search-encuadernacion">
|
||||
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||
<option value="fresado" th:text="#{presupuesto.fresado}">Fresado</option>
|
||||
<option value="cosido" th:text="#{presupuesto.cosido}">Cosido</option>
|
||||
<option value="espiral" th:text="#{presupuesto.espiral}">Espiral</option>
|
||||
<option value="wireo" th:text="#{presupuesto.wireo}">Wireo</option>
|
||||
<option value="grapado" th:text="#{presupuesto.grapado}">Grapado</option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm margenes-presupuesto-select-filter" id="search-cubierta">
|
||||
<option value="" th:text="#{margenes-presupuesto.todos}">Todos</option>
|
||||
<option value="tapaBlanda" th:text="#{presupuesto.tapa-blanda}"></option>
|
||||
<option value="tapaDura" th:text="#{presupuesto.tapa-dura}"></option>
|
||||
<option value="tapaDuraLomoRedondo" th:text="#{presupuesto.tapa-dura-lomo-redondo}"></option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tiradaMin" />
|
||||
</th>
|
||||
<th>
|
||||
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="tiradaMax" />
|
||||
</th>
|
||||
<th>
|
||||
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margenMax" />
|
||||
</th>
|
||||
<th>
|
||||
<input type="text" class="form-control form-control-sm margenes-presupuesto-filter" data-col="margenMin" />
|
||||
</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>
|
||||
<script th:src="@{/assets/js/pages/imprimelibros/configuracion/margenes-presupuesto/list.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
84
src/main/resources/templates/imprimelibros/email/layout.html
Normal file
84
src/main/resources/templates/imprimelibros/email/layout.html
Normal file
@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org" th:fragment="mail(content)">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title th:text="${subject} ?: 'Notificación'">Notificación</title>
|
||||
|
||||
<!-- Evitar dark mode agresivo en clientes que lo respetan -->
|
||||
<meta name="color-scheme" content="light only">
|
||||
<meta name="supported-color-schemes" content="light">
|
||||
|
||||
<!-- Tu CSS incrustado -->
|
||||
<style th:utext="${emailCss}"></style>
|
||||
|
||||
<!-- Hack específico para Gmail iOS (fuerza blanco en la tarjeta/contenido) -->
|
||||
<style>
|
||||
/* Gmail iOS: el selector u + .body apunta al cuerpo real que Gmail envuelve */
|
||||
u + .body .email-container { background: #ffffff !important; }
|
||||
u + .body .email-content { background: #ffffff !important; color: #333 !important; }
|
||||
u + .body .email-header { background: #f0f0f0 !important; }
|
||||
u + .body .email-footer { background: #f9f9f9 !important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<!-- Añade class="body" para el hack de Gmail iOS -->
|
||||
<body class="body" style="margin:0; padding:0; background-color:#f5f7fb;" bgcolor="#f5f7fb">
|
||||
|
||||
<!-- Wrapper 100% -->
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"
|
||||
class="email-wrapper"
|
||||
style="width:100%; background-color:#f5f7fb;"
|
||||
bgcolor="#f5f7fb">
|
||||
<tr>
|
||||
<td align="center" class="email-body" style="padding:20px 10px;">
|
||||
|
||||
<!-- Tarjeta blanca (con pixel blanco para evitar inversión en móvil) -->
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0"
|
||||
class="email-container"
|
||||
style="max-width:600px; width:100%; background-color:#ffffff; border-radius:8px; overflow:hidden; box-shadow:0 2px 6px rgba(0,0,0,0.08);"
|
||||
bgcolor="#ffffff"
|
||||
background="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/et0HFAAAAABJRU5ErkJggg==">
|
||||
<!-- Header -->
|
||||
<tr>
|
||||
<td class="email-header" style="background:#f0f0f0; padding:20px; text-align:center;" bgcolor="#f0f0f0">
|
||||
<h2 style="margin:0;">
|
||||
<img src="cid:logo" alt="imprimelibros" height="45" style="display:block; margin:0 auto;">
|
||||
</h2>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
<td class="email-content"
|
||||
style="padding:24px; font-size:14px; line-height:1.6; color:#333; text-align:left; background:#ffffff;"
|
||||
bgcolor="#ffffff">
|
||||
<th:block th:replace="~{${template} :: content}"></th:block>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<!-- Footer -->
|
||||
<tr>
|
||||
<td class="email-footer" style="background:#f9f9f9; padding:12px; text-align:center; font-size:12px; color:#777;" bgcolor="#f9f9f9">
|
||||
<p style="margin:0;">
|
||||
<strong th:text="${companyName} ?: 'ImprimeLibros'">ImprimeLibros</strong><br>
|
||||
Calle José Picón, Nº 28 Local A, 28028, Madrid<br>
|
||||
91 005 25 74 -
|
||||
<a href="mailto:contacto@imprimelibros.com" style="color:#2563eb; text-decoration:none;">
|
||||
contacto@imprimelibros.com
|
||||
</a><br>
|
||||
© <span th:text="${year} ?: ${#dates.year(#dates.createNow())}">2025</span>
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- /Tarjeta -->
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- /Wrapper -->
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org">
|
||||
<th:block th:fragment="content">
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.greeting}">Hola</span>
|
||||
<span th:text="${fullName} ?: 'Usuario'">Usuario</span>,
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.reset-password.body}">
|
||||
Haz clic en el siguiente botón para restablecer tu contraseña:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 16px;">
|
||||
<a th:href="${resetUrl}"
|
||||
style="display:inline-block; padding:12px 20px; border-radius:6px; font-weight:bold;
|
||||
background:#2563eb; color:#ffffff !important; text-decoration:none;">
|
||||
<span th:text="#{email.reset-password.button}">Restablecer contraseña</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 8px; color:#333333 !important;">
|
||||
<span th:text="#{email.reset-password.link-instruction}">
|
||||
Si no funciona, copia y pega esta URL en tu navegador:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="${resetUrl}">https://...</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.reset-password.expiration(${minutes})}">
|
||||
Este enlace caduca en 60 minutos.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0; color:#333333 !important;">
|
||||
<span th:text="#{email.reset-password.ignoreMessage}">
|
||||
Si no solicitaste este cambio, puedes ignorar este mensaje.
|
||||
</span>
|
||||
</p>
|
||||
</th:block>
|
||||
</html>
|
||||
45
src/main/resources/templates/imprimelibros/email/verify.html
Normal file
45
src/main/resources/templates/imprimelibros/email/verify.html
Normal file
@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es" xmlns:th="http://www.thymeleaf.org">
|
||||
<th:block th:fragment="content">
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.greeting}">Hola</span>
|
||||
<span th:text="${fullName} ?: 'Usuario'">Usuario</span>,
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.body}">
|
||||
Haz clic en el siguiente botón para verificar tu correo electrónico:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 16px;">
|
||||
<a th:href="${verifyUrl}"
|
||||
style="display:inline-block; padding:12px 20px; border-radius:6px; font-weight:bold;
|
||||
background:#2563eb; color:#ffffff !important; text-decoration:none;">
|
||||
<span th:text="#{email.verify.button}">Verificar cuenta</span>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 8px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.link-instruction}">
|
||||
Si no funciona, copia y pega esta URL en tu navegador:
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="${verifyUrl}">https://...</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0 0 12px; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.expiration(${minutes})}">
|
||||
Este enlace caduca en 60 minutos.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p style="margin:0; color:#333333 !important;">
|
||||
<span th:text="#{email.verify.ignoreMessage}">
|
||||
Si no solicitaste este cambio, puedes ignorar este mensaje.
|
||||
</span>
|
||||
</p>
|
||||
</th:block>
|
||||
</html>
|
||||
@ -1,9 +1,14 @@
|
||||
<html th:lang="${#locale.language}" th:with="isAuth=${#authorization.expression('isAuthenticated()')}"
|
||||
<html th:lang="${#locale.country != '' ? #locale.language + '-' + #locale.country : #locale.language}"
|
||||
th:with="isAuth=${#authorization.expression('isAuthenticated()')}"
|
||||
th:attrappend="data-layout=${isAuth} ? 'semibox' : 'horizontal'" data-sidebar-visibility="show" data-topbar="light"
|
||||
data-sidebar="light" data-sidebar-size="lg" data-sidebar-image="none" data-preloader="disable"
|
||||
xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
<head>
|
||||
<meta name="_csrf" th:content="${_csrf.token}" />
|
||||
<meta name="_csrf_header" th:content="${_csrf.headerName}" />
|
||||
|
||||
<th:block layout:fragment="pagetitle" />
|
||||
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
|
||||
<link href="/assets/libs/sweetalert2/sweetalert2.min.css" rel="stylesheet" type="text/css" />
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
<div th:fragment="_forgot-password">
|
||||
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.password-recovery.title}">Recuperar contraseña</h5>
|
||||
</div>
|
||||
|
||||
<form th:if="${form == '_forgot-password'}" th:action="@{/auth/password/forgot}" method="post">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:placeholder="#{login.email-placeholder}"
|
||||
name="username">
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.password-recovery.button}">Recuperar
|
||||
contraseña</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,60 @@
|
||||
<div th:fragment="_login">
|
||||
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.subtitle}">Inicie sesión para continuar:</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<form th:action="@{/login}" method="post">
|
||||
<!-- CSRF obligatorio -->
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div th:if="${param.error}" class="alert alert-danger" th:text="#{login.error}">
|
||||
Credenciales inválidas
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:placeholder="#{login.email-placeholder}"
|
||||
name="username">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="float-end">
|
||||
<a href="/auth/password/forgot" class="text-muted" th:text="#{login.forgotPassword}">¿Olvidó su
|
||||
contraseña?</a>
|
||||
</div>
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<div class="position-relative auth-pass-inputgroup mb-3">
|
||||
<input type="password" class="form-control pe-5 password-input"
|
||||
th:placeholder="#{login.password-placeholder}" id="password-input" name="password">
|
||||
<button
|
||||
class="btn btn-link position-absolute end-0 top-0 text-decoration-none text-muted password-addon"
|
||||
type="button" id="password-addon"><i class="ri-eye-fill align-middle"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" value="" id="remember-me" name="remember-me">
|
||||
<label class="form-check-label" for="remember-me" th:text="#{login.rememberMe}">Recuerdame</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.login}">Iniciar
|
||||
Sesión</button>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 text-center">
|
||||
<p class="mb-0">
|
||||
<span th:text="#{login.new-account}">¿No tienes una cuenta?</span>
|
||||
<a href="/signup" class="fw-semibold text-primary text-decoration-underline" th:text="#{login.sign-up}">
|
||||
Regístrate
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,41 @@
|
||||
<div th:fragment="_reset-password">
|
||||
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.change-password.title}">Cambiar contraseña</h5>
|
||||
</div>
|
||||
|
||||
<form th:if="${form == '_reset-password'} and ${valid}" th:action="@{/auth/password/reset}" method="post"
|
||||
autocomplete="off">
|
||||
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
<input type="hidden" name="uid" th:value="${uid}" />
|
||||
<input type="hidden" name="token" th:value="${token}" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-input" th:text="#{login.change-password.new-password}">Nueva
|
||||
contraseña</label>
|
||||
<input type="password" class="form-control" id="password-input" name="password" required minlength="8"
|
||||
autocomplete="new-password" th:placeholder="#{login.password-placeholder}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-confirm-input"
|
||||
th:text="#{login.change-password.confirm-password}">Confirmar contraseña</label>
|
||||
<input type="password" class="form-control" id="password-confirm-input" name="password2" required
|
||||
minlength="8" autocomplete="new-password" th:placeholder="#{login.password-placeholder}">
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.change-password.button}">Cambiar
|
||||
contraseña</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Si el token no es válido, sugerir pedir otro -->
|
||||
<div th:if="${form == '_reset-password'} and ${valid != null and !valid}" class="mt-3">
|
||||
<a class="btn btn-outline-primary w-100" th:href="@{/auth/password/forgot}"
|
||||
th:text="#{login.password-reset.request-new}">
|
||||
Solicitar nuevo enlace
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,47 @@
|
||||
<div th:fragment="_signup">
|
||||
|
||||
<div>
|
||||
<h5 class="text-primary" th:text="#{login.welcome}">¡Bienvenido!</h5>
|
||||
<p class="text-muted" th:text="#{login.sign-up.title}">Crear cuenta</p>
|
||||
</div>
|
||||
|
||||
<!-- En el caso del formulario de signup, asegúrate de bindear el DTO -->
|
||||
<form th:if="${form == '_signup'}" th:action="@{/signup}" method="post" th:object="${signupForm}">
|
||||
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label" th:text="#{login.email}">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="username" th:field="*{username}"
|
||||
th:placeholder="#{login.email-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('username')}" th:errors="*{username}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="name" th:text="#{login.sign-up.name}">Nombre completo</label>
|
||||
<input type="text" class="form-control" id="name" th:field="*{name}" th:placeholder="#{login.sign-up.name}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('name')}" th:errors="*{name}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-input" th:text="#{login.password}">Contraseña</label>
|
||||
<input type="password" class="form-control" id="password-input" th:field="*{password}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="password-confirm-input" th:text="#{login.confirm-password}">Confirmar
|
||||
contraseña</label>
|
||||
<input type="password" class="form-control" id="password-confirm-input" th:field="*{passwordConfirm}"
|
||||
th:placeholder="#{login.password-placeholder}">
|
||||
<div class="text-danger" th:if="${#fields.hasErrors('passwordConfirm')}" th:errors="*{passwordConfirm}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-secondary w-100" type="submit" th:text="#{login.sign-up-button}">Crear
|
||||
cuenta</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,88 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
th:lang="${#locale.language}" data-layout="vertical" data-topbar="light" data-sidebar="dark" data-sidebar-size="lg"
|
||||
data-sidebar-image="none" data-preloader="disable">
|
||||
|
||||
<head>
|
||||
<!--page title-->
|
||||
<th:block layout:fragment="pagetitle" />
|
||||
|
||||
<!-- Page CSS -->
|
||||
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="auth-page-wrapper auth-bg-cover py-5 d-flex justify-content-center align-items-center min-vh-100">
|
||||
<div class="bg-overlay"></div>
|
||||
<!-- auth-page content -->
|
||||
<div class="auth-page-content overflow-hidden pt-lg-5">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="card overflow-hidden">
|
||||
<div class="row g-0">
|
||||
<div class="col-lg-6">
|
||||
<div class="p-lg-1 p-1 auth-one-bg h-100">
|
||||
<div class="bg-overlay"></div>
|
||||
<div class="position-relative h-100 d-flex flex-column justify-content-end">
|
||||
<div class="mt-auto">
|
||||
<p class="fs-18 fst-italic text-center" style="color: lightgray;"
|
||||
th:utext="#{login.slogan}">
|
||||
imprimelibros.com<br>
|
||||
Especialistas en impresión de libros</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- end col -->
|
||||
|
||||
<div class="col-lg-6">
|
||||
|
||||
<div class="p-lg-5 p-4">
|
||||
<div th:if="${info}" class="alert alert-info" th:text="${info}"></div>
|
||||
<div th:if="${danger}" class="alert alert-danger" th:text="${danger}"></div>
|
||||
<div th:if="${signup_error}" class="alert alert-danger"
|
||||
th:text="${signup_error}"></div>
|
||||
|
||||
<div th:insert="~{${'imprimelibros/login/_items/' + form} :: ${form}}"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- end col -->
|
||||
</div>
|
||||
<!-- end row -->
|
||||
</div>
|
||||
<!-- end card -->
|
||||
</div>
|
||||
<!-- end col -->
|
||||
|
||||
</div>
|
||||
<!-- end row -->
|
||||
</div>
|
||||
<!-- end container -->
|
||||
</div>
|
||||
<!-- end auth page content -->
|
||||
|
||||
<!-- footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="text-center">
|
||||
<p class="mb-0" style="color: #6c757d;">©
|
||||
<script>document.write(new Date().getFullYear())</script> imprimelibros.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- end Footer -->
|
||||
</div>
|
||||
|
||||
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
|
||||
<!-- password-addon init -->
|
||||
<script src="/assets/js/pages/password-addon.init.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -7,7 +7,6 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cerrar"></button>
|
||||
</div>
|
||||
<div class="modal-body" th:id="${bodyId}">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<img src="/assets/images/logo-sm.png" alt="" height="22">
|
||||
</span>
|
||||
<span class="logo-lg">
|
||||
<img src="/assets/images/logo-dark.png" alt="" height="17">
|
||||
<img src="/assets/images/logo-dark.png" alt="" height="45">
|
||||
</span>
|
||||
</a>
|
||||
<!-- Light Logo-->
|
||||
@ -18,11 +18,12 @@
|
||||
<img src="/assets/images/logo-sm.png" alt="" height="22">
|
||||
</span>
|
||||
<span class="logo-lg">
|
||||
<img src="/assets/images/logo-light.png" alt="" height="17">
|
||||
<img src="/assets/images/logo-light.png" alt="" height="45">
|
||||
</span>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm p-0 fs-20 header-item float-end btn-vertical-sm-hover"
|
||||
id="vertical-hover">
|
||||
id="vertical-hover"
|
||||
href="/#" data-bs-toggle="tooltip" data-bs-placement="right" title="Expand">
|
||||
<i class="ri-record-circle-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
@ -32,14 +33,30 @@
|
||||
|
||||
<div id="two-column-menu">
|
||||
</div>
|
||||
<li href="/" class="menu-title"><span data-key="t-menu">Menu</span></li>
|
||||
<ul class="navbar-nav" id="navbar-nav">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link menu-link" href="/">
|
||||
<i class="ri-home-line"></i> <span data-key="t-home">Inicio</span>
|
||||
<i class="ri-home-line"></i> <span th:text="#{app.sidebar.inicio}">Inicio</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- <div th:replace="~{printhub/partials/sidebarMenus/configurationMenu :: configuration}"></div> -->
|
||||
<li class="nav-item">
|
||||
<a class="nav-link menu-link" href="/users">
|
||||
<i class="ri-user-line"></i> <span th:text="#{app.sidebar.usuarios}">Usuarios</span>
|
||||
</a>
|
||||
</li>
|
||||
<div th:if="${#authentication.principal.role == 'SUPERADMIN'}">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link menu-link collapsed" href="#sidebarConfig" data-bs-toggle="collapse" role="button" aria-expanded="false" aria-controls="sidebarConfig">
|
||||
<i class="ri-settings-2-line"></i> <span th:text="#{app.sidebar.configuracion}">Configuración</span>
|
||||
</a>
|
||||
<div class="collapse menu-dropdown" id="sidebarConfig">
|
||||
<ul class="nav nav-sm flex-column">
|
||||
<li class="nav-item">
|
||||
<a href="/configuracion/margenes-presupuesto" class="nav-link" th:text="#{margenes-presupuesto.titulo}">Márgenes de presupuesto</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Sidebar -->
|
||||
|
||||
@ -46,7 +46,7 @@
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
<!-- item-->
|
||||
<a href="javascript:void(0);" class="dropdown-item notify-item language" data-lang="es"
|
||||
<a href="javascript:void(0);" class="dropdown-item notify-item language" data-lang="es-ES"
|
||||
title="Spanish">
|
||||
<img src="/assets/images/flags/spain.svg" alt="user-image" class="me-2 rounded"
|
||||
height="18">
|
||||
@ -54,7 +54,7 @@
|
||||
</a>
|
||||
|
||||
<!-- item-->
|
||||
<a href="javascript:void(0);" class="dropdown-item notify-item language py-2" data-lang="en"
|
||||
<a href="javascript:void(0);" class="dropdown-item notify-item language py-2" data-lang="en-GB"
|
||||
title="English">
|
||||
<img src="/assets/images/flags/gb.svg" alt="user-image" class="me-2 rounded"
|
||||
height="18">
|
||||
@ -69,46 +69,31 @@
|
||||
<div class="dropdown ms-sm-3 header-item topbar-user">
|
||||
<button type="button" class="btn" id="page-header-user-dropdown" data-bs-toggle="dropdown"
|
||||
aria-haspopup="true" aria-expanded="false">
|
||||
<span class="d-flex align-items-center">
|
||||
<img class="rounded-circle header-profile-user"
|
||||
src="/assets/images/users/avatar-1.jpg" alt="Header Avatar">
|
||||
<span class="text-start ms-xl-2">
|
||||
<span class="d-none d-xl-inline-block ms-1 fw-medium user-name-text">Anna
|
||||
Adame</span>
|
||||
<span
|
||||
class="d-none d-xl-block ms-1 fs-12 text-muted user-name-sub-text">Founder</span>
|
||||
</span>
|
||||
<span sec:authorize="isAuthenticated()" class="text-center ms-xl-2">
|
||||
<span class="d-none d-xl-inline-block ms-1 fw-medium user-name-text"
|
||||
th:text="${#authentication.principal.fullname}">Nombre</span>
|
||||
<div th:if="${#authentication.principal.role != 'USER'}">
|
||||
<span class="d-none d-xl-block ms-1 fs-12 text-muted user-name-sub-text"
|
||||
th:text="${#authentication.principal.role}">Rol</span>
|
||||
</div>
|
||||
</span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end">
|
||||
<!-- item-->
|
||||
<h6 class="dropdown-header">Welcome Anna!</h6>
|
||||
<h6 class="dropdown-header"><span th:text="#{app.bienvenido}">Bienvenido</span> <span
|
||||
th:text="${#authentication.principal.fullname}">User</span> <span>!</span></h6>
|
||||
<a class="dropdown-item" href="/pages-profile"><i
|
||||
class="mdi mdi-account-circle text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle">Profile</span></a>
|
||||
class="align-middle" th:text="#{app.perfil}">Perfil</span></a>
|
||||
<a class="dropdown-item" href="/apps-chat"><i
|
||||
class="mdi mdi-message-text-outline text-muted fs-16 align-middle me-1"></i>
|
||||
<span class="align-middle">Messages</span></a>
|
||||
<a class="dropdown-item" href="/apps-tasks-kanban"><i
|
||||
class="mdi mdi-calendar-check-outline text-muted fs-16 align-middle me-1"></i>
|
||||
<span class="align-middle">Taskboard</span></a>
|
||||
<a class="dropdown-item" href="/pages-faqs"><i
|
||||
class="mdi mdi-lifebuoy text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle">Help</span></a>
|
||||
<span class="align-middle" th:text="#{app.mensajes}">Mensajes</span></a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="/pages-profile"><i
|
||||
class="mdi mdi-wallet text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle">Balance : <b>$5971.67</b></span></a>
|
||||
<a class="dropdown-item" href="/pages-profile-settings"><span
|
||||
class="badge bg-soft-success text-success mt-1 float-end">New</span><i
|
||||
class="mdi mdi-cog-outline text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle">Settings</span></a>
|
||||
<a class="dropdown-item" href="auth-lockscreen-basic"><i
|
||||
class="mdi mdi-lock text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle">Lock screen</span></a>
|
||||
<a class="dropdown-item" href="auth-logout-basic"><i
|
||||
class="mdi mdi-logout text-muted fs-16 align-middle me-1"></i> <span
|
||||
class="align-middle" data-key="t-logout">Logout</span></a>
|
||||
<a class="dropdown-item" href="#"
|
||||
onclick="document.getElementById('logoutForm').submit(); return false;">
|
||||
<i class="mdi mdi-logout text-muted fs-16 align-middle me-1"></i>
|
||||
<span class="align-middle" data-key="t-logout" th:text="#{app.logout}">Cerrar sesión</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -122,6 +107,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="logoutForm" th:action="@{/logout}" method="post" class="d-none">
|
||||
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
|
||||
</form>
|
||||
</header>
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
<div th:fragment="userForm">
|
||||
<form id="userForm" novalidate th:action="${action}" th:object="${user}" method="post" th:data-add="#{usuarios.add}"
|
||||
th:data-edit="#{usuarios.editar}">
|
||||
|
||||
<input type="hidden" name="_method" value="PUT" th:if="${user.id != null}" />
|
||||
|
||||
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
|
||||
<div th:each="e : ${#fields.globalErrors()}" th:text="${e}"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.nombre}" for="nombre">Nombre</label>
|
||||
<input type="text" class="form-control" id="nombre" th:field="*{fullName}"
|
||||
th:classappend="${#fields.hasErrors('fullName')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('fullName')}" th:errors="*{fullName}">Error</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.email}" for="email">Correo electrónico</label>
|
||||
<input type="email" class="form-control" id="email" th:field="*{userName}"
|
||||
th:classappend="${#fields.hasErrors('userName')} ? ' is-invalid'" required>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('userName')}" th:errors="*{userName}">Error</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.password}" for="password">Contraseña</label>
|
||||
<input type="password" class="form-control" id="password" th:field="*{password}" minlength="6"
|
||||
th:attr="required=${user.id == null}" th:classappend="${#fields.hasErrors('password')} ? ' is-invalid'">
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('password')}" th:errors="*{password}">Error</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.confirmarPassword}" for="confirmPassword">Confirmar Contraseña</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" th:field="*{confirmPassword}" minlength="6"
|
||||
th:attr="required=${user.id == null}"
|
||||
th:classappend="${#fields.hasErrors('confirmPassword')} ? ' is-invalid'">
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('confirmPassword')}"
|
||||
th:errors="*{confirmPassword}">Error</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.rol}" for="rol">Rol</label>
|
||||
<select class="form-control" id="rol" th:field="*{roleName}" required
|
||||
th:classappend="${#fields.hasErrors('roleName')} ? ' is-invalid'">
|
||||
<option value="USER" selected>Usuario</option>
|
||||
<option value="ADMIN">Administrador</option>
|
||||
<option value="SUPERADMIN">Super Administrador</option>
|
||||
</select>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('roleName')}" th:errors="*{roleName}">Error</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label th:text="#{usuarios.form.estado}" for="estado">Estado</label>
|
||||
<select class="form-control" id="estado" th:field="*{enabled}" required
|
||||
th:classappend="${#fields.hasErrors('enabled')} ? ' is-invalid'">
|
||||
<option th:value="true" th:selected="${user.id == null or user.enabled}">Activo</option>
|
||||
<option th:value="false" th:selected="${user.id != null and !user.enabled}">Inactivo</option>
|
||||
</select>
|
||||
<div class="invalid-feedback" th:if="${#fields.hasErrors('enabled')}" th:errors="*{enabled}">Error</div>
|
||||
</div>
|
||||
<div class="row mt-3 justified-content-center d-flex">
|
||||
<button type="submit" class="btn btn-secondary" th:text="#{usuarios.guardar}">Guardar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -0,0 +1,97 @@
|
||||
<!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('userFormModal', 'usuarios.add', 'modal-md', 'userModalBody')">
|
||||
</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="#{usuarios.titulo}">Usuarios</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<button type="button" class="btn btn-secondary mb-3" id="addUserButton">
|
||||
<i class="ri-add-line align-bottom me-1"></i> <span th:text="#{usuarios.add}">Añadir usuario</span>
|
||||
</button>
|
||||
|
||||
<table id="users-datatable" class="table table-striped table-nowrap responsive w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" th:text="#{usuarios.tabla.id}">ID</th>
|
||||
<th scope="col" th:text="#{usuarios.tabla.nombre}">Nombre</th>
|
||||
<th scope="col" th:text="#{usuarios.tabla.email}">Correo electrónico</th>
|
||||
<th scope="col" th:text="#{usuarios.tabla.rol}">Rol</th>
|
||||
<th scope="col" th:text="#{usuarios.tabla.estado}">Estado</th>
|
||||
<th scope="col" th:text="#{usuarios.tabla.acciones}">Acciones</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><input type="text" class="form-control form-control-sm user-filter" data-col="id" />
|
||||
</th>
|
||||
<th><input type="text" class="form-control form-control-sm user-filter"
|
||||
data-col="fullName" /></th>
|
||||
<th><input type="text" class="form-control form-control-sm user-filter"
|
||||
data-col="userName" /></th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm user-filter-select" id="search-role">
|
||||
<option value="" th:text="#{usuarios.todos}">Todos</option>
|
||||
<option value="USER" th:text="#{usuarios.rol.user}">Usuario</option>
|
||||
<option value="ADMIN" th:text="#{usuarios.rol.admin}">Administrador</option>
|
||||
<option value="SUPERADMIN" th:text="#{usuarios.rol.superadmin}">Super Administrador
|
||||
</option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<select class="form-select form-select-sm user-filter-select" id="search-status">
|
||||
<option value="" th:text="#{usuarios.todos}">Todos</option>
|
||||
<option value="true" th:text="#{usuarios.tabla.activo}">Activo</option>
|
||||
<option value="false" th:text="#{usuarios.tabla.inactivo}">Inactivo</option>
|
||||
</select>
|
||||
</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>
|
||||
<script th:src="@{/assets/js/pages/imprimelibros/users/list.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -3,10 +3,7 @@ package com.imprimelibros.erp;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
import java.util.Map;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@ -11,6 +11,8 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import com.imprimelibros.erp.externalApi.skApiClient;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoCubierta;
|
||||
import com.imprimelibros.erp.presupuesto.Presupuesto.TipoEncuadernacion;
|
||||
|
||||
@SpringBootTest
|
||||
class skApiClientTest {
|
||||
@ -76,7 +78,7 @@ class skApiClientTest {
|
||||
body.put("faja", false);
|
||||
body.put("servicios", servicios);
|
||||
|
||||
return apiClient.getPrice(body);
|
||||
return apiClient.getPrice(body, TipoEncuadernacion.fresado, TipoCubierta.tapaBlanda);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
132
src/test/java/com/imprimelibros/erp/verificationEmailTest.java
Normal file
132
src/test/java/com/imprimelibros/erp/verificationEmailTest.java
Normal file
@ -0,0 +1,132 @@
|
||||
package com.imprimelibros.erp;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Year;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.support.ResourceBundleMessageSource;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.util.StreamUtils;
|
||||
import org.thymeleaf.context.Context;
|
||||
import org.thymeleaf.spring6.SpringTemplateEngine;
|
||||
import org.thymeleaf.templatemode.TemplateMode;
|
||||
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
import com.imprimelibros.erp.common.email.EmailService;
|
||||
|
||||
@SpringBootTest
|
||||
public class verificationEmailTest {
|
||||
|
||||
@Autowired
|
||||
private EmailService emailService;
|
||||
|
||||
|
||||
private SpringTemplateEngine buildEngineForTests() {
|
||||
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
|
||||
resolver.setPrefix("templates/");
|
||||
resolver.setSuffix(".html");
|
||||
resolver.setTemplateMode(TemplateMode.HTML);
|
||||
resolver.setCharacterEncoding("UTF-8");
|
||||
resolver.setCacheable(false);
|
||||
|
||||
// MessageSource para tests (apunta a tus ficheros de la carpeta i18n)
|
||||
ResourceBundleMessageSource ms = new ResourceBundleMessageSource();
|
||||
ms.setBasenames(
|
||||
"i18n/app",
|
||||
"i18n/auth",
|
||||
"i18n/email", // <-- imprescindible para las claves email.*
|
||||
"i18n/login",
|
||||
"i18n/margenesPresupuesto",
|
||||
"i18n/presupuesto",
|
||||
"i18n/users",
|
||||
"i18n/validation");
|
||||
ms.setDefaultEncoding("UTF-8");
|
||||
ms.setFallbackToSystemLocale(false);
|
||||
|
||||
SpringTemplateEngine engine = new SpringTemplateEngine();
|
||||
engine.setTemplateResolver(resolver);
|
||||
engine.setTemplateEngineMessageSource(ms); // <-- clave
|
||||
|
||||
return engine;
|
||||
}
|
||||
|
||||
private String readClasspath(String path) throws Exception {
|
||||
ClassPathResource res = new ClassPathResource(path);
|
||||
return StreamUtils.copyToString(res.getInputStream(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void render_verify_email_should_include_content_and_white_card() throws Exception {
|
||||
SpringTemplateEngine engine = buildEngineForTests();
|
||||
|
||||
Locale locale = Locale.forLanguageTag("es-ES");
|
||||
Context ctx = new Context(locale);
|
||||
String verifyUrl = "https://imprimelibros.com/verify?token=TESTTOKEN";
|
||||
ctx.setVariable("subject", "Verifica tu correo");
|
||||
ctx.setVariable("companyName", "ImprimeLibros");
|
||||
ctx.setVariable("year", Year.now().getValue());
|
||||
ctx.setVariable("fullName", "Juan Pérez");
|
||||
ctx.setVariable("verifyUrl", verifyUrl);
|
||||
ctx.setVariable("minutes", 60);
|
||||
|
||||
// CSS embebido para el test (si lo usas)
|
||||
String emailCss = readClasspath("static/assets/css/email.css");
|
||||
ctx.setVariable("emailCss", emailCss);
|
||||
|
||||
// === Render ===
|
||||
String html = engine.process("imprimelibros/email/verify", ctx);
|
||||
|
||||
// Vuelca siempre el HTML para inspección
|
||||
File outDir = new File("target/email-test");
|
||||
outDir.mkdirs();
|
||||
File out = new File(outDir, "verify.html");
|
||||
try (FileOutputStream fos = new FileOutputStream(out)) {
|
||||
fos.write(html.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
System.out.println("📧 HTML generado en: " + out.getAbsolutePath());
|
||||
|
||||
// === Asserts robustos (sin depender de traducciones) ===
|
||||
|
||||
// 1) El slot se ha reemplazado (aparece la celda y hay contenido dentro)
|
||||
assertThat(html)
|
||||
.as("Debe existir la celda del contenido")
|
||||
.contains("<td class=\"email-content\"");
|
||||
assertThat(html.replaceAll("\\s+", " "))
|
||||
.as("El fragmento debe inyectar párrafos dentro del slot")
|
||||
.containsPattern("<td class=\\\"email-content\\\"[^>]*>.*?<p>.*?</p>.*?</td>");
|
||||
|
||||
// 2) Debe aparecer la URL del botón de verificación
|
||||
assertThat(html).as("El verifyUrl debe estar inyectado").contains(verifyUrl);
|
||||
|
||||
// 3) Debe existir un enlace con clase 'btn' (el botón)
|
||||
assertThat(html).as("Debe existir el botón con clase .btn").contains("<a ", "class=\"btn");
|
||||
|
||||
// 4) Fondos (wrapper gris claro y tarjeta blanca) con bgcolor y/o inline
|
||||
assertThat(html).as("Wrapper debe tener fondo claro").contains("bgcolor=\"#f5f7fb\"");
|
||||
assertThat(html).as("Tarjeta debe tener fondo blanco").contains("bgcolor=\"#ffffff\"");
|
||||
|
||||
// 5) Logo inline presente
|
||||
assertThat(html).as("Logo CID debe estar").contains("cid:logo");
|
||||
|
||||
assertThat(html).doesNotContain("??email."); // sin claves sin resolver
|
||||
}
|
||||
|
||||
@Test
|
||||
void send_verification_email_live() {
|
||||
// Lee el destinatario del entorno para no hardcodearlo ni spamear
|
||||
String to = System.getenv().getOrDefault("MAIL_TO", "jaimejimenezortega@gmail.com");
|
||||
String verifyUrl = "https://imprimelibros.com/verify?token=LIVE_TEST";
|
||||
Locale locale = Locale.forLanguageTag("es-ES");
|
||||
|
||||
// Si no lanza excepción, damos el test por OK
|
||||
emailService.sendVerificationEmail(to, "Usuario", verifyUrl, locale);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user