Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions backend/src/main/java/com/versus/api/admin/AdminService.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

Expand Down Expand Up @@ -46,13 +47,31 @@ public class AdminService {
@Transactional(readOnly = true)
public AdminUserPageResponse getUsers(int page, int size, String search, Role role, Boolean active) {
PageRequest pageable = PageRequest.of(page, size, Sort.by("createdAt").descending());
String searchParam = (search != null && !search.isBlank()) ? search : null;
Page<User> result = users.searchUsers(searchParam, role, active, pageable);
String searchParam = (search != null && !search.isBlank()) ? search.trim() : null;
Page<User> result = users.findAll(userFilter(searchParam, role, active), pageable);
List<AdminUserResponse> items = result.getContent().stream().map(this::toDto).toList();
return new AdminUserPageResponse(items, result.getNumber(), result.getSize(),
result.getTotalElements(), result.getTotalPages());
}

private static Specification<User> userFilter(String search, Role role, Boolean active) {
Specification<User> spec = Specification.where(null);
if (search != null) {
String pattern = "%" + search.toLowerCase() + "%";
spec = spec.and((root, q, cb) -> cb.or(
cb.like(cb.lower(root.get("username")), pattern),
cb.like(cb.lower(root.get("email")), pattern)
));
}
if (role != null) {
spec = spec.and((root, q, cb) -> cb.equal(root.get("role"), role));
}
if (active != null) {
spec = spec.and((root, q, cb) -> cb.equal(root.get("isActive"), active));
}
return spec;
}

@Transactional
public AdminUserResponse updateRole(UUID adminId, UUID targetId, Role newRole) {
if (adminId.equals(targetId)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
package com.versus.api.users.repo;

import com.versus.api.users.Role;
import com.versus.api.users.domain.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;
import java.util.UUID;
Expand All @@ -22,16 +19,4 @@ public interface UserRepository extends JpaRepository<User, UUID>, JpaSpecificat
Optional<User> findByPasswordResetToken(String passwordResetToken);
long countByIsActive(boolean isActive);
Page<User> findByUsernameContainingIgnoreCaseAndIsActiveTrue(String username, Pageable pageable);

@Query("""
SELECT u FROM User u
WHERE (:search IS NULL OR LOWER(u.username) LIKE LOWER(CONCAT('%', :search, '%'))
OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%')))
AND (:role IS NULL OR u.role = :role)
AND (:active IS NULL OR u.isActive = :active)
""")
Page<User> searchUsers(@Param("search") String search,
@Param("role") Role role,
@Param("active") Boolean active,
Pageable pageable);
}
47 changes: 29 additions & 18 deletions backend/src/test/java/com/versus/api/admin/AdminServiceTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;

import java.time.Instant;
import java.time.temporal.ChronoUnit;
Expand Down Expand Up @@ -75,24 +76,34 @@ private User user(UUID id, String username, Role role) {
@Nested
class GetUsers {

@DisplayName("Búsqueda en blanco normaliza a null en el repositorio")
@DisplayName("Sin filtros, llama a findAll con una Specification no-null y devuelve resultado vacío")
@Test
void busquedaEnBlanco_pasaNullAlRepositorio() {
when(users.searchUsers(any(), any(), any(), any())).thenReturn(Page.empty());
ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class);
void sinFiltros_invocaFindAll() {
when(users.findAll(any(Specification.class), any(Pageable.class))).thenReturn(Page.empty());

AdminUserPageResponse response = adminService.getUsers(0, 20, null, null, null);

assertThat(response.items()).isEmpty();
assertThat(response.totalElements()).isZero();
verify(users).findAll(any(Specification.class), any(Pageable.class));
}

@DisplayName("Búsqueda en blanco no rompe (se trata como sin búsqueda)")
@Test
void busquedaEnBlanco_noRompe() {
when(users.findAll(any(Specification.class), any(Pageable.class))).thenReturn(Page.empty());

adminService.getUsers(0, 20, " ", null, null);

verify(users).searchUsers(captor.capture(), any(), any(), any());
assertThat(captor.getValue()).isNull();
verify(users).findAll(any(Specification.class), any(Pageable.class));
}

@DisplayName("Resultado paginado se mapea correctamente al DTO")
@Test
void resultadoPaginado_seMapeaCorrectamente() {
User u = user(TARGET_ID, "alice", Role.PLAYER);
Page<User> page = new PageImpl<>(List.of(u));
when(users.searchUsers(any(), any(), any(), any())).thenReturn(page);
when(users.findAll(any(Specification.class), any(Pageable.class))).thenReturn(page);

AdminUserPageResponse response = adminService.getUsers(0, 20, null, null, null);

Expand All @@ -102,21 +113,21 @@ void resultadoPaginado_seMapeaCorrectamente() {
assertThat(response.items().get(0).role()).isEqualTo("PLAYER");
}

@DisplayName("Con filtros, se pasan al repositorio correctamente")
@DisplayName("Con filtros, la consulta usa el Pageable con orden por createdAt descendente")
@Test
void conFiltros_sePassanAlRepositorio() {
when(users.searchUsers(any(), any(), any(), any())).thenReturn(Page.empty());
ArgumentCaptor<String> searchCaptor = ArgumentCaptor.forClass(String.class);
ArgumentCaptor<Role> roleCaptor = ArgumentCaptor.forClass(Role.class);
ArgumentCaptor<Boolean> activeCaptor = ArgumentCaptor.forClass(Boolean.class);
void conFiltros_pageableOrdenadoPorCreatedAt() {
when(users.findAll(any(Specification.class), any(Pageable.class))).thenReturn(Page.empty());
ArgumentCaptor<Pageable> pageableCaptor = ArgumentCaptor.forClass(Pageable.class);

adminService.getUsers(0, 10, "alice", Role.ADMIN, true);

verify(users).searchUsers(searchCaptor.capture(), roleCaptor.capture(),
activeCaptor.capture(), any());
assertThat(searchCaptor.getValue()).isEqualTo("alice");
assertThat(roleCaptor.getValue()).isEqualTo(Role.ADMIN);
assertThat(activeCaptor.getValue()).isTrue();
verify(users).findAll(any(Specification.class), pageableCaptor.capture());
Pageable used = pageableCaptor.getValue();
assertThat(used.getPageNumber()).isZero();
assertThat(used.getPageSize()).isEqualTo(10);
assertThat(used.getSort().getOrderFor("createdAt"))
.isNotNull()
.satisfies(o -> assertThat(o.isDescending()).isTrue());
}
}

Expand Down
30 changes: 12 additions & 18 deletions frontend/src/app/features/admin/components/sidebar/sidebar.html
Original file line number Diff line number Diff line change
@@ -1,37 +1,31 @@
<aside class="vs-admin__side">
<div class="vs-mark" style="padding: 0 8px 18px; border-bottom: 1px solid var(--vs-border); margin-bottom: 14px; font-size: 18px">
VER<span class="vs-mark__slash">/</span>SUS
<div class="vs-admin__brand">
VER<span class="vs-admin__brand__slash">/</span>SUS
</div>

@for (section of sections; track section.label) {
<div class="vs-admin__nav-section">{{ section.label }}</div>
@for (item of section.items; track item.key) {
@if (item.route) {
<a class="vs-admin__nav-item"
[routerLink]="item.route"
routerLinkActive="active">
{{ item.label }}
</a>
} @else {
<span class="vs-admin__nav-item vs-admin__nav-item--disabled">
{{ item.label }}
</span>
}
<a class="vs-admin__nav-item"
[routerLink]="item.route"
routerLinkActive="active">
{{ item.label }}
</a>
}
}

<div style="flex: 1"></div>
<div class="vs-admin__spacer"></div>

<div style="padding: 12px; border-top: 1px solid var(--vs-border); display: flex; align-items: center; gap: 10px">
<div class="vs-admin__profile">
<vs-avatar
[name]="auth.user()?.username ?? 'AD'"
[avatarUrl]="auth.user()?.avatarUrl"
[sizePx]="28"
[fontSizePx]="11"
/>
<div style="flex: 1">
<div style="font-size: 12px; font-weight: 600">{{ auth.user()?.username ?? '—' }}</div>
<div style="font-size: 10px; color: var(--vs-text-muted)">{{ auth.user()?.role ?? 'ADMIN' }}</div>
<div class="vs-admin__profile-info">
<div class="vs-admin__profile-name">{{ auth.user()?.username ?? '—' }}</div>
<div class="vs-admin__profile-role">{{ roleLabel(auth.user()?.role) }}</div>
</div>
</div>
</aside>
151 changes: 151 additions & 0 deletions frontend/src/app/features/admin/components/sidebar/sidebar.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
:host {
display: block;
grid-column: 1;
}

.vs-admin__side {
background: var(--vs-bg-deeper);
border-right: 1px solid var(--vs-border);
padding: 24px 18px;
display: flex;
flex-direction: column;
gap: 6px;
min-height: 100vh;
width: 100%;
box-sizing: border-box;
}

.vs-admin__brand {
padding: 0 8px 18px;
margin-bottom: 14px;
font-size: 18px;
font-family: 'Bebas Neue', sans-serif;
letter-spacing: 0.18em;
color: var(--vs-text-primary);
border-bottom: 1px solid var(--vs-border);
display: flex;
align-items: center;

&__slash {
color: var(--vs-accent-red);
margin: 0 4px;
font-weight: bold;
}
}

.vs-admin__nav-section {
font-size: 10px;
letter-spacing: 0.2em;
color: var(--vs-text-muted);
text-transform: uppercase;
padding: 14px 12px 6px;
}

.vs-admin__nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--vs-radius-sm);
font-size: 13px;
color: var(--vs-text-secondary);
cursor: pointer;
text-decoration: none;
border-left: 2px solid transparent;
transition: background 150ms, color 150ms, border-color 150ms;

&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
background: currentColor;
opacity: 0.35;
flex-shrink: 0;
}

&:hover {
background: rgba(255, 255, 255, 0.04);
color: var(--vs-text-primary);

&::before { opacity: 0.7; }
}

&.active {
background: rgba(67, 97, 238, 0.12);
color: var(--vs-text-primary);
border-left-color: var(--vs-accent-blue);

&::before {
background: var(--vs-accent-blue);
opacity: 1;
}
}
}

.vs-admin__spacer { flex: 1; }

.vs-admin__profile {
padding: 12px;
border-top: 1px solid var(--vs-border);
display: flex;
align-items: center;
gap: 10px;
}

.vs-admin__profile-info {
flex: 1;
min-width: 0;
}

.vs-admin__profile-name {
font-size: 12px;
font-weight: 600;
color: var(--vs-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.vs-admin__profile-role {
font-size: 10px;
color: var(--vs-text-muted);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-top: 2px;
}

@media (max-width: 768px) {
:host { grid-column: 1 / -1; }

.vs-admin__side {
min-height: auto;
border-right: none;
border-bottom: 1px solid var(--vs-border);
padding: 16px;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
}

.vs-admin__brand {
flex-basis: 100%;
margin-bottom: 8px;
padding-bottom: 12px;
}

.vs-admin__nav-section {
flex-basis: 100%;
padding: 8px 4px 2px;
}

.vs-admin__nav-item { flex: 0 1 auto; padding: 8px 12px; }

.vs-admin__spacer { display: none; }

.vs-admin__profile {
flex-basis: 100%;
border-top: 1px solid var(--vs-border);
margin-top: 8px;
}
}
Loading
Loading