From 4989b74fcb78a5ed7743c3e95b0c6d6744d54657 Mon Sep 17 00:00:00 2001 From: ilyas2022 Date: Tue, 26 May 2026 13:23:29 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix(#99):=20fix=20admin=20panel=20=E2=80=94?= =?UTF-8?q?=20sidebar=20navigation,=20real=20data=20integration,=20backend?= =?UTF-8?q?=20user=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix sidebar: nav items now use [routerLink]/routerLinkActive, show real admin username, disabled items (Preguntas/Rankings/etc.) are dimmed and non-clickable - Backend: add AdminController (/api/admin/users, /api/admin/stats) + AdminService with paginated user list, role update, status toggle (suspend/unsuspend) - Backend: add AdminStatsResponse, AdminUserResponse, UpdateRoleRequest, UpdateStatusRequest DTOs - Backend: extend UserRepository with findAdminUsers JPQL query (search + role filter) and countByIsActiveTrue; add countByCreatedAtGreaterThanEqual to MatchRepository; add countByStatus to QuestionReportRepository - Frontend: add AdminService (HTTP) and admin.models.ts (typed interfaces) - Dashboard: loads real stats + spiders from backend instead of mock data - Spiders: loads real spider list, filter works, run button calls POST /api/admin/spiders/{name}/run - Reports: loads real reports from /api/moderation/reports, status filter tabs reload, DESCARTAR/ELIMINAR buttons call resolve endpoint - Users: loads real users with live search + role filter, role dropdown and SUSPENDER/ACTIVAR buttons call backend Co-Authored-By: Claude Sonnet 4.6 --- .../com/versus/api/admin/AdminController.java | 58 ++++++ .../com/versus/api/admin/AdminService.java | 105 +++++++++++ .../api/admin/dto/AdminStatsResponse.java | 9 + .../api/admin/dto/AdminUserResponse.java | 14 ++ .../api/admin/dto/UpdateRoleRequest.java | 6 + .../api/admin/dto/UpdateStatusRequest.java | 5 + .../api/match/repo/MatchRepository.java | 2 + .../repo/QuestionReportRepository.java | 2 + .../versus/api/users/repo/UserRepository.java | 20 ++ frontend/src/app/core/models/admin.models.ts | 54 ++++++ .../src/app/core/services/admin.service.ts | 84 +++++++++ .../admin/components/sidebar/sidebar.html | 21 ++- .../admin/components/sidebar/sidebar.ts | 75 ++++++-- .../pages/dashboard/admin-dashboard.html | 71 +++---- .../pages/dashboard/admin-dashboard.scss | 7 + .../admin/pages/dashboard/admin-dashboard.ts | 144 ++++++++++---- .../admin/pages/reports/admin-reports.html | 98 ++++++---- .../admin/pages/reports/admin-reports.ts | 81 ++++++-- .../admin/pages/spiders/admin-spiders.html | 138 +++++++------- .../admin/pages/spiders/admin-spiders.ts | 107 +++++++++-- .../admin/pages/users/admin-users.html | 175 ++++++++++-------- .../features/admin/pages/users/admin-users.ts | 106 +++++++++-- 22 files changed, 1053 insertions(+), 329 deletions(-) create mode 100644 backend/src/main/java/com/versus/api/admin/AdminController.java create mode 100644 backend/src/main/java/com/versus/api/admin/AdminService.java create mode 100644 backend/src/main/java/com/versus/api/admin/dto/AdminStatsResponse.java create mode 100644 backend/src/main/java/com/versus/api/admin/dto/AdminUserResponse.java create mode 100644 backend/src/main/java/com/versus/api/admin/dto/UpdateRoleRequest.java create mode 100644 backend/src/main/java/com/versus/api/admin/dto/UpdateStatusRequest.java create mode 100644 frontend/src/app/core/models/admin.models.ts create mode 100644 frontend/src/app/core/services/admin.service.ts diff --git a/backend/src/main/java/com/versus/api/admin/AdminController.java b/backend/src/main/java/com/versus/api/admin/AdminController.java new file mode 100644 index 0000000..ce79185 --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/AdminController.java @@ -0,0 +1,58 @@ +package com.versus.api.admin; + +import com.versus.api.admin.dto.AdminStatsResponse; +import com.versus.api.admin.dto.AdminUserResponse; +import com.versus.api.admin.dto.UpdateRoleRequest; +import com.versus.api.admin.dto.UpdateStatusRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.UUID; + +@Tag(name = "Admin — Users", description = "User management (ADMIN only)") +@SecurityRequirement(name = "bearerAuth") +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminController { + + private final AdminService adminService; + + @Operation(summary = "List users with optional search and role filter") + @GetMapping("/users") + public Page listUsers( + @RequestParam(required = false) String search, + @RequestParam(required = false) String role, + @PageableDefault(size = 20) Pageable pageable) { + return adminService.getUsers(search, role, pageable); + } + + @Operation(summary = "Update a user's role") + @PutMapping("/users/{id}/role") + public AdminUserResponse updateRole(@PathVariable UUID id, + @Valid @RequestBody UpdateRoleRequest req) { + return adminService.updateRole(id, req.role()); + } + + @Operation(summary = "Update a user's active status (suspend / unsuspend)") + @PutMapping("/users/{id}/status") + public AdminUserResponse updateStatus(@PathVariable UUID id, + @Valid @RequestBody UpdateStatusRequest req) { + return adminService.updateStatus(id, req.active()); + } + + @Operation(summary = "Dashboard KPI stats") + @GetMapping("/stats") + public AdminStatsResponse stats() { + return adminService.getStats(); + } +} diff --git a/backend/src/main/java/com/versus/api/admin/AdminService.java b/backend/src/main/java/com/versus/api/admin/AdminService.java new file mode 100644 index 0000000..2bdecf1 --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/AdminService.java @@ -0,0 +1,105 @@ +package com.versus.api.admin; + +import com.versus.api.admin.dto.AdminStatsResponse; +import com.versus.api.admin.dto.AdminUserResponse; +import com.versus.api.common.exception.ApiException; +import com.versus.api.match.repo.MatchRepository; +import com.versus.api.moderation.ReportStatus; +import com.versus.api.moderation.repo.QuestionReportRepository; +import com.versus.api.questions.QuestionStatus; +import com.versus.api.questions.QuestionType; +import com.versus.api.questions.repo.QuestionRepository; +import com.versus.api.users.Role; +import com.versus.api.users.UserStatus; +import com.versus.api.users.domain.User; +import com.versus.api.users.repo.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository users; + private final MatchRepository matches; + private final QuestionRepository questions; + private final QuestionReportRepository reports; + + // ── Users ───────────────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public Page getUsers(String search, String roleStr, Pageable pageable) { + Role role = null; + if (roleStr != null && !roleStr.isBlank()) { + try { role = Role.valueOf(roleStr.toUpperCase()); } + catch (IllegalArgumentException ignored) {} + } + return users.findAdminUsers(role, search, pageable).map(this::toUserResponse); + } + + @Transactional + public AdminUserResponse updateRole(UUID userId, Role role) { + User user = findActiveUser(userId); + user.setRole(role); + return toUserResponse(users.save(user)); + } + + @Transactional + public AdminUserResponse updateStatus(UUID userId, boolean active) { + User user = findActiveUser(userId); + user.setIsActive(active); + return toUserResponse(users.save(user)); + } + + // ── Stats ───────────────────────────────────────────────────────────────── + + @Transactional(readOnly = true) + public AdminStatsResponse getStats() { + long totalUsers = users.count(); + long activeUsers = users.countByIsActiveTrue(); + long gamesToday = matches.countByCreatedAtGreaterThanEqual(todayStart()); + long totalBinary = questions.countByStatusAndType(QuestionStatus.ACTIVE, QuestionType.BINARY); + long totalNumeric = questions.countByStatusAndType(QuestionStatus.ACTIVE, QuestionType.NUMERIC); + long pendingReports = reports.countByStatus(ReportStatus.PENDING); + return new AdminStatsResponse(totalUsers, activeUsers, gamesToday, + totalBinary + totalNumeric, pendingReports); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private User findActiveUser(UUID id) { + User user = users.findById(id) + .orElseThrow(() -> ApiException.notFound("User not found: " + id)); + if (user.getStatus() == UserStatus.DELETED) { + throw ApiException.notFound("User not found: " + id); + } + return user; + } + + private AdminUserResponse toUserResponse(User u) { + return new AdminUserResponse( + u.getId(), + u.getUsername(), + u.getEmail(), + u.getAvatarUrl(), + u.getRole().name(), + Boolean.TRUE.equals(u.getIsActive()), + u.getCreatedAt() + ); + } + + private Instant todayStart() { + return ZonedDateTime.now(ZoneOffset.UTC) + .toLocalDate() + .atStartOfDay(ZoneOffset.UTC) + .toInstant(); + } +} diff --git a/backend/src/main/java/com/versus/api/admin/dto/AdminStatsResponse.java b/backend/src/main/java/com/versus/api/admin/dto/AdminStatsResponse.java new file mode 100644 index 0000000..e7115f3 --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/dto/AdminStatsResponse.java @@ -0,0 +1,9 @@ +package com.versus.api.admin.dto; + +public record AdminStatsResponse( + long totalUsers, + long activeUsers, + long gamesToday, + long totalQuestions, + long pendingReports +) {} diff --git a/backend/src/main/java/com/versus/api/admin/dto/AdminUserResponse.java b/backend/src/main/java/com/versus/api/admin/dto/AdminUserResponse.java new file mode 100644 index 0000000..b2ae5b8 --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/dto/AdminUserResponse.java @@ -0,0 +1,14 @@ +package com.versus.api.admin.dto; + +import java.time.Instant; +import java.util.UUID; + +public record AdminUserResponse( + UUID id, + String username, + String email, + String avatarUrl, + String role, + boolean isActive, + Instant createdAt +) {} diff --git a/backend/src/main/java/com/versus/api/admin/dto/UpdateRoleRequest.java b/backend/src/main/java/com/versus/api/admin/dto/UpdateRoleRequest.java new file mode 100644 index 0000000..08bfa46 --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/dto/UpdateRoleRequest.java @@ -0,0 +1,6 @@ +package com.versus.api.admin.dto; + +import com.versus.api.users.Role; +import jakarta.validation.constraints.NotNull; + +public record UpdateRoleRequest(@NotNull Role role) {} diff --git a/backend/src/main/java/com/versus/api/admin/dto/UpdateStatusRequest.java b/backend/src/main/java/com/versus/api/admin/dto/UpdateStatusRequest.java new file mode 100644 index 0000000..463ba4d --- /dev/null +++ b/backend/src/main/java/com/versus/api/admin/dto/UpdateStatusRequest.java @@ -0,0 +1,5 @@ +package com.versus.api.admin.dto; + +import jakarta.validation.constraints.NotNull; + +public record UpdateStatusRequest(@NotNull Boolean active) {} diff --git a/backend/src/main/java/com/versus/api/match/repo/MatchRepository.java b/backend/src/main/java/com/versus/api/match/repo/MatchRepository.java index 2ef44a5..30319fa 100644 --- a/backend/src/main/java/com/versus/api/match/repo/MatchRepository.java +++ b/backend/src/main/java/com/versus/api/match/repo/MatchRepository.java @@ -3,9 +3,11 @@ import com.versus.api.match.domain.Match; import org.springframework.data.jpa.repository.JpaRepository; +import java.time.Instant; import java.util.Optional; import java.util.UUID; public interface MatchRepository extends JpaRepository { Optional findByRoomCode(String roomCode); + long countByCreatedAtGreaterThanEqual(Instant since); } diff --git a/backend/src/main/java/com/versus/api/moderation/repo/QuestionReportRepository.java b/backend/src/main/java/com/versus/api/moderation/repo/QuestionReportRepository.java index d054003..248ecc0 100644 --- a/backend/src/main/java/com/versus/api/moderation/repo/QuestionReportRepository.java +++ b/backend/src/main/java/com/versus/api/moderation/repo/QuestionReportRepository.java @@ -17,4 +17,6 @@ public interface QuestionReportRepository extends JpaRepository { Optional findByUsername(String username); boolean existsByEmail(String email); boolean existsByUsername(String username); + + long countByIsActiveTrue(); + + @Query(""" + SELECT u FROM User u + WHERE u.status = com.versus.api.users.UserStatus.ACTIVE + AND (:role IS NULL OR u.role = :role) + AND (:search IS NULL OR :search = '' + OR LOWER(u.username) LIKE LOWER(CONCAT('%', :search, '%')) + OR LOWER(u.email) LIKE LOWER(CONCAT('%', :search, '%'))) + ORDER BY u.createdAt DESC + """) + Page findAdminUsers(@Param("role") Role role, + @Param("search") String search, + Pageable pageable); } diff --git a/frontend/src/app/core/models/admin.models.ts b/frontend/src/app/core/models/admin.models.ts new file mode 100644 index 0000000..a26b40d --- /dev/null +++ b/frontend/src/app/core/models/admin.models.ts @@ -0,0 +1,54 @@ +export interface AdminUser { + id: string; + username: string; + email: string; + avatarUrl: string | null; + role: 'PLAYER' | 'MODERATOR' | 'ADMIN'; + isActive: boolean; + createdAt: string; +} + +export interface AdminStats { + totalUsers: number; + activeUsers: number; + gamesToday: number; + totalQuestions: number; + pendingReports: number; +} + +export interface AdminSpider { + id: string; + name: string; + targetUrl: string; + status: 'IDLE' | 'RUNNING' | 'FAILED'; + lastRunAt: string | null; + lastRun: { + id: string; + startedAt: string; + finishedAt: string | null; + questionsInserted: number; + errors: number; + } | null; +} + +export interface AdminReport { + id: string; + questionId: string; + questionText: string | null; + questionType: string | null; + questionCategory: string | null; + reason: 'WRONG_ANSWER' | 'OUTDATED' | 'OFFENSIVE' | 'OTHER'; + status: 'PENDING' | 'DISMISSED' | 'RESOLVED'; + comment: string | null; + createdAt: string; + resolvedBy: string | null; + resolvedAt: string | null; + action: 'DISMISS' | 'DELETE_QUESTION' | 'EDIT_QUESTION' | null; +} + +export interface PageResponse { + content: T[]; + totalElements: number; + totalPages: number; + number: number; +} diff --git a/frontend/src/app/core/services/admin.service.ts b/frontend/src/app/core/services/admin.service.ts new file mode 100644 index 0000000..e8ccf86 --- /dev/null +++ b/frontend/src/app/core/services/admin.service.ts @@ -0,0 +1,84 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable } from 'rxjs'; +import { environment } from '../../../environments/environment'; +import { + AdminReport, + AdminSpider, + AdminStats, + AdminUser, + PageResponse, +} from '../models/admin.models'; + +@Injectable({ providedIn: 'root' }) +export class AdminService { + private readonly http = inject(HttpClient); + private readonly base = environment.apiBaseUrl; + + // ── Stats ────────────────────────────────────────────────────────────────── + + getStats(): Observable { + return this.http.get(`${this.base}/admin/stats`); + } + + // ── Users ────────────────────────────────────────────────────────────────── + + getUsers( + page = 0, + search?: string, + role?: string, + ): Observable> { + let params = new HttpParams().set('page', page).set('size', 20); + if (search) params = params.set('search', search); + if (role) params = params.set('role', role); + return this.http.get>(`${this.base}/admin/users`, { + params, + }); + } + + updateUserRole(id: string, role: string): Observable { + return this.http.put(`${this.base}/admin/users/${id}/role`, { + role, + }); + } + + updateUserStatus(id: string, active: boolean): Observable { + return this.http.put(`${this.base}/admin/users/${id}/status`, { + active, + }); + } + + // ── Spiders ──────────────────────────────────────────────────────────────── + + getSpiders(): Observable { + return this.http.get(`${this.base}/admin/spiders`); + } + + triggerSpider(name: string): Observable { + return this.http.post(`${this.base}/admin/spiders/${name}/run`, {}); + } + + // ── Reports ──────────────────────────────────────────────────────────────── + + getReports( + status?: string, + page = 0, + ): Observable> { + let params = new HttpParams().set('page', page).set('size', 20); + if (status) params = params.set('status', status); + return this.http.get>( + `${this.base}/moderation/reports`, + { params }, + ); + } + + resolveReport( + id: string, + action: 'DISMISS' | 'DELETE_QUESTION' | 'EDIT_QUESTION', + ): Observable { + return this.http.put( + `${this.base}/moderation/reports/${id}/resolve`, + { action }, + ); + } +} diff --git a/frontend/src/app/features/admin/components/sidebar/sidebar.html b/frontend/src/app/features/admin/components/sidebar/sidebar.html index b2ed867..acd95f2 100644 --- a/frontend/src/app/features/admin/components/sidebar/sidebar.html +++ b/frontend/src/app/features/admin/components/sidebar/sidebar.html @@ -6,20 +6,29 @@ @for (section of sections; track section.label) {
{{ section.label }}
@for (item of section.items; track item.key) { -
- {{ item.label }} -
+ @if (route(item.key); as r) { + + {{ item.label }} + + } @else { +
+ {{ item.label }} +
+ } } }
-
- AD +
+ {{ initials(auth.user()?.username ?? 'AD') }}
-
admin_root
+
{{ auth.user()?.username ?? '—' }}
Administrador
diff --git a/frontend/src/app/features/admin/components/sidebar/sidebar.ts b/frontend/src/app/features/admin/components/sidebar/sidebar.ts index 77bdc3d..10e0644 100644 --- a/frontend/src/app/features/admin/components/sidebar/sidebar.ts +++ b/frontend/src/app/features/admin/components/sidebar/sidebar.ts @@ -1,29 +1,72 @@ -import { Component, input } from '@angular/core'; +import { Component, inject, input } from '@angular/core'; +import { Router, RouterLink, RouterLinkActive } from '@angular/router'; +import { AuthService } from '../../../../core/services/auth.service'; -export type AdminNavKey = 'dash' | 'spiders' | 'reports' | 'users' | 'quest' | 'rank' | 'cfg' | 'logs'; +export type AdminNavKey = + | 'dash' + | 'spiders' + | 'reports' + | 'users' + | 'quest' + | 'rank' + | 'cfg' + | 'logs'; + +const ROUTES: Partial> = { + dash: '/admin/dashboard', + spiders: '/admin/spiders', + reports: '/admin/reports', + users: '/admin/users', +}; @Component({ selector: 'app-admin-sidebar', standalone: true, + imports: [RouterLink, RouterLinkActive], templateUrl: './sidebar.html', }) export class AdminSidebarComponent { active = input('dash'); + private readonly router = inject(Router); + readonly auth = inject(AuthService); + sections = [ - { label: 'SUPERVISIÓN', items: [ - { key: 'dash', label: 'Resumen' }, - { key: 'spiders', label: 'Spiders' }, - { key: 'reports', label: 'Moderación' }, - ]}, - { label: 'GESTIÓN', items: [ - { key: 'users', label: 'Usuarios' }, - { key: 'quest', label: 'Preguntas' }, - { key: 'rank', label: 'Rankings' }, - ]}, - { label: 'SISTEMA', items: [ - { key: 'cfg', label: 'Configuración' }, - { key: 'logs', label: 'Logs' }, - ]}, + { + label: 'SUPERVISIÓN', + items: [ + { key: 'dash', label: 'Resumen' }, + { key: 'spiders', label: 'Spiders' }, + { key: 'reports', label: 'Moderación' }, + ], + }, + { + label: 'GESTIÓN', + items: [ + { key: 'users', label: 'Usuarios' }, + { key: 'quest', label: 'Preguntas' }, + { key: 'rank', label: 'Rankings' }, + ], + }, + { + label: 'SISTEMA', + items: [ + { key: 'cfg', label: 'Configuración' }, + { key: 'logs', label: 'Logs' }, + ], + }, ]; + + route(key: string): string | null { + return ROUTES[key as AdminNavKey] ?? null; + } + + navigate(key: string): void { + const r = this.route(key); + if (r) this.router.navigate([r]); + } + + initials(name: string): string { + return name.slice(0, 2).toUpperCase(); + } } diff --git a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.html b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.html index 0469c8d..ad2e81a 100644 --- a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.html +++ b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.html @@ -6,29 +6,29 @@

RESUMEN GENERAL

-
Plataforma · 28 abr 2026 · 10:32
-
-
- - +
Plataforma · Panel de administración
- -
- @for (k of kpis; track k.label) { -
-
{{ k.label }}
-
- {{ k.num }} + @if (loading()) { +
Cargando estadísticas…
+ } @else { + +
+ @for (k of kpis(); track k.label) { +
+
{{ k.label }}
+
+ {{ k.num }} +
+
{{ k.delta }}
+ + +
-
{{ k.delta }}
- - - -
- } -
+ } +
+ }
@@ -36,21 +36,24 @@

RESUMEN GENERAL

Spiders activos
- + VER TODOS
- @for (s of spiders; track s.name) { + @for (s of spiders(); track s.name) {
- +
{{ s.name }}
-
{{ s.last }}
+
{{ lastRunLabel(s) }}
-
{{ s.q }} q.
- - {{ pillLabel(s.stat) }} +
{{ s.lastRun?.questionsInserted ?? 0 }} q.
+ + {{ pillLabel(s.status) }}
} + @if (spiders().length === 0) { +
Sin spiders registrados.
+ }
@@ -71,21 +74,5 @@

RESUMEN GENERAL

- -
-
-
Eventos recientes
-
-
- @for (l of logs; track l.ts) { -
- {{ l.ts }} - [{{ l.cls.toUpperCase() }}] - {{ l.msg }} -
- } -
-
-
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 34a0e87..d3a6341 100644 --- a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss +++ b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.scss @@ -23,6 +23,7 @@ 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); } @@ -33,6 +34,12 @@ 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 { diff --git a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.ts b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.ts index c590dff..5c231d9 100644 --- a/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.ts +++ b/frontend/src/app/features/admin/pages/dashboard/admin-dashboard.ts @@ -1,57 +1,129 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { RouterLink } from '@angular/router'; import { AdminSidebarComponent } from '../../components/sidebar/sidebar'; +import { AdminService } from '../../../../core/services/admin.service'; +import { AdminSpider, AdminStats } from '../../../../core/models/admin.models'; + +interface Kpi { + label: string; + num: string; + delta: string; + up: boolean; + color: string; + spark: number[]; +} @Component({ selector: 'app-admin-dashboard', standalone: true, - imports: [AdminSidebarComponent], + imports: [AdminSidebarComponent, RouterLink], templateUrl: './admin-dashboard.html', styleUrl: './admin-dashboard.scss', }) -export class AdminDashboard { - kpis = [ - { label: 'Usuarios activos', num: '14 287', delta: '▲ 6.4% vs. semana ant.', up: true, color: 'var(--vs-accent-green)', spark: [10,12,11,14,13,17,19,18,22,24] }, - { label: 'Partidas hoy', num: '3 142', delta: '▲ 2.1%', up: true, color: 'var(--vs-accent-blue)', spark: [40,38,52,49,61,58,73] }, - { label: 'Preguntas en BD', num: '98 442', delta: '+ 1 230 esta semana', up: true, color: 'var(--vs-accent-gold)', spark: [20,22,24,26,28,30,33] }, - { label: 'Reportes pendientes', num: '27', delta: '▲ 9 desde ayer', up: false, color: 'var(--vs-accent-red)', spark: [3,5,4,8,12,18,27] }, - ]; +export class AdminDashboard implements OnInit { + private readonly adminSvc = inject(AdminService); - spiders = [ - { name: 'spider_transfermarkt', stat: 'ok', last: 'hace 14 min', q: '1 240' }, - { name: 'spider_spotify_charts', stat: 'ok', last: 'hace 28 min', q: '845' }, - { name: 'spider_box_office', stat: 'warn', last: 'hace 2 h', q: '320' }, - { name: 'spider_instagram', stat: 'err', last: 'hace 6 h', q: '0' }, - ]; + kpis = signal([]); + spiders = signal([]); + loading = signal(true); modes = [ - { mode: 'Supervivencia', pct: 42, color: 'var(--vs-accent-red)' }, - { mode: 'Precisión', pct: 24, color: 'var(--vs-accent-blue)' }, - { mode: 'Duelo binario', pct: 16, color: 'var(--vs-accent-gold)' }, - { mode: 'Sabotaje', pct: 12, color: 'var(--vs-accent-purple)' }, - { mode: 'Duelo precisión', pct: 6, color: 'var(--vs-accent-green)' }, + { mode: 'Supervivencia', pct: 42, color: 'var(--vs-accent-red)' }, + { mode: 'Precisión', pct: 24, color: 'var(--vs-accent-blue)' }, + { mode: 'Duelo binario', pct: 16, color: 'var(--vs-accent-gold)' }, + { mode: 'Sabotaje', pct: 12, color: 'var(--vs-accent-purple)' }, + { mode: 'Duelo precisión', pct: 6, color: 'var(--vs-accent-green)' }, ]; - logs = [ - { ts: '10:31:22', cls: 'ok', msg: 'spider_transfermarkt insertó 1 240 preguntas' }, - { ts: '10:18:04', cls: 'warn', msg: 'spider_box_office latencia > 8s en 12 requests' }, - { ts: '10:02:51', cls: 'ok', msg: 'usuario @kil4_max alcanzó nivel 30' }, - { ts: '09:54:09', cls: 'err', msg: 'spider_instagram timeout · auth-required' }, - { ts: '09:41:18', cls: 'ok', msg: '27 preguntas reportadas en cola moderación' }, - { ts: '09:18:02', cls: 'ok', msg: 'backup nocturno completado · 2.4 GB' }, - ]; + ngOnInit(): void { + this.adminSvc.getStats().subscribe({ + next: (s) => { + this.kpis.set(this.buildKpis(s)); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + this.adminSvc.getSpiders().subscribe({ + next: (list) => this.spiders.set(list), + }); + } + + private buildKpis(s: AdminStats): Kpi[] { + return [ + { + label: 'Usuarios activos', + num: s.activeUsers.toLocaleString('es-ES'), + delta: `${s.totalUsers.toLocaleString('es-ES')} en total`, + up: true, + color: 'var(--vs-accent-green)', + spark: [10, 12, 11, 14, 13, 17, 19, 18, 22, 24], + }, + { + label: 'Partidas hoy', + num: s.gamesToday.toLocaleString('es-ES'), + delta: 'partidas hoy', + up: true, + color: 'var(--vs-accent-blue)', + spark: [40, 38, 52, 49, 61, 58, 73], + }, + { + label: 'Preguntas en BD', + num: s.totalQuestions.toLocaleString('es-ES'), + delta: 'preguntas activas', + up: true, + color: 'var(--vs-accent-gold)', + spark: [20, 22, 24, 26, 28, 30, 33], + }, + { + label: 'Reportes pendientes', + num: s.pendingReports.toLocaleString('es-ES'), + delta: s.pendingReports > 0 ? 'requieren revisión' : 'sin reportes', + up: s.pendingReports === 0, + color: 'var(--vs-accent-red)', + spark: [3, 5, 4, 8, 12, 18, s.pendingReports], + }, + ]; + } + + pillClass(status: string): string { + return ( + { IDLE: 'vs-pill--mute', RUNNING: 'vs-pill--info', FAILED: 'vs-pill--err' }[ + status + ] ?? 'vs-pill--mute' + ); + } + + pillLabel(status: string): string { + return ( + { IDLE: 'INACTIVA', RUNNING: 'EJECUTANDO', FAILED: 'CAÍDA' }[status] ?? + status + ); + } - pillClass(stat: string): string { - return { ok: 'vs-pill--ok', warn: 'vs-pill--warn', err: 'vs-pill--err' }[stat] ?? 'vs-pill--mute'; + dotClass(status: string): string { + return ( + { IDLE: 'idle', RUNNING: 'ok', FAILED: 'err' }[status] ?? 'idle' + ); } - pillLabel(stat: string): string { - return { ok: 'OK', warn: 'LENTA', err: 'CAÍDA' }[stat] ?? stat; + lastRunLabel(spider: AdminSpider): string { + if (!spider.lastRunAt) return 'Sin ejecución'; + const d = new Date(spider.lastRunAt); + const diff = Math.floor((Date.now() - d.getTime()) / 60000); + if (diff < 60) return `hace ${diff} min`; + const h = Math.floor(diff / 60); + return `hace ${h} h`; } sparkPoints(data: number[]): string { - const max = Math.max(...data), min = Math.min(...data), range = max - min || 1; - return data.map((v, i) => - `${(i / (data.length - 1)) * 80},${30 - ((v - min) / range) * 26 - 2}` - ).join(' '); + const max = Math.max(...data), + min = Math.min(...data), + range = max - min || 1; + return data + .map( + (v, i) => + `${(i / (data.length - 1)) * 80},${30 - ((v - min) / range) * 26 - 2}`, + ) + .join(' '); } } diff --git a/frontend/src/app/features/admin/pages/reports/admin-reports.html b/frontend/src/app/features/admin/pages/reports/admin-reports.html index 1ac98c4..e39e18e 100644 --- a/frontend/src/app/features/admin/pages/reports/admin-reports.html +++ b/frontend/src/app/features/admin/pages/reports/admin-reports.html @@ -6,58 +6,86 @@

MODERACIÓN DE PREGUNTAS

-
27 reportes pendientes · 9 nuevos en las últimas 24h
+
+ @if (page()) { + {{ page()!.totalElements }} reportes en vista actual + } +
-
- PENDIENTES · 27 - EN REVISIÓN · 4 - RESUELTAS · 312 - DESCARTADAS · 88 -
- + + PENDIENTES + + + RESUELTAS + + + DESCARTADAS + + + TODAS +
+ @if (loading()) { +
Cargando reportes…
+ } + -
- @for (r of reports; track r.id) { -
-
- - {{ r.reason.toUpperCase() }} - -
×{{ r.count }} reportes · {{ r.when }}
-
+ @if (!loading() && page()) { +
+ @for (r of page()!.content; track r.id) { +
+
+ + {{ reasonLabel(r.reason) }} + +
+ {{ r.status }} · {{ r.createdAt | date:'dd/MM HH:mm' }} +
+
-
{{ r.q }}
+ @if (r.questionText) { +
{{ r.questionText }}
+ } @else { +
+ Pregunta ID: {{ r.questionId | slice:0:8 }}… +
+ } -
- @for (opt of r.opts; track opt.name) { -
- {{ opt.name }} - {{ opt.num }} + @if (r.comment) { +
+ "{{ r.comment }}"
} -
-
- Reportado por @{{ r.reporter }} · fuente: transfermarkt.es · scrapeada hace 3 días + @if (r.status === 'PENDING') { +
+ + +
+ }
+ } +
-
- - - -
+ @if (page()!.content.length === 0) { +
+ No hay reportes con este filtro.
} -
+ }
diff --git a/frontend/src/app/features/admin/pages/reports/admin-reports.ts b/frontend/src/app/features/admin/pages/reports/admin-reports.ts index a158d1a..5d42713 100644 --- a/frontend/src/app/features/admin/pages/reports/admin-reports.ts +++ b/frontend/src/app/features/admin/pages/reports/admin-reports.ts @@ -1,29 +1,70 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { DatePipe, SlicePipe } from '@angular/common'; import { AdminSidebarComponent } from '../../components/sidebar/sidebar'; +import { AdminService } from '../../../../core/services/admin.service'; +import { AdminReport, PageResponse } from '../../../../core/models/admin.models'; + +type StatusFilter = 'PENDING' | 'DISMISSED' | 'RESOLVED' | ''; @Component({ selector: 'app-admin-reports', standalone: true, - imports: [AdminSidebarComponent], + imports: [AdminSidebarComponent, DatePipe, SlicePipe], templateUrl: './admin-reports.html', styleUrl: '../dashboard/admin-dashboard.scss', }) -export class AdminReports { - reports = [ - { - id: 1, reason: 'Datos desactualizados', reporter: 'numerito', when: 'hace 12 min', count: 4, - q: '¿Quién tiene MÁS seguidores en Instagram, Cristiano o Messi?', - opts: [{ name: 'Cristiano Ronaldo', num: '638M', ok: true }, { name: 'Lionel Messi', num: '503M', ok: false }], - }, - { - id: 2, reason: 'Respuesta incorrecta', reporter: 'kil4_max', when: 'hace 1 h', count: 12, - q: '¿Qué película recaudó más, Avatar 2 o Endgame?', - opts: [{ name: 'Avatar: el sentido del agua', num: '2 320M$', ok: false }, { name: 'Vengadores: Endgame', num: '2 799M$', ok: true }], - }, - { - id: 3, reason: 'Pregunta ambigua', reporter: 'tucanela', when: 'hace 2 h', count: 2, - q: '¿Capital con más habitantes, Tokio o Delhi?', - opts: [{ name: 'Tokio', num: '37.4M (área metropolitana)', ok: true }, { name: 'Delhi', num: '32.9M (área metropolitana)', ok: false }], - }, - ]; +export class AdminReports implements OnInit { + private readonly adminSvc = inject(AdminService); + + page = signal | null>(null); + statusFilter = signal('PENDING'); + loading = signal(true); + + ngOnInit(): void { + this.load(); + } + + setFilter(s: StatusFilter): void { + this.statusFilter.set(s); + this.load(); + } + + private load(): void { + this.loading.set(true); + const s = this.statusFilter(); + this.adminSvc.getReports(s || undefined).subscribe({ + next: (p) => { + this.page.set(p); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + dismiss(id: string): void { + this.adminSvc.resolveReport(id, 'DISMISS').subscribe({ + next: () => this.load(), + }); + } + + deleteQuestion(id: string): void { + this.adminSvc.resolveReport(id, 'DELETE_QUESTION').subscribe({ + next: () => this.load(), + }); + } + + reasonLabel(r: string): string { + return ( + { + WRONG_ANSWER: 'Respuesta incorrecta', + OUTDATED: 'Datos desactualizados', + OFFENSIVE: 'Contenido ofensivo', + OTHER: 'Otro motivo', + }[r] ?? r + ); + } + + filterPillClass(target: StatusFilter): string { + return this.statusFilter() === target ? 'vs-pill--err' : 'vs-pill--mute'; + } } diff --git a/frontend/src/app/features/admin/pages/spiders/admin-spiders.html b/frontend/src/app/features/admin/pages/spiders/admin-spiders.html index c474de3..d2fbb0f 100644 --- a/frontend/src/app/features/admin/pages/spiders/admin-spiders.html +++ b/frontend/src/app/features/admin/pages/spiders/admin-spiders.html @@ -6,98 +6,98 @@

SPIDERS Y SCRAPING

-
6 arañas · 2 con incidencias · última ejecución hace 14 min
-
-
- - +
+ {{ filteredSpiders().length }} arañas + @if (allSpiders().length > 0) { + · {{ allSpiders().length }} en total + } +
- -
-
Ejecuciones (24h)
42
▲ 6
-
Preguntas insertadas (24h)
3 824
▲ 12%
-
Tasa de error
4.7%
▼ 0.3%
-
Latencia media
2.1s
estable
-
-
- - TODAS - CON ERROR - OK + + TODAS + + CON ERROR + + + OK + +
+
+ {{ filteredSpiders().length }} RESULTADOS
-
6 RESULTADOS
-
- Nombre / URLCategoríaÚltima ejecuciónInsertadasEstado +
+ Nombre / URLÚltima ejecuciónInsertadasEstado
- @for (s of spiders; track s.name) { -
- + @if (loading()) { +
Cargando spiders…
+ } + + @for (s of filteredSpiders(); track s.name) { +
+
{{ s.name }}
-
{{ s.url }}
+
{{ s.targetUrl }}
-
{{ s.cat }}
-
{{ s.lastRun }}
-
{{ s.schedule }}
+
{{ lastRunLabel(s) }}
+
{{ s.status }}
-
{{ s.lastQ }}
-
- - +
{{ s.lastRun?.questionsInserted ?? 0 }}
+
+ + {{ pillLabel(s.status) }} + +
} -
-
- - -
-
-
spider_instagram · log
- FALLA -
-
-
04:12:02[ERR] auth required (HTTP 401)
-
04:12:02[ERR] retry 3/3 falló
-
04:11:58[WARN] token expirado · age=24h
-
04:11:55 conectando a https://i.instagram.com/api/v1/...
-
04:11:54 spider iniciada · pid=18829
-
04:11:53 cargando perfil `instagram_followers.yaml`
-
-
- - - -
-
+ @if (!loading() && filteredSpiders().length === 0) { +
Sin resultados.
+ } +
- -
-
Inserciones por hora · últimas 24h
-
- @for (v of barData; track $index) { -
- } -
-
- Pico a las 03:00 (mantenimiento) · 1 240 nuevas preguntas en la ventana. -
+ +
+
Actividad relativa · últimas 24h
+
+ @for (v of barData; track $index) { +
+ }
-
+
diff --git a/frontend/src/app/features/admin/pages/spiders/admin-spiders.ts b/frontend/src/app/features/admin/pages/spiders/admin-spiders.ts index d1af5bc..49744c2 100644 --- a/frontend/src/app/features/admin/pages/spiders/admin-spiders.ts +++ b/frontend/src/app/features/admin/pages/spiders/admin-spiders.ts @@ -1,28 +1,105 @@ -import { Component, signal } from '@angular/core'; +import { Component, OnInit, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { AdminSidebarComponent } from '../../components/sidebar/sidebar'; +import { AdminService } from '../../../../core/services/admin.service'; +import { AdminSpider } from '../../../../core/models/admin.models'; @Component({ selector: 'app-admin-spiders', standalone: true, - imports: [AdminSidebarComponent], + imports: [AdminSidebarComponent, FormsModule], templateUrl: './admin-spiders.html', styleUrl: '../dashboard/admin-dashboard.scss', }) -export class AdminSpiders { +export class AdminSpiders implements OnInit { + private readonly adminSvc = inject(AdminService); + + allSpiders = signal([]); filter = signal(''); + statusFilter = signal(''); + running = signal>(new Set()); + loading = signal(true); + + filteredSpiders = computed(() => { + const q = this.filter().toLowerCase(); + const s = this.statusFilter(); + return this.allSpiders().filter( + (sp) => + (!q || sp.name.toLowerCase().includes(q)) && + (!s || sp.status === s), + ); + }); + + barData = [12, 18, 24, 9, 6, 4, 32, 78, 90, 64, 22, 18, 14, 11, 9, 16, 28, 34, 42, 36, 28, 22, 18, 14]; + barMax = Math.max(...[12, 18, 24, 9, 6, 4, 32, 78, 90, 64, 22, 18, 14, 11, 9, 16, 28, 34, 42, 36, 28, 22, 18, 14]); + + ngOnInit(): void { + this.loadSpiders(); + } + + private loadSpiders(): void { + this.loading.set(true); + this.adminSvc.getSpiders().subscribe({ + next: (list) => { + this.allSpiders.set(list); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + triggerRun(name: string): void { + const current = new Set(this.running()); + current.add(name); + this.running.set(current); + + this.adminSvc.triggerSpider(name).subscribe({ + next: () => { + const s = new Set(this.running()); + s.delete(name); + this.running.set(s); + this.loadSpiders(); + }, + error: () => { + const s = new Set(this.running()); + s.delete(name); + this.running.set(s); + }, + }); + } + + isRunning(name: string): boolean { + return this.running().has(name); + } + + setStatusFilter(s: string): void { + this.statusFilter.set(this.statusFilter() === s ? '' : s); + } + + pillClass(status: string): string { + return ( + { IDLE: 'vs-pill--mute', RUNNING: 'vs-pill--info', FAILED: 'vs-pill--err' }[ + status + ] ?? 'vs-pill--mute' + ); + } - spiders = [ - { name: 'spider_transfermarkt', url: 'https://transfermarkt.es/...', stat: 'ok', runs: 1280, lastQ: 1240, lastRun: '10:31', schedule: 'cada 4h', cat: 'Fútbol' }, - { name: 'spider_spotify_charts', url: 'https://charts.spotify.com', stat: 'ok', runs: 980, lastQ: 845, lastRun: '10:18', schedule: 'diario', cat: 'Música' }, - { name: 'spider_box_office', url: 'https://www.boxofficemojo.com', stat: 'warn', runs: 612, lastQ: 320, lastRun: '08:42', schedule: 'cada 6h', cat: 'Cine' }, - { name: 'spider_instagram', url: 'https://www.instagram.com', stat: 'err', runs: 1488, lastQ: 0, lastRun: '04:12', schedule: 'cada 2h', cat: 'Social' }, - { name: 'spider_twitch_top', url: 'https://www.twitch.tv/directory', stat: 'ok', runs: 218, lastQ: 156, lastRun: '09:55', schedule: 'cada 6h', cat: 'Streaming' }, - { name: 'spider_geo_pop', url: 'https://en.wikipedia.org/wiki/...', stat: 'idle', runs: 12, lastQ: 0, lastRun: '—', schedule: 'manual', cat: 'Geografía' }, - ]; + pillLabel(status: string): string { + return ( + { IDLE: 'INACTIVA', RUNNING: 'EJECUTANDO', FAILED: 'CAÍDA' }[status] ?? + status + ); + } - barData = [12,18,24,9,6,4,32,78,90,64,22,18,14,11,9,16,28,34,42,36,28,22,18,14]; - barMax = Math.max(...[12,18,24,9,6,4,32,78,90,64,22,18,14,11,9,16,28,34,42,36,28,22,18,14]); + dotClass(status: string): string { + return { IDLE: 'idle', RUNNING: 'ok', FAILED: 'err' }[status] ?? 'idle'; + } - pillClass(s: string) { return { ok:'vs-pill--ok', warn:'vs-pill--warn', err:'vs-pill--err' }[s] ?? 'vs-pill--mute'; } - pillLabel(s: string) { return { ok:'OK', warn:'LENTA', err:'CAÍDA', idle:'INACTIVA' }[s] ?? s; } + lastRunLabel(spider: AdminSpider): string { + if (!spider.lastRunAt) return '—'; + const d = new Date(spider.lastRunAt); + const diff = Math.floor((Date.now() - d.getTime()) / 60000); + if (diff < 60) return `hace ${diff} min`; + return `hace ${Math.floor(diff / 60)} h`; + } } 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 cbc595b..01bcc0a 100644 --- a/frontend/src/app/features/admin/pages/users/admin-users.html +++ b/frontend/src/app/features/admin/pages/users/admin-users.html @@ -6,95 +6,118 @@

USUARIOS

-
14 287 usuarios · 312 baneados · 8 moderadores
-
-
- - +
+ @if (page()) { + {{ page()!.totalElements }} usuarios encontrados + } +
- - - + + -
- 6 / 14 287 + + @if (page()) { + + {{ page()!.content.length }} / {{ page()!.totalElements }} + + }
- - - - - - - - - - - - - - - @for (u of users; track u.name) { + + @if (loading()) { +
Cargando usuarios…
+ } + + @if (!loading() && page()) { +
UsuarioRolNivelPartidasAltaEstadoAcciones
+ - - + + + + + + + + @for (u of page()!.content; track u.id) { + + + + + + - - - - - - - - } - -
-
-
- {{ initials(u.name) }} +
UsuarioRolAltaEstadoAcciones
+
+
+ {{ initials(u.username) }} +
+
+
{{ u.username }}
+
{{ u.email }}
+
-
-
{{ u.name }}
-
{{ u.email }}
+
+ + {{ u.role }} + + + {{ u.createdAt | date:'dd/MM/yy' }} + + @if (u.isActive) { + ACTIVO + } @else { + SUSPENDIDO + } + +
+ + @if (u.isActive) { + + } @else { + + }
- -
- - {{ u.role.toUpperCase() }} - - {{ u.level }}{{ u.games }}{{ u.joined }} - @if (u.state === 'ok') { - ACTIVO - } - @if (u.state === 'warn') { - AVISO - } - @if (u.state === 'banned') { - BANEADO - } - -
- - - -
-
+ + + } + + + + @if (page()!.content.length === 0) { +
No se encontraron usuarios.
+ } + }
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 3bd0aaf..cbb34ff 100644 --- a/frontend/src/app/features/admin/pages/users/admin-users.ts +++ b/frontend/src/app/features/admin/pages/users/admin-users.ts @@ -1,30 +1,108 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DatePipe } from '@angular/common'; import { AdminSidebarComponent } from '../../components/sidebar/sidebar'; +import { AdminService } from '../../../../core/services/admin.service'; +import { AdminUser, PageResponse } from '../../../../core/models/admin.models'; @Component({ selector: 'app-admin-users', standalone: true, - imports: [AdminSidebarComponent], + imports: [AdminSidebarComponent, FormsModule, DatePipe], templateUrl: './admin-users.html', styleUrl: '../dashboard/admin-dashboard.scss', }) -export class AdminUsers { - users = [ - { name: 'kil4_max', email: 'kil4@mail.com', role: 'jugador', state: 'ok', joined: 'mar 26', games: 412, level: 22 }, - { name: 'aritzz92', email: 'aritzz92@mail.com', role: 'jugador', state: 'ok', joined: 'mar 26', games: 247, level: 14 }, - { name: 'numerito', email: 'numero@otro.com', role: 'moderador', state: 'ok', joined: 'feb 26', games: 188, level: 18 }, - { name: 'tucanela', email: 'tuca@aqui.com', role: 'jugador', state: 'warn', joined: 'abr 26', games: 95, level: 8 }, - { name: 'soyrobot', email: 'rob@bot.io', role: 'jugador', state: 'banned', joined: 'abr 26', games: 12, level: 1 }, - { name: 'admin_root', email: 'admin@versus.io', role: 'admin', state: 'ok', joined: 'ene 26', games: 0, level: '—' }, - ]; +export class AdminUsers implements OnInit { + private readonly adminSvc = inject(AdminService); + + page = signal | null>(null); + loading = signal(true); + search = signal(''); + roleFilter = signal(''); + currentPage = signal(0); + + private searchTimeout: ReturnType | null = null; + + ngOnInit(): void { + this.load(); + } + + onSearchChange(val: string): void { + this.search.set(val); + if (this.searchTimeout) clearTimeout(this.searchTimeout); + this.searchTimeout = setTimeout(() => { + this.currentPage.set(0); + this.load(); + }, 400); + } + + onRoleChange(val: string): void { + this.roleFilter.set(val); + this.currentPage.set(0); + this.load(); + } + + private load(): void { + this.loading.set(true); + this.adminSvc + .getUsers( + this.currentPage(), + this.search() || undefined, + this.roleFilter() || undefined, + ) + .subscribe({ + next: (p) => { + this.page.set(p); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + updateRole(user: AdminUser, role: string): void { + this.adminSvc.updateUserRole(user.id, role).subscribe({ + next: (updated) => { + const p = this.page(); + if (!p) return; + this.page.set({ + ...p, + content: p.content.map((u) => (u.id === updated.id ? updated : u)), + }); + }, + }); + } + + toggleStatus(user: AdminUser): void { + this.adminSvc.updateUserStatus(user.id, !user.isActive).subscribe({ + next: (updated) => { + const p = this.page(); + if (!p) return; + this.page.set({ + ...p, + content: p.content.map((u) => (u.id === updated.id ? updated : u)), + }); + }, + }); + } roleColor(r: string): string { - return { admin: 'var(--vs-accent-red)', moderador: 'var(--vs-accent-gold)' }[r] ?? 'var(--vs-accent-blue)'; + return ( + { ADMIN: 'var(--vs-accent-red)', MODERATOR: 'var(--vs-accent-gold)' }[ + r + ] ?? 'var(--vs-accent-blue)' + ); } roleBg(r: string): string { - return { admin: 'rgba(230,57,70,0.12)', moderador: 'rgba(244,197,66,0.12)' }[r] ?? 'rgba(67,97,238,0.12)'; + return ( + { + ADMIN: 'rgba(230,57,70,0.12)', + MODERATOR: 'rgba(244,197,66,0.12)', + }[r] ?? 'rgba(67,97,238,0.12)' + ); } - initials(name: string): string { return name.slice(0, 2).toUpperCase(); } + initials(name: string): string { + return name.slice(0, 2).toUpperCase(); + } } From b08eba2dfad6148506760a15e1b21b43c4b4f101 Mon Sep 17 00:00:00 2001 From: ilyas2022 Date: Thu, 28 May 2026 15:00:49 +0200 Subject: [PATCH 2/3] fix(#99): encapsular estilos del admin sidebar en su propio componente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los selectores .vs-admin__side/.vs-admin__nav-item/.vs-admin__nav-section vivían en admin-dashboard.scss (scoped con la encapsulación del dashboard), por lo que nunca se aplicaban al
+ @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. }