Compare commits

...

17 Commits

Author SHA1 Message Date
892c473266 Merge branch 'feat/login' into 'main'
Feat/login

See merge request jjimenez/erp-imprimelibros!9
2025-10-04 15:01:31 +00:00
dbc2038f9f password forgot hecho 2025-10-04 16:51:22 +02:00
b66ceee85c terminado sign up 2025-10-04 13:09:35 +02:00
d9c4f16cf0 trabajando en el signup. problema con el mismo username aunque este delete at 2025-10-03 18:30:26 +02:00
cc49732531 trabajando en el registro de usuarios 2025-10-03 13:57:44 +02:00
5da73a3679 trabajando en el registro de usuarios 2025-10-03 13:57:22 +02:00
1e24065fb7 terminado margenes presupuesto e incluido en la api 2025-10-02 20:50:39 +02:00
460d2cfc01 añadidos margenes presupuesto 2025-10-02 00:07:42 +02:00
add4e43955 modificados algunos problemas para arreglar el dump de la bbdd y quitados los warn del arranque 2025-09-29 19:17:10 +02:00
656bb5bad2 implementado el soft-delete 2025-09-29 15:35:41 +02:00
865b1573b9 recovery del pass hecho en el backend a falta de hacer los formularios 2025-09-28 18:36:44 +02:00
22198b4f25 terminado eliminar y corregido bug en ordenación por id 2025-09-28 14:07:05 +02:00
50599cf33e implementados filtros y delete. falta los textos del delete 2025-09-28 09:41:15 +02:00
847249d2de falta borrar y busqueda por columnas 2025-09-27 17:07:24 +02:00
88b43847f0 lista de usuarios terminada 2025-09-26 17:57:06 +02:00
01a1ac4b71 trabajando en usuarios 2025-09-26 15:13:11 +02:00
062a20c26a modificando el form login 2025-09-25 19:54:57 +02:00
100 changed files with 32303 additions and 107 deletions

24
pom.xml
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 + '\'' + '}';
}
}

View File

@ -0,0 +1,9 @@
package com.imprimelibros.erp.users;
import java.util.Optional;
public interface RoleDao {
Optional<Role> findRoleByName(String theRoleName);
}

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

View 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() +
'}';
}
}

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

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

View File

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

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

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

View File

@ -0,0 +1,7 @@
package com.imprimelibros.erp.users;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserService extends UserDetailsService {
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -0,0 +1 @@

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

View File

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

View File

@ -0,0 +1 @@

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

View File

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

View File

@ -0,0 +1 @@

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

View File

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

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1012 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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&lt;\\\/i&gt; oder <i>⌘&lt;\\\/i&gt; + <i>C&lt;\\\/i&gt; 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"
}

View 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"
}

View 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&lt;\\\/a&gt;).<\/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"
}

View 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"
}

View 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"
}

View 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"
}

View File

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

View File

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

View 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>
&copy; <span th:text="${year} ?: ${#dates.year(#dates.createNow())}">2025</span>
</p>
</td>
</tr>
</table>
<!-- /Tarjeta -->
</td>
</tr>
</table>
<!-- /Wrapper -->
</body>
</html>

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;">&copy;
<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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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