diff --git a/backend/src/main/java/com/versus/api/admin/AdminService.java b/backend/src/main/java/com/versus/api/admin/AdminService.java index 5ed633c..790fdd6 100644 --- a/backend/src/main/java/com/versus/api/admin/AdminService.java +++ b/backend/src/main/java/com/versus/api/admin/AdminService.java @@ -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; @@ -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 result = users.searchUsers(searchParam, role, active, pageable); + String searchParam = (search != null && !search.isBlank()) ? search.trim() : null; + Page result = users.findAll(userFilter(searchParam, role, active), pageable); List items = result.getContent().stream().map(this::toDto).toList(); return new AdminUserPageResponse(items, result.getNumber(), result.getSize(), result.getTotalElements(), result.getTotalPages()); } + private static Specification userFilter(String search, Role role, Boolean active) { + Specification 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)) { diff --git a/backend/src/main/java/com/versus/api/users/repo/UserRepository.java b/backend/src/main/java/com/versus/api/users/repo/UserRepository.java index 0e54025..abca8bb 100644 --- a/backend/src/main/java/com/versus/api/users/repo/UserRepository.java +++ b/backend/src/main/java/com/versus/api/users/repo/UserRepository.java @@ -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; @@ -22,16 +19,4 @@ public interface UserRepository extends JpaRepository, JpaSpecificat Optional findByPasswordResetToken(String passwordResetToken); long countByIsActive(boolean isActive); Page 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 searchUsers(@Param("search") String search, - @Param("role") Role role, - @Param("active") Boolean active, - Pageable pageable); } diff --git a/backend/src/test/java/com/versus/api/admin/AdminServiceTest.java b/backend/src/test/java/com/versus/api/admin/AdminServiceTest.java index a25d9ee..578e956 100644 --- a/backend/src/test/java/com/versus/api/admin/AdminServiceTest.java +++ b/backend/src/test/java/com/versus/api/admin/AdminServiceTest.java @@ -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; @@ -75,16 +76,26 @@ 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 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") @@ -92,7 +103,7 @@ void busquedaEnBlanco_pasaNullAlRepositorio() { void resultadoPaginado_seMapeaCorrectamente() { User u = user(TARGET_ID, "alice", Role.PLAYER); Page 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); @@ -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 searchCaptor = ArgumentCaptor.forClass(String.class); - ArgumentCaptor roleCaptor = ArgumentCaptor.forClass(Role.class); - ArgumentCaptor activeCaptor = ArgumentCaptor.forClass(Boolean.class); + void conFiltros_pageableOrdenadoPorCreatedAt() { + when(users.findAll(any(Specification.class), any(Pageable.class))).thenReturn(Page.empty()); + ArgumentCaptor 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()); } } diff --git a/frontend/src/app/features/admin/components/sidebar/sidebar.html b/frontend/src/app/features/admin/components/sidebar/sidebar.html index 8c04c3b..a366139 100644 --- a/frontend/src/app/features/admin/components/sidebar/sidebar.html +++ b/frontend/src/app/features/admin/components/sidebar/sidebar.html @@ -1,37 +1,31 @@ diff --git a/frontend/src/app/features/admin/components/sidebar/sidebar.scss b/frontend/src/app/features/admin/components/sidebar/sidebar.scss new file mode 100644 index 0000000..ad74df3 --- /dev/null +++ b/frontend/src/app/features/admin/components/sidebar/sidebar.scss @@ -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; + } +} diff --git a/frontend/src/app/features/admin/components/sidebar/sidebar.ts b/frontend/src/app/features/admin/components/sidebar/sidebar.ts index e5a0fb9..8acbd93 100644 --- a/frontend/src/app/features/admin/components/sidebar/sidebar.ts +++ b/frontend/src/app/features/admin/components/sidebar/sidebar.ts @@ -9,13 +9,9 @@ export type AdminNavKey = | 'spiders' | 'reports' | 'proposals' - | 'users' - | 'quest' - | 'rank' - | 'cfg' - | 'logs'; + | 'users'; -const ROUTES: Partial> = { +const ROUTES: Record = { dash: '/admin/dashboard', spiders: '/admin/spiders', reports: '/admin/reports', @@ -26,7 +22,7 @@ const ROUTES: Partial> = { interface AdminNavItem { key: AdminNavKey; label: string; - route: string | null; + route: string; } interface AdminNavSection { @@ -39,6 +35,7 @@ interface AdminNavSection { standalone: true, imports: [RouterLink, RouterLinkActive, AvatarComponent], templateUrl: './sidebar.html', + styleUrl: './sidebar.scss', }) export class AdminSidebarComponent { active = input('dash'); @@ -72,22 +69,13 @@ export class AdminSidebarComponent { label: 'GESTION', items: [ this.navItem('users', 'Usuarios'), - this.navItem('quest', 'Preguntas'), - this.navItem('rank', 'Rankings'), - ], - }, - { - label: 'SISTEMA', - items: [ - this.navItem('cfg', 'Configuracion'), - this.navItem('logs', 'Logs'), ], }, ]; } private navItem(key: AdminNavKey, label: string): AdminNavItem { - return { key, label, route: ROUTES[key] ?? null }; + return { key, label, route: ROUTES[key] }; } roleLabel(role: Role | undefined): string { @@ -98,8 +86,4 @@ export class AdminSidebarComponent { }; return role ? labels[role] : '—'; } - - initials(name: string): string { - return name.slice(0, 2).toUpperCase(); - } } diff --git a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss index 238dfac..00fc2aa 100644 --- a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss +++ b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss @@ -5,51 +5,6 @@ background: var(--vs-bg-base); } -.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; -} - -.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; - transition: background 150ms, color 150ms; - - &:hover { background: rgba(255,255,255,0.04); color: var(--vs-text-primary); } - - &.active { - background: rgba(67,97,238,0.12); - color: var(--vs-text-primary); - border-left: 2px solid var(--vs-accent-blue); - padding-left: 10px; - } - - &--disabled { - opacity: 0.35; - cursor: not-allowed; - pointer-events: none; - } -} - -.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__main { padding: 28px 32px; display: flex; @@ -256,15 +211,6 @@ grid-template-columns: 1fr; } - .vs-admin__side { - border-right: none; - border-bottom: 1px solid var(--vs-border); - padding: 16px; - flex-direction: row; - flex-wrap: wrap; - gap: 4px; - } - .vs-admin__main { padding: 20px 16px; } diff --git a/frontend/src/app/features/admin/pages/users/admin-users.html b/frontend/src/app/features/admin/pages/users/admin-users.html index 4df6a1d..ad67f96 100644 --- a/frontend/src/app/features/admin/pages/users/admin-users.html +++ b/frontend/src/app/features/admin/pages/users/admin-users.html @@ -30,6 +30,10 @@

USUARIOS

{{ users().length }} / {{ totalElements() }} + @if (errorMsg()) { +
{{ errorMsg() }}
+ } +
@@ -93,7 +97,9 @@

USUARIOS

@if (users().length === 0) { } diff --git a/frontend/src/app/features/admin/pages/users/admin-users.ts b/frontend/src/app/features/admin/pages/users/admin-users.ts index f186551..f1e40ff 100644 --- a/frontend/src/app/features/admin/pages/users/admin-users.ts +++ b/frontend/src/app/features/admin/pages/users/admin-users.ts @@ -26,6 +26,9 @@ export class AdminUsers implements OnInit { roleFilter = signal(null); activeFilter = signal(null); + loading = signal(false); + errorMsg = signal(null); + editingRoleId = signal(null); private searchTimeout: ReturnType | null = null; @@ -99,16 +102,37 @@ export class AdminUsers implements OnInit { } private load(): void { + this.loading.set(true); + this.errorMsg.set(null); this.adminService.listUsers({ page: this.page(), size: this.size, search: this.search() || undefined, role: this.roleFilter() ?? undefined, active: this.activeFilter() ?? undefined, - }).subscribe(result => { - this.users.set(result.items); - this.totalElements.set(result.totalElements); - this.totalPages.set(result.totalPages); + }).subscribe({ + next: result => { + this.users.set(result.items); + this.totalElements.set(result.totalElements); + this.totalPages.set(result.totalPages); + this.loading.set(false); + }, + error: err => { + this.users.set([]); + this.totalElements.set(0); + this.totalPages.set(0); + this.loading.set(false); + const status = err?.status ?? 0; + if (status === 401) { + this.errorMsg.set('Sesión expirada. Vuelve a iniciar sesión.'); + } else if (status === 403) { + this.errorMsg.set('No tienes permisos para ver la lista de usuarios.'); + } else if (status === 0) { + this.errorMsg.set('No se puede conectar con el servidor.'); + } else { + this.errorMsg.set(err?.error?.message ?? `Error ${status}: no se pudieron cargar los usuarios.`); + } + }, }); } }
- Sin resultados. + @if (loading()) { Cargando usuarios… } + @else if (errorMsg()) { — } + @else { Sin resultados. }