Compare commits

...

24 Commits

Author SHA1 Message Date
35967b93a0 el precio de los marcapáginas no se añadia al total del presupuesto 2026-02-10 14:48:15 +01:00
cef0af1bd2 Merge branch 'fix/problema_impersonate' into 'main'
arreglado el problema http. el superadmin no puede ser impersonate

See merge request jjimenez/erp-imprimelibros!41
2026-02-09 11:19:53 +00:00
8282c92419 arreglado el problema http. el superadmin no puede ser impersonate 2026-02-09 12:19:13 +01:00
433a055b14 Merge branch 'fix/actualizar_estado_pedido' into 'main'
Fix/actualizar estado pedido

See merge request jjimenez/erp-imprimelibros!40
2026-02-08 17:51:14 +00:00
fe4d180e2d terminado 2026-02-08 18:50:48 +01:00
cc2d2ef193 vamos a corregir el error 2026-02-08 18:12:08 +01:00
11a5918c37 Merge branch 'feat/add_local_perfil' into 'main'
hecho

See merge request jjimenez/erp-imprimelibros!39
2026-02-08 12:01:14 +00:00
88769ddaeb hecho 2026-02-08 13:00:51 +01:00
9acb105127 Merge branch 'jjimenez-main-patch-54099' into 'main'
Edit .gitignore

See merge request jjimenez/erp-imprimelibros!38
2026-02-08 11:46:19 +00:00
6dab15afbc Edit .gitignore 2026-02-08 11:46:08 +00:00
a0783c2062 Merge branch 'feat/add_presup_public_to_client' into 'main'
hehco

See merge request jjimenez/erp-imprimelibros!37
2026-02-08 11:44:40 +00:00
d0ccfb5626 hehco 2026-02-08 12:44:17 +01:00
2e569a7ffd Merge branch 'feat/home_fidelidad' into 'main'
Feat/home fidelidad

See merge request jjimenez/erp-imprimelibros!36
2026-02-08 11:34:00 +00:00
bc8ce4fa81 Merge branch 'main' into 'feat/home_fidelidad'
# Conflicts:
#   logs/erp.log
2026-02-08 11:33:53 +00:00
1bfe0cf3a2 terminado banner fidelidad 2026-02-08 12:32:44 +01:00
61e55e014f trabajando en el home 2026-02-04 20:36:08 +01:00
06a3521f6b Merge branch 'feat/perfil' into 'main'
añadido perfil a IL

See merge request jjimenez/erp-imprimelibros!35
2026-02-04 18:27:18 +00:00
ecf1472f58 añadido perfil a IL 2026-02-04 19:26:44 +01:00
48993a34c4 Merge branch 'feat/impersonate' into 'main'
impersonation implementado

See merge request jjimenez/erp-imprimelibros!34
2026-02-04 18:05:44 +00:00
a0bf8552f1 impersonation implementado 2026-02-04 19:05:10 +01:00
562dc2b231 Merge branch 'feat/log_save_presupuesto' into 'main'
añadido log en guardar presupuesto

See merge request jjimenez/erp-imprimelibros!33
2026-01-09 16:55:09 +00:00
9a49ccf6b8 añadido log en guardar presupuesto 2026-01-09 17:54:06 +01:00
b2026f1cab Merge branch 'mod/test_perfil' into 'main'
Modificaciones en el perfil de test

See merge request jjimenez/erp-imprimelibros!32
2026-01-08 11:31:12 +00:00
a5b6bf3a25 Modificaciones en el perfil de test 2026-01-08 12:30:43 +01:00
30 changed files with 1480 additions and 12356 deletions

4
.gitignore vendored
View File

@ -33,4 +33,6 @@ build/
.vscode/
### Logs ###
erp-*.log
/Logs/
erp.log
erp*.log

12303
logs/erp.log

File diff suppressed because one or more lines are too long

View File

@ -149,6 +149,10 @@ public class SecurityConfig {
"/pagos/redsys/**"
)
.permitAll()
.requestMatchers("/impersonate/exit")
.hasRole("PREVIOUS_ADMINISTRATOR")
.requestMatchers("/impersonate")
.hasAnyRole("SUPERADMIN", "ADMIN")
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())

View File

@ -398,13 +398,12 @@ public class skApiClient {
public Map<String, Object> checkPedidoEstado(Long presupuestoId, Locale locale) {
try {
String jsonResponse = performWithRetry(() -> {
String url = this.skApiUrl + "api/estado-pedido/" + presupuestoId;
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(authService.getToken()); // token actualizado
headers.setBearerAuth(authService.getToken());
headers.setAccept(java.util.List.of(MediaType.APPLICATION_JSON));
HttpEntity<Void> entity = new HttpEntity<>(headers);
@ -420,19 +419,34 @@ public class skApiClient {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(jsonResponse);
if (root.get("data") == null) {
throw new RuntimeException(
"Sin respuesta desde el servidor del proveedor");
// ✅ Si falta data, devolvemos mapa sin "estado" (o con estado=null pero con
// HashMap)
if (root == null || root.get("data") == null || root.get("data").isNull()) {
Map<String, Object> out = new HashMap<>();
out.put("message", "Respuesta sin campo 'data' desde el servidor del proveedor");
return out;
}
String estado = root.get("data").asText();
return Map.of(
"estado", estado);
return Map.of("estado", estado); // aquí NO es null, así que Map.of OK
} catch (HttpClientErrorException ex) {
if (ex.getStatusCode() == HttpStatus.NOT_FOUND) {
// ✅ 404: devolvemos mapa sin "estado" para evitar null en Map.of
Map<String, Object> out = new HashMap<>();
out.put("notFound", true);
out.put("message", "Orden de trabajo no encontrada para presupuestoId=" + presupuestoId);
return out;
}
throw ex;
} catch (JsonProcessingException e) {
// Fallback al 80% del ancho
return Map.of(
"estado", null);
// ✅ no parseable (HTML, debugbar, etc.)
Map<String, Object> out = new HashMap<>();
out.put("message", "Respuesta no-JSON o JSON inválido desde el proveedor");
return out;
}
}
@ -542,7 +556,7 @@ public class skApiClient {
}
public Boolean aceptarFerro(Long presupuestoId, Locale locale) {
String result = performWithRetry(() -> {
String url = this.skApiUrl + "api/aceptar-ferro/" + presupuestoId;
@ -576,9 +590,8 @@ public class skApiClient {
return Boolean.parseBoolean(result);
}
public Boolean cancelarPedido(Long pedidoId) {
String result = performWithRetry(() -> {
String url = this.skApiUrl + "api/cancelar-pedido/" + pedidoId;
@ -618,12 +631,21 @@ public class skApiClient {
private String performWithRetry(Supplier<String> request) {
try {
return request.get();
} catch (HttpClientErrorException.Unauthorized e) {
// Token expirado, renovar y reintentar
authService.invalidateToken();
try {
return request.get(); // segundo intento
} catch (HttpClientErrorException ex) {
// ✅ IMPORTANTe: si el segundo intento es 404, NO lo envuelvas
if (ex.getStatusCode() == HttpStatus.NOT_FOUND) {
throw ex;
}
// Si es otro 4xx/5xx, sí lo envolvemos
throw new RuntimeException("La autenticación ha fallado tras renovar el token.", ex);
}
}

View File

@ -7,13 +7,18 @@ import org.springframework.web.bind.annotation.GetMapping;
import com.imprimelibros.erp.configurationERP.VariableService;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.pedidos.Pedido;
import com.imprimelibros.erp.pedidos.PedidoRepository;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import java.security.Principal;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import com.imprimelibros.erp.common.Utils;
@Controller
public class HomeController {
@ -22,9 +27,10 @@ public class HomeController {
private TranslationService translationService;
@Autowired
private VariableService variableService;
@Autowired PedidoRepository pedidoRepository;
@GetMapping("/")
public String index(Model model, Authentication authentication, Locale locale) {
public String index(Model model, Authentication authentication, Principal principal,Locale locale) {
boolean isAuthenticated = authentication != null && authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken);
@ -37,7 +43,8 @@ public class HomeController {
"presupuesto.impresion-cubierta",
"presupuesto.impresion-cubierta-help",
"presupuesto.iva-reducido",
"presupuesto.iva-reducido-descripcion");
"presupuesto.iva-reducido-descripcion",
"pedido.gasto-anual");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
@ -51,6 +58,11 @@ public class HomeController {
// empty translations for authenticated users
Map<String, String> translations = Map.of();
model.addAttribute("languageBundle", translations);
Instant haceUnAno = Instant.now().minusSeconds(365 * 24 * 60 * 60);
Long userId = Utils.currentUserId(principal);
double totalGastado = pedidoRepository.sumTotalByCreatedByAndCreatedAtAfter(userId, haceUnAno);
model.addAttribute("totalGastado", totalGastado);
}
return "imprimelibros/home/home";
}

View File

@ -3,6 +3,7 @@ package com.imprimelibros.erp.pedidos;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.stereotype.Service;
import java.util.List;
@ -25,18 +26,34 @@ public class PedidoEstadoService {
/**
* Ejecuta cada noche a las 4:00 AM
*/
@Transactional
// test @Scheduled(cron = "0 * * * * *")
@Scheduled(cron = "0 0 4 * * *")
public void actualizarEstadosPedidos() {
log.info("JOB actualizarEstadosPedidos iniciado");
List<PedidoLinea> pedidosLineas = pedidoLineaRepository.findPedidosLineasParaActualizarEstado();
log.info("Pedidos líneas a procesar: {}", pedidosLineas.size());
for (PedidoLinea linea : pedidosLineas) {
log.info("Actualizando estado pedidoLineaId={}", linea.getId());
try {
Map<String, Object> resultado = pedidoService.actualizarEstado(linea.getId(), Locale.getDefault());
if (!Boolean.TRUE.equals(resultado.get("success"))) {
log.error("Error al actualizar estado. pedidoLineaId={} message={}",
linea.getId(), resultado.get("message"));
} else {
String msg = String.valueOf(resultado.get("message"));
if (msg != null && msg.contains("Orden de trabajo no encontrada")) {
log.warn("OT no encontrada. pedidoLineaId={} message={}", linea.getId(), msg);
}
}
} catch (Exception ex) {
log.error("Excepción actualizando estado. pedidoLineaId={}", linea.getId(), ex);

View File

@ -24,10 +24,12 @@ import com.imprimelibros.erp.presupuesto.service.PresupuestoService;
import com.imprimelibros.erp.users.UserService;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.facturacion.FacturaDireccion;
import com.imprimelibros.erp.facturacion.dto.DireccionFacturacionDto;
import com.imprimelibros.erp.pedidos.PedidoLinea.Estado;
import org.springframework.http.HttpStatus;
import org.springframework.web.client.HttpClientErrorException;
@Service
public class PedidoService {
@ -341,14 +343,27 @@ public class PedidoService {
Map<String, Object> result = skApiClient.checkPedidoEstado(refExterna, locale);
if (result == null || result.get("estado") == null) {
if (result == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
String estadoStr = String.valueOf(result.get("estado"));
if (Boolean.TRUE.equals(result.get("notFound"))) {
return Map.of(
"success", true,
"message", String.valueOf(result.getOrDefault("message", "OT no encontrada (404). Se omite.")));
}
Object estadoObj = result.get("estado");
if (estadoObj == null) {
return Map.of(
"success", false,
"message", messageSource.getMessage("pedido.errors.update-server-error", null, locale));
}
String estadoStr = String.valueOf(estadoObj);
PedidoLinea.Estado estadoSk;
try {
// si la API devuelve minúsculas tipo "produccion", esto funciona

View File

@ -14,6 +14,8 @@ import java.util.Optional;
import java.util.List;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.http.ResponseEntity;
@ -63,6 +65,8 @@ import jakarta.validation.Valid;
@RequestMapping("/presupuesto")
public class PresupuestoController {
private static final Logger log = LoggerFactory.getLogger(PresupuestoController.class);
private final PresupuestoRepository presupuestoRepository;
@Autowired
@ -509,6 +513,102 @@ public class PresupuestoController {
return ResponseEntity.ok(resumen);
}
@PostMapping("/public/prepare-claim")
public ResponseEntity<?> prepareClaim(
@RequestBody Map<String, Object> body,
HttpServletRequest request) {
Long presupuestoId = objectMapper.convertValue(body.get("presupuestoId"), Long.class);
if (presupuestoId == null) {
return ResponseEntity.badRequest().body(Map.of("message", "missing presupuestoId"));
}
Presupuesto p = presupuestoRepository.findById(presupuestoId).orElse(null);
if (p == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", "presupuesto not found"));
}
if (p.getOrigen() != Presupuesto.Origen.publico) {
return ResponseEntity.badRequest().body(Map.of("message", "presupuesto not public"));
}
request.getSession(true).setAttribute("presupuesto_claim_id", presupuestoId);
return ResponseEntity.ok(Map.of("success", true));
}
@GetMapping("/claim")
@Transactional
public String claimPresupuesto(
HttpServletRequest request,
Authentication authentication,
RedirectAttributes redirectAttributes,
Locale locale) {
Object attr = request.getSession(false) != null
? request.getSession(false).getAttribute("presupuesto_claim_id")
: null;
Long presupuestoId = null;
if (attr instanceof Long) {
presupuestoId = (Long) attr;
} else if (attr != null) {
try {
presupuestoId = Long.valueOf(attr.toString());
} catch (NumberFormatException ignore) {
}
}
if (presupuestoId == null) {
redirectAttributes.addFlashAttribute("errorMessage",
messageSource.getMessage("presupuesto.errores.presupuesto-no-existe", new Object[] { 0 }, locale));
return "redirect:/presupuesto";
}
Presupuesto p = presupuestoRepository.findById(presupuestoId).orElse(null);
if (p == null) {
redirectAttributes.addFlashAttribute("errorMessage",
messageSource.getMessage("presupuesto.errores.presupuesto-no-existe",
new Object[] { presupuestoId }, locale));
return "redirect:/presupuesto";
}
if (p.getUser() != null && authentication != null) {
Long currentUserId = null;
if (authentication.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else {
currentUserId = userRepo.findIdByUserNameIgnoreCase(authentication.getName()).orElse(null);
}
if (currentUserId != null && p.getUser().getId().equals(currentUserId)) {
request.getSession().removeAttribute("presupuesto_claim_id");
return "redirect:/presupuesto/edit/" + p.getId();
}
}
if (p.getOrigen() != Presupuesto.Origen.publico) {
redirectAttributes.addFlashAttribute("errorMessage",
messageSource.getMessage("presupuesto.errores.presupuesto-no-existe",
new Object[] { presupuestoId }, locale));
return "redirect:/presupuesto";
}
if (authentication != null) {
if (authentication.getPrincipal() instanceof UserDetailsImpl udi) {
p.setUser(userRepo.getReferenceById(udi.getId()));
} else {
userRepo.findByUserNameIgnoreCase(authentication.getName()).ifPresent(p::setUser);
}
}
p.setOrigen(Presupuesto.Origen.privado);
p.setEstado(Presupuesto.Estado.borrador);
presupuestoRepository.saveAndFlush(p);
request.getSession().removeAttribute("presupuesto_claim_id");
return "redirect:/presupuesto/edit/" + p.getId();
}
// =============================================
// MÉTODOS PARA USUARIOS AUTENTICADOS
// =============================================
@ -824,6 +924,7 @@ public class PresupuestoController {
return ResponseEntity.ok(Map.of("id", saveResult.get("presupuesto_id"),
"message", messageSource.getMessage("presupuesto.exito.guardado", null, locale)));
} catch (Exception ex) {
log.error("Error al guardar el presupuesto", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("message",
messageSource.getMessage("presupuesto.error.save-internal-error", null, locale),

View File

@ -0,0 +1,115 @@
package com.imprimelibros.erp.users;
import java.util.ArrayList;
import java.util.List;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import com.imprimelibros.erp.config.Sanitizer;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
@Controller
public class ImpersonationController {
private static final String PREVIOUS_ADMIN_ROLE = "ROLE_PREVIOUS_ADMINISTRATOR";
private static final String SESSION_ATTR = "IMPERSONATOR_AUTH";
private final UserService userService;
private final Sanitizer sanitizer;
public ImpersonationController(UserService userService, Sanitizer sanitizer) {
this.userService = userService;
this.sanitizer = sanitizer;
}
@PostMapping("/impersonate")
@PreAuthorize("hasRole('ADMIN') or hasRole('SUPERADMIN')")
public ResponseEntity<Void> impersonate(
@RequestParam("username") String username,
Authentication authentication,
HttpServletRequest request) {
if (authentication == null) {
return ResponseEntity.status(401).build();
}
if (hasRole(authentication, PREVIOUS_ADMIN_ROLE)) {
return ResponseEntity.status(409).build();
}
String normalized = sanitizer.plain(username);
if (normalized == null || normalized.isBlank()) {
return ResponseEntity.badRequest().build();
}
normalized = normalized.trim().toLowerCase();
if (authentication.getName() != null
&& authentication.getName().equalsIgnoreCase(normalized)) {
return ResponseEntity.status(409).build();
}
UserDetails target;
try {
target = userService.loadUserByUsername(normalized);
} catch (UsernameNotFoundException ex) {
throw new AccessDeniedException("No autorizado");
}
boolean targetIsSuperAdmin = target.getAuthorities().stream()
.anyMatch(a -> "ROLE_SUPERADMIN".equals(a.getAuthority()));
if (targetIsSuperAdmin) {
throw new AccessDeniedException("No autorizado");
}
HttpSession session = request.getSession(true);
if (session.getAttribute(SESSION_ATTR) == null) {
session.setAttribute(SESSION_ATTR, authentication);
}
List<GrantedAuthority> authorities = new ArrayList<>(target.getAuthorities());
authorities.add(new SimpleGrantedAuthority(PREVIOUS_ADMIN_ROLE));
UsernamePasswordAuthenticationToken newAuth = new UsernamePasswordAuthenticationToken(
target, target.getPassword(), authorities);
newAuth.setDetails(authentication.getDetails());
SecurityContextHolder.getContext().setAuthentication(newAuth);
return ResponseEntity.noContent().build();
}
@PostMapping("/impersonate/exit")
@PreAuthorize("hasRole('PREVIOUS_ADMINISTRATOR')")
public String exit(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session != null) {
Object previous = session.getAttribute(SESSION_ATTR);
if (previous instanceof Authentication previousAuth) {
SecurityContextHolder.getContext().setAuthentication(previousAuth);
} else {
SecurityContextHolder.clearContext();
}
session.removeAttribute(SESSION_ATTR);
}
return "redirect:/";
}
private static boolean hasRole(Authentication auth, String role) {
return auth != null
&& auth.getAuthorities().stream()
.anyMatch(a -> role.equals(a.getAuthority()));
}
}

View File

@ -0,0 +1,155 @@
package com.imprimelibros.erp.users;
import java.util.Locale;
import org.springframework.context.MessageSource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import com.imprimelibros.erp.config.Sanitizer;
import com.imprimelibros.erp.users.validation.ProfileForm;
@Controller
@RequestMapping("/pages-profile")
@PreAuthorize("isAuthenticated()")
public class ProfileController {
private final UserDao userDao;
private final PasswordEncoder passwordEncoder;
private final MessageSource messageSource;
private final Sanitizer sanitizer;
public ProfileController(UserDao userDao, PasswordEncoder passwordEncoder,
MessageSource messageSource, Sanitizer sanitizer) {
this.userDao = userDao;
this.passwordEncoder = passwordEncoder;
this.messageSource = messageSource;
this.sanitizer = sanitizer;
}
@GetMapping
public String view(
Authentication authentication,
@RequestParam(name = "success", required = false) String success,
Model model,
Locale locale) {
if (authentication == null) {
return "redirect:/login";
}
User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
if (user == null) {
return "redirect:/login";
}
ProfileForm form = new ProfileForm();
form.setId(user.getId());
form.setFullName(user.getFullName());
form.setUserName(user.getUserName());
model.addAttribute("user", form);
model.addAttribute("success", success != null);
return "imprimelibros/users/profile";
}
@PostMapping
public String update(
Authentication authentication,
@Validated @ModelAttribute("user") ProfileForm form,
BindingResult binding,
Model model,
RedirectAttributes redirectAttributes,
Locale locale) {
if (authentication == null) {
return "redirect:/login";
}
User user = userDao.findByUserNameIgnoreCase(authentication.getName()).orElse(null);
if (user == null) {
return "redirect:/login";
}
String normalized = sanitizer.plain(form.getUserName());
if (normalized != null) {
normalized = normalized.trim().toLowerCase();
}
if (normalized == null || normalized.isBlank()) {
binding.rejectValue("userName", "usuarios.error.email",
messageSource.getMessage("usuarios.error.email", null, locale));
} else if (userDao.existsByUserNameIgnoreCaseAndIdNot(normalized, user.getId())) {
binding.rejectValue("userName", "usuarios.error.duplicado",
messageSource.getMessage("usuarios.error.duplicado", null, locale));
}
String cleanName = sanitizer.plain(form.getFullName());
if (cleanName == null || cleanName.isBlank()) {
binding.rejectValue("fullName", "usuarios.error.nombre",
messageSource.getMessage("usuarios.error.nombre", null, locale));
}
boolean wantsPasswordChange = hasText(form.getCurrentPassword())
|| hasText(form.getNewPassword())
|| hasText(form.getConfirmPassword());
if (wantsPasswordChange) {
if (!hasText(form.getCurrentPassword())) {
binding.rejectValue("currentPassword", "usuarios.error.password.actual",
messageSource.getMessage("usuarios.error.password.actual", null, locale));
} else if (!passwordEncoder.matches(form.getCurrentPassword(), user.getPassword())) {
binding.rejectValue("currentPassword", "usuarios.error.password.actual.incorrecta",
messageSource.getMessage("usuarios.error.password.actual.incorrecta", null, locale));
}
if (!hasText(form.getNewPassword())) {
binding.rejectValue("newPassword", "usuarios.error.password.nueva.requerida",
messageSource.getMessage("usuarios.error.password.nueva.requerida", null, locale));
} else if (form.getNewPassword().length() < 6) {
binding.rejectValue("newPassword", "usuarios.error.password.min",
messageSource.getMessage("usuarios.error.password.min", null, locale));
}
if (!hasText(form.getConfirmPassword())) {
binding.rejectValue("confirmPassword", "usuarios.error.confirmPassword.requerida",
messageSource.getMessage("usuarios.error.confirmPassword.requerida", null, locale));
} else if (hasText(form.getNewPassword()) && !form.getNewPassword().equals(form.getConfirmPassword())) {
binding.rejectValue("confirmPassword", "usuarios.error.password-coinciden",
messageSource.getMessage("usuarios.error.password-coinciden", null, locale));
}
}
if (binding.hasErrors()) {
model.addAttribute("success", false);
return "imprimelibros/users/profile";
}
user.setFullName(cleanName.trim());
user.setUserName(normalized);
if (wantsPasswordChange) {
user.setPassword(passwordEncoder.encode(form.getNewPassword()));
}
userDao.save(user);
redirectAttributes.addAttribute("success", "1");
return "redirect:/pages-profile";
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}

View File

@ -81,6 +81,9 @@ public class UserController {
"usuarios.delete.button",
"app.yes",
"app.cancelar",
"usuarios.impersonate.title",
"usuarios.impersonate.text",
"usuarios.impersonate.button",
"usuarios.delete.ok.title",
"usuarios.delete.ok.text");
@ -132,26 +135,36 @@ public class UserController {
.collect(Collectors.joining(" ")))
.add("actions", (user) -> {
boolean isSuperAdmin = authentication.getAuthorities().stream()
boolean isSuperAdmin = authentication != null && 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>";
boolean isSelf = authentication != null
&& authentication.getName() != null
&& authentication.getName().equalsIgnoreCase(user.getUserName());
boolean targetIsSuperAdmin = user.getRoles().stream()
.anyMatch(r -> "SUPERADMIN".equalsIgnoreCase(r.getName()));
StringBuilder actions = new StringBuilder();
actions.append("<div class=\"hstack gap-3 flex-wrap\">");
actions.append("<a href=\"javascript:void(0);\" data-id=\"")
.append(user.getId())
.append("\" class=\"link-success btn-edit-user fs-15\"><i class=\"ri-edit-2-line\"></i></a>");
if (!isSelf && !targetIsSuperAdmin) {
actions.append("<a href=\"javascript:void(0);\" data-username=\"")
.append(user.getUserName())
.append("\" class=\"link-info btn-impersonate-user fs-15\"><i class=\"ri-user-shared-line\"></i></a>");
}
if (isSuperAdmin) {
actions.append("<a href=\"javascript:void(0);\" data-id=\"")
.append(user.getId())
.append("\" class=\"link-danger btn-delete-user fs-15\"><i class=\"user-delete ri-delete-bin-line\"></i></a>");
}
actions.append("</div>");
return actions.toString();
})
.where(base)
// Filtros custom:

View File

@ -0,0 +1,68 @@
package com.imprimelibros.erp.users.validation;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public class ProfileForm {
private Long id;
@NotBlank(message = "{usuarios.error.nombre}")
private String fullName;
@NotBlank(message = "{usuarios.error.email}")
@Email(message = "{usuarios.error.email.formato}")
private String userName;
private String currentPassword;
private String newPassword;
private String confirmPassword;
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 getCurrentPassword() {
return currentPassword;
}
public void setCurrentPassword(String currentPassword) {
this.currentPassword = currentPassword;
}
public String getNewPassword() {
return newPassword;
}
public void setNewPassword(String newPassword) {
this.newPassword = newPassword;
}
public String getConfirmPassword() {
return confirmPassword;
}
public void setConfirmPassword(String confirmPassword) {
this.confirmPassword = confirmPassword;
}
}

View File

@ -33,7 +33,7 @@ logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} -
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
# Datos de la API de Safekat
safekat.api.url=http://localhost:8000/
safekat.api.url=https://erp-dev.safekat.es/
safekat.api.email=imnavajas@coit.es
safekat.api.password=Safekat2024
@ -41,6 +41,6 @@ safekat.api.password=Safekat2024
redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify
redsys.urls.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify

View File

@ -0,0 +1,46 @@
# Profile desarrollo
#
# Logging
#
logging.level.root=INFO
logging.level.org.springframework.security=ERROR
logging.level.org.springframework=ERROR
logging.level.org.springframework.web=ERROR
logging.level.org.thymeleaf=ERROR
logging.level.org.apache.catalina.core=ERROR
# Debug JPA / Hibernate
#logging.level.org.hibernate.SQL=DEBUG
#logging.level.org.hibernate.orm.jdbc.bind=TRACE
#spring.jpa.properties.hibernate.format_sql=true
server.error.include-message=always
server.error.include-stacktrace=on_param
server.error.include-binding-errors=on_param
# Archivo relativo a tu proyecto (asegúrate de que exista el directorio ./logs)
logging.file.name=logs/erp.log
# Rotación tiempo+tamaño (mismo patrón, pero en ./logs)
logging.logback.rollingpolicy.file-name-pattern=logs/erp-%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.max-file-size=10MB
logging.logback.rollingpolicy.max-history=10
logging.logback.rollingpolicy.total-size-cap=1GB
# Formatos con timestamp
logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
logging.pattern.file=%d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n
# Datos de la API de Safekat
safekat.api.url=http://localhost:8000/
safekat.api.email=imnavajas@coit.es
safekat.api.password=Safekat2024
# Configuración Redsys
redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify

View File

@ -18,6 +18,8 @@ server.error.include-binding-errors=never
# Opcional: desactivar Whitelabel y servir tu propia página de error
server.error.whitelabel.enabled=false
# Servelet options
server.servlet.context-path=/intranet
# Archivo principal dentro del contenedor (monta /var/log/imprimelibros como volumen)
logging.file.name=/var/log/imprimelibros/erp.log
@ -42,6 +44,6 @@ safekat.api.password=Safekat2024
redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify
redsys.urls.ok=https://app.imprimelibros.com/intranet/pagos/redsys/ok
redsys.urls.ko=https://app.imprimelibros.com/intranet/pagos/redsys/ko
redsys.urls.notify=https://app.imprimelibros.com/intranet/pagos/redsys/notify

View File

@ -1,7 +1,8 @@
spring.application.name=erp
# Active profile
spring.profiles.active=local
#spring.profiles.active=dev
spring.profiles.active=test
#spring.profiles.active=test
#spring.profiles.active=prod

View File

@ -6,4 +6,5 @@ app.cancelar=Cancel
app.guardar=Save
app.editar=Edit
app.eliminar=Delete
app.imprimir=Print
app.imprimir=Print
app.impersonate.exit=Return to my user

View File

@ -32,4 +32,5 @@ app.sidebar.gestion-pagos=Gestión de Pagos
app.errors.403=No tienes permiso para acceder a esta página.
app.validation.required=Campo obligatorio
app.validation.required=Campo obligatorio
app.impersonate.exit=Volver a mi usuario

View File

@ -51,6 +51,8 @@ pedido.table.importe=Importe
pedido.table.estado=Estado
pedido.table.acciones=Acciones
pedido.gasto-anual=Gasto últimos 12 meses
pedido.view.tirada=Tirada
pedido.view.view-presupuesto=Ver presupuesto
pedido.view.aceptar-ferro=Aceptar ferro

View File

@ -1 +1,23 @@
usuarios.form.nombre=Full name
usuarios.form.email=Email
usuarios.form.confirmarPassword=Confirm password
usuarios.form.password.actual=Current password
usuarios.form.password.nueva=New password
usuarios.form.password.nota=You can only change the password if you provide the current one.
usuarios.error.nombre=Name is required.
usuarios.error.email=Email is required.
usuarios.error.email.formato=Email is not valid.
usuarios.error.password.min=Password must be at least 6 characters.
usuarios.error.password.actual=Current password is required.
usuarios.error.password.actual.incorrecta=Current password is not correct.
usuarios.error.password.nueva.requerida=New password is required.
usuarios.error.confirmPassword.requerida=Password confirmation is required.
usuarios.error.password-coinciden=Passwords do not match.
usuarios.error.duplicado=There is already a user with that email.
usuarios.impersonate.title=Sign in as user
usuarios.impersonate.text=You are about to sign in as <b>{0}</b>. You can return to your user from the menu.
usuarios.impersonate.button=Continue
usuarios.profile.title=Edit profile
usuarios.profile.success=Profile updated successfully.

View File

@ -20,6 +20,9 @@ usuarios.form.nombre=Nombre completo
usuarios.form.email=Correo electrónico
usuarios.form.password=Contraseña
usuarios.form.confirmarPassword=Confirmar contraseña
usuarios.form.password.actual=Contraseña actual
usuarios.form.password.nueva=Nueva contraseña
usuarios.form.password.nota=Solo podrás cambiar la contraseña si indicas la actual.
usuarios.form.rol=Rol
usuarios.form.estado=Estado
@ -37,6 +40,9 @@ 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.password.actual=La contraseña actual es obligatoria.
usuarios.error.password.actual.incorrecta=La contraseña actual no es correcta.
usuarios.error.password.nueva.requerida=La nueva contraseña es obligatoria.
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.
@ -53,4 +59,9 @@ 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.
usuarios.delete.ok.text=El usuario ha sido eliminado con éxito.
usuarios.impersonate.title=Entrar como usuario
usuarios.impersonate.text=Vas a iniciar sesión como <b>{0}</b>. Podrás volver a tu usuario desde el menú.
usuarios.impersonate.button=Entrar
usuarios.profile.title=Editar perfil
usuarios.profile.success=Perfil actualizado correctamente.

View File

@ -0,0 +1,372 @@
:root {
/* ====== Colores (cámbialos a tu gusto) ====== */
--banner-bg-1: #a5a091;
--banner-bg-2: #8292a8;
--banner-panel-bg: #a1b1b2;
--banner-panel-border: rgba(255, 255, 255, .75);
--text-main: #ffffff;
--text-muted: rgba(255, 255, 255, .8);
--accent-1: #e5745b;
/* salmón */
--accent-2: #92b2a7;
/* tu verde corporativo */
--accent-3: #7cc7ff;
/* toque azul claro */
--card-bg: #ffffff;
--card-border: rgba(8, 42, 67, .18);
--card-title: #0a314b;
--card-chip-bg: var(--accent-1);
--card-chip-text: #ffffff;
--shadow: 0 10px 30px rgba(0, 0, 0, .18);
/* ====== Medidas ====== */
--radius-lg: 18px;
--radius-md: 14px;
--radius-sm: 10px;
--pad: 22px;
}
.ib-loyalty-banner {
position: relative;
width: 100%;
overflow: hidden;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
color: var(--text-main);
/* padding fluido */
padding: clamp(14px, 2.2vw, 22px);
/* Importante: reserva espacio inferior para decoraciones (libro) en desktop */
padding-bottom: clamp(18px, 3.2vw, 46px);
/* Fondo con gradiente + textura sutil */
background:
radial-gradient(1200px 500px at 20% 0%, rgba(124, 199, 255, .20), transparent 60%),
radial-gradient(900px 450px at 90% 20%, rgba(243, 162, 133, .22), transparent 65%),
linear-gradient(135deg, var(--banner-bg-1), var(--banner-bg-2));
}
/* Opcional pero ayuda a que haya “lienzo” para el libro */
@media (min-width: 1101px) {
.ib-loyalty-banner {
min-height: 240px;
}
}
/* ===== Decoraciones generales ===== */
.ib-loyalty-banner .decor {
position: absolute;
inset: 0;
pointer-events: none;
opacity: .95;
}
/* Círculos/“sellos” */
.ib-loyalty-banner .decor::before,
.ib-loyalty-banner .decor::after {
content: "";
position: absolute;
width: 140px;
height: 140px;
border-radius: 50%;
border: 10px solid rgba(243, 162, 133, .65);
box-shadow: inset 0 0 0 10px rgba(255, 255, 255, .08);
}
.ib-loyalty-banner .decor::before {
top: -42px;
right: -50px;
transform: rotate(10deg);
}
.ib-loyalty-banner .decor::after {
bottom: -55px;
left: -55px;
border-color: rgba(243, 162, 133, .55);
}
/* ===== Libros “dibujados” con SVG como background ===== */
.ib-loyalty-banner .book {
position: absolute;
width: 190px;
height: 150px;
opacity: .9;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
background-image: url("/assets/images/open-book.svg");
/* Sombra sin cargarte otros filtros */
filter: drop-shadow(0 10px 18px rgba(0, 0, 0, .25));
}
/* Libro pequeño: lo subimos un poco para que no se quede “bajo” cuando el layout crece */
.ib-loyalty-banner .book.small {
width: 150px;
height: 120px;
left: 22px;
bottom: 18px;
/* antes 10px */
opacity: .85;
z-index: 1;
/* por encima del fondo, por debajo del contenido (contenido z-index 2) */
}
/* ===== Contenido ===== */
.ib-loyalty-inner {
position: relative;
display: grid;
gap: clamp(12px, 2vw, 18px);
/* CLAVE: NO estires ambas columnas a la misma altura */
align-items: start;
z-index: 2;
/* Dos columnas con mínimos reales */
grid-template-columns: minmax(320px, 1.05fr) minmax(320px, .95fr);
}
/* Apila antes para que no se estrangule */
@media (max-width: 1100px) {
.ib-loyalty-inner {
grid-template-columns: 1fr;
}
}
/* Panel principal (logo + textos) */
.ib-loyalty-hero {
border: 2px solid var(--banner-panel-border);
border-radius: var(--radius-lg);
background: var(--banner-panel-bg);
padding: 18px;
position: relative;
}
/*.ib-loyalty-hero::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: -2px;
height: 6px;
background: linear-gradient(90deg, transparent, var(--accent-1), transparent);
opacity: .9;
border-radius: 999px;
}*/
.ib-loyalty-head {
display: flex;
gap: 14px;
align-items: flex-start;
flex-wrap: wrap;
/* responsive */
}
.ib-loyalty-logo {
width: 56px;
height: 56px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
.ib-loyalty-logo img {
width: 38px;
height: 38px;
object-fit: contain;
}
.ib-loyalty-title {
margin: 0;
font-size: clamp(1.05rem, 1.2vw, 1.35rem);
font-weight: 800;
letter-spacing: .2px;
}
.ib-loyalty-sub {
margin: 4px 0 0 0;
color: var(--text-muted);
font-weight: 500;
font-size: clamp(.9rem, 1vw, 1rem);
}
/* ===== Rewards ===== */
.ib-rewards {
border-radius: var(--radius-lg);
padding: 14px 14px 10px;
background: rgba(255, 255, 255, .04);
border: 1px solid rgba(255, 255, 255, .12);
}
.ib-rewards h6 {
margin: 4px 6px 12px;
font-size: .95rem;
letter-spacing: .25px;
opacity: .95;
}
.ib-rewards-grid{
display: grid;
gap: 10px;
/* Nunca más de 3 columnas, pero baja si no hay sitio */
grid-template-columns: repeat(3, minmax(160px, 1fr));
}
@media (max-width: 992px){
.ib-rewards-grid{
grid-template-columns: repeat(2, minmax(160px, 1fr));
}
}
@media (max-width: 520px){
.ib-rewards-grid{
grid-template-columns: 1fr;
}
}
.ib-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-md);
padding: 10px 10px 9px;
color: var(--card-title);
box-shadow: 0 8px 18px rgba(0, 0, 0, .10);
}
.ib-card .range {
font-size: .82rem;
opacity: .85;
font-weight: 700;
margin-bottom: 6px;
}
.ib-card .percent {
font-size: 1.35rem;
font-weight: 900;
line-height: 1;
margin: 2px 0 8px;
}
.ib-card .chip {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
border-radius: 999px;
background: var(--card-chip-bg);
color: var(--card-chip-text);
font-weight: 800;
font-size: .78rem;
letter-spacing: .2px;
}
/* Tarjeta “0%” con borde punteado (si la usas) */
.ib-card.is-empty {
background: rgba(255, 255, 255, .9);
border: 2px dashed rgba(10, 49, 75, .35);
}
/* ===== Ajustes extra para móviles ===== */
@media (max-width: 420px) {
.ib-rewards {
padding: 12px;
}
.ib-card {
padding: 12px;
}
}
/* ===== Libro: reduce presencia o desaparece en móvil ===== */
@media (max-width: 1100px) {
.ib-loyalty-banner .book.small {
opacity: .55;
left: 10px;
bottom: 10px;
width: 120px;
height: 95px;
}
}
@media (max-width: 520px) {
.ib-loyalty-banner .book.small {
display: none;
/* fuera en móviles pequeños */
}
}
/* ===== OPCIONAL: hero más compacto en móvil ===== */
@media (max-width: 520px) {
.ib-loyalty-head {
gap: 10px;
}
.ib-loyalty-logo {
width: 48px;
height: 48px;
}
.ib-loyalty-logo img {
width: 32px;
height: 32px;
}
}
.ib-loyalty-left{
display: flex;
flex-direction: column;
gap: 18px;
height: 100%;
}
.ib-loyalty-stat-card{
/* centra en el espacio libre */
margin-top: auto;
margin-bottom: auto;
margin-left: auto;
margin-right: auto;
/* mismo look que el hero */
background: var(--banner-panel-bg);
border: 2px solid var(--banner-panel-border);
border-radius: var(--radius-lg);
padding: 22px;
max-width: 360px; /* evita que se haga enorme */
}
.ib-loyalty-stat-card h6{
letter-spacing: .4px;
margin-bottom: 12px;
}
.ib-loyalty-stat-card h2{
font-weight: 800;
}
.ib-loyalty-stat-card i{
opacity: .85;
}
@media (max-width: 1100px){
.ib-loyalty-stat-card{
margin-top: 12px;
margin-bottom: 0;
max-width: 100%;
}
}

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#ffffff" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 511 511" xml:space="preserve">
<g>
<path d="M487.5,128.106H479v-24.5c0-2.905-1.678-5.549-4.307-6.786C405.088,64.066,325.408,63.6,255.5,95.371
C185.592,63.6,105.912,64.067,36.307,96.82C33.678,98.057,32,100.701,32,103.606v24.5h-8.5c-12.958,0-23.5,10.542-23.5,23.5v264
c0,12.958,10.542,23.5,23.5,23.5h464c12.958,0,23.5-10.542,23.5-23.5v-264C511,138.648,500.458,128.106,487.5,128.106z
M263,239.583c0-0.009,0-0.019,0-0.028V108.416c64.137-28.707,136.861-28.707,201,0v27.161c0,0.01-0.001,0.02-0.001,0.029
s0.001,0.02,0.001,0.029v244.438c-32.237-13.461-66.371-20.193-100.5-20.193c-34.129,0-68.264,6.732-100.5,20.193V239.583z
M215,96.391c11.187,3.204,22.217,7.198,33,12.025v117.177l-12.34-8.227c-2.52-1.68-5.801-1.68-8.32,0L215,225.593V96.391z
M47,135.626c0-0.007,0.001-0.013,0.001-0.02S47,135.594,47,135.587v-27.171c48.563-21.736,102.046-26.999,153-15.82v32.856
c-26.767-5.505-54.078-6.777-81.328-3.75c-4.117,0.457-7.083,4.165-6.626,8.282c0.458,4.116,4.162,7.085,8.282,6.626
c26.708-2.967,53.479-1.562,79.671,4.165v48.686c-15.912-3.265-32.14-5.067-48.377-5.323c-4.145-0.078-7.552,3.239-7.618,7.38
c-0.065,4.142,3.239,7.552,7.38,7.618c16.331,0.258,32.654,2.164,48.614,5.647v16.66c-43.389-8.909-88.39-6.644-130.748,6.665
c-3.952,1.241-6.148,5.451-4.907,9.403c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347
c40.908-12.852,84.428-14.773,126.252-5.638v2.825c0,2.766,1.522,5.308,3.961,6.612c2.438,1.306,5.398,1.162,7.699-0.372
l19.84-13.227l16.5,11v136.454c-32.237-13.461-66.371-20.193-100.5-20.193c-34.129,0-68.264,6.732-100.5,20.193V135.626z
M224,424.106H23.5c-4.687,0-8.5-3.813-8.5-8.5v-264c0-4.687,3.813-8.5,8.5-8.5H32v248.5v8c0,4.142,3.358,7.5,7.5,7.5H224V424.106z
M57.29,392.106c58.099-22.934,122.32-22.935,180.42,0H57.29z M272,424.106h-33v-17h33V424.106z M453.71,392.106H273.29
C331.389,369.172,395.61,369.172,453.71,392.106z M496,415.606c0,4.687-3.813,8.5-8.5,8.5H287v-17h184.5c4.142,0,7.5-3.358,7.5-7.5
v-8v-248.5h8.5c4.687,0,8.5,3.813,8.5,8.5V415.606z"/>
<path d="M309.96,317.749c-8.302,1.74-16.615,3.911-24.708,6.454c-3.952,1.242-6.148,5.452-4.907,9.403
c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347c7.628-2.396,15.464-4.443,23.288-6.083
c4.054-0.85,6.652-4.825,5.802-8.879C317.989,319.497,314.011,316.9,309.96,317.749z"/>
<path d="M439.502,338.859c3.189,0,6.147-2.051,7.153-5.254c1.241-3.952-0.956-8.162-4.907-9.403
c-32.073-10.076-65.329-13.842-98.844-11.188c-4.129,0.326-7.211,3.938-6.885,8.068s3.935,7.213,8.068,6.885
c31.59-2.499,62.935,1.048,93.165,10.546C438,338.748,438.757,338.859,439.502,338.859z"/>
<path d="M287.498,306.767c0.745,0,1.502-0.112,2.25-0.347c48.249-15.159,99.256-15.159,147.504,0
c3.952,1.24,8.162-0.956,9.403-4.907c1.241-3.952-0.956-8.162-4.907-9.403c-51.191-16.083-105.306-16.083-156.496,0
c-3.952,1.241-6.149,5.451-4.907,9.403C281.352,304.716,284.309,306.767,287.498,306.767z"/>
<path d="M287.498,274.859c0.745,0,1.502-0.112,2.25-0.347c27.681-8.697,56.409-12.412,85.399-11.037
c4.147,0.192,7.651-2.999,7.847-7.137c0.196-4.138-2.999-7.65-7.137-7.847c-30.753-1.456-61.236,2.483-90.605,11.71
c-3.952,1.242-6.149,5.452-4.907,9.403C281.352,272.81,284.309,274.859,287.498,274.859z"/>
<path d="M441.748,260.202c-10.76-3.38-21.846-6.086-32.952-8.043c-4.08-0.719-7.968,2.006-8.688,6.085
c-0.719,4.079,2.005,7.969,6.085,8.688c10.467,1.844,20.917,4.395,31.058,7.581c0.749,0.235,1.505,0.347,2.25,0.347
c3.189,0,6.147-2.051,7.153-5.254C447.896,265.653,445.7,261.443,441.748,260.202z"/>
<path d="M287.498,242.767c0.745,0,1.502-0.112,2.25-0.347c48.249-15.159,99.256-15.159,147.504,0
c3.952,1.24,8.162-0.956,9.403-4.907c1.241-3.952-0.956-8.162-4.907-9.403c-51.191-16.083-105.306-16.083-156.496,0
c-3.952,1.241-6.149,5.451-4.907,9.403C281.352,240.716,284.309,242.767,287.498,242.767z"/>
<path d="M334.678,185.702c-16.732,1.858-33.362,5.36-49.426,10.407c-3.952,1.241-6.148,5.451-4.907,9.403
c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347c15.141-4.757,30.815-8.057,46.585-9.809
c4.117-0.457,7.083-4.165,6.626-8.282S338.79,185.244,334.678,185.702z"/>
<path d="M367.386,199.137c23.725,0.375,47.231,4.17,69.866,11.283c0.748,0.234,1.505,0.347,2.25,0.347
c3.189,0,6.146-2.051,7.153-5.254c1.241-3.952-0.956-8.162-4.907-9.403c-24.015-7.545-48.955-11.572-74.125-11.97
c-4.125-0.078-7.552,3.239-7.618,7.38S363.244,199.072,367.386,199.137z"/>
<path d="M390.671,168.704c4.116,0.46,7.825-2.509,8.282-6.626c0.458-4.117-2.509-7.825-6.626-8.282
c-36.252-4.027-72.278-0.526-107.075,10.406c-3.952,1.242-6.148,5.452-4.907,9.403c1.007,3.204,3.964,5.254,7.153,5.254
c0.745,0,1.502-0.112,2.25-0.347C322.545,168.208,356.5,164.909,390.671,168.704z"/>
<path d="M441.748,164.202c-5.418-1.702-10.96-3.246-16.472-4.588c-4.03-0.98-8.082,1.488-9.062,5.512
c-0.98,4.024,1.488,8.082,5.512,9.062c5.196,1.265,10.419,2.72,15.526,4.324c0.748,0.235,1.505,0.347,2.25,0.347
c3.189,0,6.147-2.051,7.153-5.254C447.896,169.653,445.7,165.443,441.748,164.202z"/>
<path d="M287.498,146.767c0.745,0,1.502-0.112,2.25-0.347c5.103-1.604,10.325-3.058,15.521-4.324
c4.024-0.98,6.492-5.037,5.512-9.062s-5.038-6.492-9.062-5.512c-5.513,1.342-11.053,2.886-16.468,4.587
c-3.951,1.242-6.148,5.452-4.907,9.403C281.352,144.716,284.309,146.767,287.498,146.767z"/>
<path d="M336.329,136.611c34.172-3.796,68.126-0.496,100.923,9.809c0.748,0.234,1.505,0.347,2.25,0.347
c3.189,0,6.146-2.051,7.153-5.254c1.241-3.952-0.956-8.162-4.907-9.403c-34.797-10.933-70.824-14.435-107.076-10.406
c-4.117,0.457-7.083,4.165-6.626,8.282C328.504,134.102,332.21,137.07,336.329,136.611z"/>
<path d="M93.96,317.749c-8.302,1.74-16.615,3.911-24.708,6.454c-3.952,1.242-6.148,5.452-4.907,9.403
c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347c7.628-2.396,15.464-4.443,23.288-6.083
c4.054-0.85,6.652-4.825,5.802-8.879S98.011,316.9,93.96,317.749z"/>
<path d="M223.502,338.859c3.189,0,6.147-2.051,7.153-5.254c1.241-3.952-0.956-8.162-4.907-9.403
c-32.073-10.076-65.331-13.842-98.844-11.188c-4.129,0.326-7.211,3.938-6.885,8.068s3.934,7.213,8.068,6.885
c31.591-2.499,62.935,1.048,93.165,10.546C222,338.748,222.757,338.859,223.502,338.859z"/>
<path d="M71.498,306.767c0.745,0,1.502-0.112,2.25-0.347c48.249-15.159,99.256-15.159,147.504,0
c3.952,1.24,8.162-0.956,9.403-4.907c1.241-3.952-0.956-8.162-4.907-9.403c-51.191-16.083-105.307-16.083-156.496,0
c-3.952,1.241-6.149,5.451-4.907,9.403C65.352,304.716,68.309,306.767,71.498,306.767z"/>
<path d="M71.498,274.859c0.745,0,1.502-0.112,2.25-0.347c27.681-8.697,56.411-12.412,85.399-11.037
c4.158,0.192,7.65-2.999,7.847-7.137c0.196-4.138-2.999-7.65-7.137-7.847c-30.756-1.456-61.236,2.483-90.605,11.71
c-3.952,1.242-6.149,5.452-4.907,9.403C65.352,272.81,68.309,274.859,71.498,274.859z"/>
<path d="M190.194,266.932c10.467,1.844,20.917,4.395,31.058,7.581c0.749,0.235,1.505,0.347,2.25,0.347
c3.189,0,6.147-2.051,7.153-5.254c1.241-3.952-0.956-8.162-4.907-9.403c-10.76-3.38-21.846-6.086-32.952-8.043
c-4.079-0.719-7.969,2.006-8.688,6.085C183.39,262.323,186.114,266.213,190.194,266.932z"/>
<path d="M118.678,185.702c-16.732,1.858-33.362,5.36-49.426,10.407c-3.952,1.241-6.148,5.451-4.907,9.403
c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347c15.141-4.757,30.815-8.057,46.585-9.809
c4.117-0.457,7.083-4.165,6.626-8.282C126.503,188.212,122.788,185.244,118.678,185.702z"/>
<path d="M64.345,173.605c1.007,3.204,3.964,5.254,7.153,5.254c0.745,0,1.502-0.112,2.25-0.347
c32.797-10.305,66.752-13.604,100.923-9.809c4.116,0.46,7.825-2.509,8.282-6.626c0.458-4.117-2.509-7.825-6.626-8.282
c-36.253-4.027-72.278-0.526-107.075,10.406C65.3,165.444,63.104,169.654,64.345,173.605z"/>
<path d="M71.498,146.767c0.745,0,1.502-0.112,2.25-0.347c5.103-1.604,10.325-3.058,15.521-4.324
c4.024-0.98,6.492-5.037,5.512-9.062s-5.038-6.492-9.062-5.512c-5.513,1.342-11.053,2.886-16.468,4.587
c-3.951,1.242-6.148,5.452-4.907,9.403C65.352,144.716,68.309,146.767,71.498,146.767z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@ -0,0 +1,34 @@
import {formateaMoneda} from "./utils.js";
$(() => {
// Contador animado
function counter() {
var counter = document.querySelectorAll(".counter-value");
var speed = 250; // The lower the slower
counter &&
Array.from(counter).forEach(function (counter_value) {
function updateCount() {
var target = +counter_value.getAttribute("data-target");
var count = +counter_value.innerText;
var inc = target / speed;
if (inc < 1) {
inc = 1;
}
// Check if target is reached
if (count < target) {
// Add inc to count and output in counter_value
counter_value.innerText = (count + inc).toFixed(0);
// Call function every ms
setTimeout(updateCount, 1);
} else {
counter_value.innerText = formateaMoneda(target);
}
formateaMoneda(counter_value.innerText);
}
updateCount();
});
}
counter();
})

View File

@ -318,6 +318,50 @@ export default class PresupuestoWizard {
document.body.removeChild(a);
});
$(document)
.off('click.login-required', '.btn-login-required')
.on('click.login-required', '.btn-login-required', async (e) => {
e.preventDefault();
const rawId = this.opts.presupuestoId || window.PRESUPUESTO_ID || $('#presupuesto_id').val();
const presupuestoId = rawId ? parseInt(rawId, 10) : null;
if (!presupuestoId || Number.isNaN(presupuestoId)) {
Swal.fire({
icon: 'error',
title: 'No se encontró el presupuesto',
text: 'Vuelve a generar el resumen e inténtalo de nuevo.',
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
cancelButton: 'btn btn-light'
},
});
return;
}
try {
await $.ajax({
url: '/presupuesto/public/prepare-claim',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ presupuestoId })
});
window.location.assign('/presupuesto/claim');
} catch (err) {
Swal.fire({
icon: 'error',
title: 'No se pudo continuar',
text: 'Inténtalo de nuevo en unos segundos.',
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
cancelButton: 'btn btn-light'
},
});
}
});
}
@ -1853,6 +1897,15 @@ export default class PresupuestoWizard {
...result,
};
if (!this.formData.servicios.servicios.some(s => s.id === "marcapaginas") && result.precio > 0) {
this.formData.servicios.servicios.push({
id: "marcapaginas",
label: $(`label[for="marcapaginas"] .service-title`).text().trim(),
units: 1,
price: result.precio,
});
}
this.#cacheFormData();
});

View File

@ -147,6 +147,55 @@ $(() => {
});
});
// Botón "Entrar como"
$(document).on('click', '.btn-impersonate-user', function (e) {
e.preventDefault();
const username = $(this).data('username');
const title = window.languageBundle.get(['usuarios.impersonate.title']) || 'Entrar como usuario';
const textTpl = window.languageBundle.get(['usuarios.impersonate.text'])
|| 'Vas a iniciar sesión como <b>{0}</b>.';
const confirmText = window.languageBundle.get(['usuarios.impersonate.button']) || 'Entrar';
Swal.fire({
title,
html: textTpl.replace('{0}', username),
icon: 'warning',
showCancelButton: true,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-info w-xs mt-2',
cancelButton: 'btn btn-light w-xs mt-2'
},
confirmButtonText: confirmText,
cancelButtonText: window.languageBundle.get(['app.cancelar']) || 'Cancelar',
}).then((result) => {
if (!result.isConfirmed) return;
$.ajax({
url: '/impersonate',
type: 'POST',
data: { username },
success: function () {
window.location.href = '/';
},
error: function (xhr) {
const msg = (xhr.responseJSON && xhr.responseJSON.message)
|| 'No se pudo iniciar sesión como ese usuario.';
Swal.fire({
icon: 'error',
title: 'No se pudo suplantar',
text: msg,
buttonsStyling: false,
customClass: {
confirmButton: 'btn btn-secondary me-2',
},
});
}
});
});
});
// Submit del form en el modal
$(document).on('submit', '#userForm', function (e) {

View File

@ -0,0 +1,105 @@
<div id="fidelity-banner" th:fragment="home-container-user">
<div class="ib-loyalty-banner">
<!-- Decoraciones -->
<div class="decor"></div>
<div class="book small"></div>
<div class="ib-loyalty-inner">
<!-- ===================== -->
<!-- COLUMNA IZQUIERDA -->
<!-- ===================== -->
<div class="ib-loyalty-left">
<!-- PANEL SUPERIOR: TÍTULO -->
<div class="ib-loyalty-hero">
<div class="ib-loyalty-head">
<div class="ib-loyalty-logo">
<img src="/assets/images/logo-sm.png" alt="Logo" />
</div>
<div>
<h3 class="ib-loyalty-title">Programa de Fidelidad</h3>
<p class="ib-loyalty-sub">
Aumenta tus compras en los últimos 12 meses y obtén descuentos automáticos.
</p>
</div>
</div>
</div>
<!-- PANEL INFERIOR: ESTADÍSTICA (RECUADRO INDEPENDIENTE) -->
<div class="ib-loyalty-stat-card">
<h6 class="text-uppercase fs-13 mb-3">
<span th:text="#{pedido.gasto-anual}">Gasto últimos 12 meses</span>
<i class="ri-arrow-up-circle-line text-success fs-18 float-end align-middle"></i>
</h6>
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<i class="ri-money-euro-circle-line display-6 "></i>
</div>
<div class="flex-grow-1 ms-3">
<h2 class="mb-0">
<h2 class="mb-0"><span class="counter-value" th:attr="data-target=${totalGastado}">0</span></h2>
</h2>
</div>
</div>
</div>
</div>
<!-- ===================== -->
<!-- COLUMNA DERECHA -->
<!-- ===================== -->
<div class="ib-rewards">
<h6>Recompensas</h6>
<div class="ib-rewards-grid">
<div class="ib-card">
<div class="range">Menos de 1.200€</div>
<div class="percent">0%</div>
<span class="chip">Descuento</span>
</div>
<div class="ib-card">
<div class="range">1.200€ 1.999€</div>
<div class="percent">1%</div>
<span class="chip">Descuento</span>
</div>
<div class="ib-card">
<div class="range">2.000€ 2.999€</div>
<div class="percent">2%</div>
<span class="chip">Descuento</span>
</div>
<div class="ib-card">
<div class="range">3.000€ 3.999€</div>
<div class="percent">3%</div>
<span class="chip">Descuento</span>
</div>
<div class="ib-card">
<div class="range">4.000€ 4.999€</div>
<div class="percent">4%</div>
<span class="chip">Descuento</span>
</div>
<div class="ib-card">
<div class="range">Más de 5.000€</div>
<div class="percent">5%</div>
<span class="chip">Descuento</span>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@ -8,6 +8,8 @@
<th:block layout:fragment="pagecss">
<link th:href="@{/assets/css/presupuestador.css}" rel="stylesheet"
th:unless="${#authorization.expression('isAuthenticated()')}" />
<link th:href="@{/assets/css/home.css}" rel="stylesheet"
th:if="${#authorization.expression('isAuthenticated()')}" />
</th:block>
</head>
@ -22,7 +24,7 @@
<th:block layout:fragment="content">
<div th:if="${#authorization.expression('isAuthenticated()')}">
<div class="container-fluid">
<div th:insert="~{imprimelibros/home/home-container-user :: home-container-user}"></div>
</div>
</div>
<div th:unless="${#authorization.expression('isAuthenticated()')}">
@ -41,6 +43,9 @@
<script type="module"
th:src="@{/assets/js/pages/imprimelibros/presupuestador/presupuesto-marcapaginas.js}"></script>
</div>
<div th:if="${#authorization.expression('isAuthenticated()')}">
<script type="module" th:src="@{/assets/js/pages/imprimelibros/counter-widget.js}"></script>
</div>
<script th:inline="javascript">
window.languageBundle = /*[[${languageBundle}]]*/ {};
</script>

View File

@ -100,9 +100,14 @@
<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" 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" th:text="#{app.mensajes}">Mensajes</span></a>
<div sec:authorize="hasRole('PREVIOUS_ADMINISTRATOR')">
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#"
onclick="document.getElementById('exitImpersonationForm').submit(); return false;">
<i class="mdi mdi-account-switch text-muted fs-16 align-middle me-1"></i>
<span class="align-middle" th:text="#{app.impersonate.exit}">Volver a mi usuario</span>
</a>
</div>
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="#"
onclick="document.getElementById('logoutForm').submit(); return false;">
@ -127,7 +132,10 @@
<form id="logoutForm" th:action="@{/logout}" method="post" class="d-none">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
<form id="exitImpersonationForm" th:action="@{/impersonate/exit}" method="post" class="d-none">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
</form>
</header>
</div>
</div>

View File

@ -0,0 +1,107 @@
<!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}" />
</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()')}">
<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="#{app.perfil}">Perfil</li>
</ol>
</nav>
<div class="container-fluid">
<div class="row">
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="card-title mb-0" th:text="#{usuarios.profile.title}">Editar perfil</h5>
</div>
<div class="card-body">
<div th:if="${success}" class="alert alert-success"
th:text="#{usuarios.profile.success}">Perfil actualizado.</div>
<form id="profileForm" novalidate th:action="@{/pages-profile}" th:object="${user}"
method="post">
<div th:if="${#fields.hasGlobalErrors()}" class="alert alert-danger">
<div th:each="e : ${#fields.globalErrors()}" th:text="${e}"></div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.nombre}" for="fullName">Nombre</label>
<input type="text" class="form-control" id="fullName" 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="mb-3">
<label th:text="#{usuarios.form.email}" for="userName">Correo electrónico</label>
<input type="email" class="form-control" id="userName" 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>
<hr class="my-4">
<div class="mb-3">
<label th:text="#{usuarios.form.password.actual}" for="currentPassword">Contraseña actual</label>
<input type="password" class="form-control" id="currentPassword"
th:field="*{currentPassword}"
th:classappend="${#fields.hasErrors('currentPassword')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('currentPassword')}"
th:errors="*{currentPassword}">Error</div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.password.nueva}" for="newPassword">Nueva contraseña</label>
<input type="password" class="form-control" id="newPassword"
th:field="*{newPassword}"
th:classappend="${#fields.hasErrors('newPassword')} ? ' is-invalid'">
<div class="text-muted" th:text="#{usuarios.form.password.nota}">
Solo podrás cambiar la contraseña si indicas la actual.
</div>
<div class="invalid-feedback" th:if="${#fields.hasErrors('newPassword')}"
th:errors="*{newPassword}">Error</div>
</div>
<div class="mb-3">
<label th:text="#{usuarios.form.confirmarPassword}" for="confirmPassword">Confirmar contraseña</label>
<input type="password" class="form-control" id="confirmPassword"
th:field="*{confirmPassword}"
th:classappend="${#fields.hasErrors('confirmPassword')} ? ' is-invalid'">
<div class="invalid-feedback" th:if="${#fields.hasErrors('confirmPassword')}"
th:errors="*{confirmPassword}">Error</div>
</div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-secondary" th:text="#{app.guardar}">Guardar</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</th:block>
<th:block layout:fragment="modal" />
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
</body>
</html>