diff --git a/src/backend/Application/Admin/DTOs/AdminStatsDto.cs b/src/backend/Application/Admin/DTOs/AdminStatsDto.cs new file mode 100644 index 0000000..adcf568 --- /dev/null +++ b/src/backend/Application/Admin/DTOs/AdminStatsDto.cs @@ -0,0 +1,14 @@ +namespace Application.Admin.DTOs; + +public record AdminStatsDto( + int TotalStudents, + int TotalEnrollments, + double AverageCreditsPerStudent, + SubjectStatsDto? MostPopularSubject, + IReadOnlyList SubjectStats); + +public record SubjectStatsDto( + int SubjectId, + string SubjectName, + string ProfessorName, + int EnrollmentCount); diff --git a/src/backend/Application/Admin/Queries/GetAdminStatsQuery.cs b/src/backend/Application/Admin/Queries/GetAdminStatsQuery.cs new file mode 100644 index 0000000..e43aaad --- /dev/null +++ b/src/backend/Application/Admin/Queries/GetAdminStatsQuery.cs @@ -0,0 +1,50 @@ +namespace Application.Admin.Queries; + +using Application.Admin.DTOs; +using Domain.Ports.Repositories; +using MediatR; + +public record GetAdminStatsQuery : IRequest; + +public class GetAdminStatsHandler( + IStudentRepository studentRepository, + ISubjectRepository subjectRepository) + : IRequestHandler +{ + public async Task Handle(GetAdminStatsQuery request, CancellationToken ct) + { + var students = await studentRepository.GetAllWithEnrollmentsAsync(ct); + var totalStudents = students.Count; + + var totalEnrollments = students.Sum(s => s.Enrollments.Count); + + var avgCredits = totalStudents > 0 + ? students.Average(s => s.Enrollments.Sum(e => e.Subject?.Credits ?? 3)) + : 0; + + var subjects = await subjectRepository.GetAllWithProfessorsAsync(ct); + + var enrollmentCounts = students + .SelectMany(s => s.Enrollments) + .GroupBy(e => e.SubjectId) + .ToDictionary(g => g.Key, g => g.Count()); + + var subjectStats = subjects + .Select(s => new SubjectStatsDto( + s.Id, + s.Name, + s.Professor?.Name ?? "Sin profesor", + enrollmentCounts.GetValueOrDefault(s.Id, 0))) + .OrderByDescending(s => s.EnrollmentCount) + .ToList(); + + var mostPopular = subjectStats.FirstOrDefault(); + + return new AdminStatsDto( + totalStudents, + totalEnrollments, + Math.Round(avgCredits, 1), + mostPopular, + subjectStats); + } +} diff --git a/src/frontend/src/app/features/admin/pages/admin-dashboard/admin-dashboard.component.ts b/src/frontend/src/app/features/admin/pages/admin-dashboard/admin-dashboard.component.ts new file mode 100644 index 0000000..771216b --- /dev/null +++ b/src/frontend/src/app/features/admin/pages/admin-dashboard/admin-dashboard.component.ts @@ -0,0 +1,448 @@ +import { Component, inject, signal, OnInit } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Apollo, gql } from 'apollo-angular'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { AuthService } from '@core/services/auth.service'; + +const GET_ADMIN_STATS = gql` + query GetAdminStats { + adminStats { + totalStudents + totalEnrollments + averageCreditsPerStudent + mostPopularSubject { + subjectId + subjectName + professorName + enrollmentCount + } + subjectStats { + subjectId + subjectName + professorName + enrollmentCount + } + } + } +`; + +interface SubjectStats { + subjectId: number; + subjectName: string; + professorName: string; + enrollmentCount: number; +} + +interface AdminStats { + totalStudents: number; + totalEnrollments: number; + averageCreditsPerStudent: number; + mostPopularSubject: SubjectStats | null; + subjectStats: SubjectStats[]; +} + +@Component({ + selector: 'app-admin-dashboard', + standalone: true, + imports: [RouterLink, MatIconModule, MatButtonModule, MatProgressSpinnerModule], + template: ` +
+ @if (loading()) { +
+ +

Cargando estadisticas...

+
+ } @else if (error()) { +
+ error_outline +

Error al cargar datos

+

{{ error() }}

+ +
+ } @else if (stats()) { +
+
+

Panel de Administracion

+

Bienvenido, {{ authService.user()?.username }}

+
+
+ +
+
+ people +
+ {{ stats()!.totalStudents }} + Estudiantes +
+
+ +
+ school +
+ {{ stats()!.totalEnrollments }} + Inscripciones +
+
+ +
+ trending_up +
+ {{ stats()!.averageCreditsPerStudent }} + Creditos Promedio +
+
+ + @if (stats()!.mostPopularSubject) { +
+ star +
+ {{ stats()!.mostPopularSubject!.subjectName }} + Materia mas popular ({{ stats()!.mostPopularSubject!.enrollmentCount }} inscritos) +
+
+ } +
+ +
+
+
+

+ flash_on + Acciones Rapidas +

+
+ +
+ +
+
+

+ bar_chart + Inscripciones por Materia +

+
+
+ @for (subject of stats()!.subjectStats; track subject.subjectId) { +
+
+ {{ subject.subjectName }} + Prof. {{ subject.professorName }} +
+
+
+
+ {{ subject.enrollmentCount }} +
+
+ } +
+
+
+ } +
+ `, + styles: [` + .admin-dashboard { + max-width: 1200px; + margin: 0 auto; + } + + .loading-container, .error-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + gap: 1rem; + text-align: center; + } + + .error-container mat-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--error); + } + + .admin-header { + margin-bottom: 2rem; + padding: 1.5rem; + background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); + border-radius: 1rem; + color: white; + } + + .admin-header h1 { + margin: 0 0 0.25rem; + font-size: 1.75rem; + font-weight: 600; + } + + .admin-header p { + margin: 0; + opacity: 0.9; + } + + .stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; + } + + .stat-card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem; + background: white; + border-radius: 1rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + .stat-card mat-icon { + font-size: 40px; + width: 40px; + height: 40px; + padding: 0.75rem; + border-radius: 0.75rem; + } + + .stat-card.primary mat-icon { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; + } + + .stat-card.success mat-icon { + background: rgba(34, 197, 94, 0.1); + color: #22c55e; + } + + .stat-card.info mat-icon { + background: rgba(99, 102, 241, 0.1); + color: #6366f1; + } + + .stat-card.warning mat-icon { + background: rgba(245, 158, 11, 0.1); + color: #f59e0b; + } + + .stat-content { + display: flex; + flex-direction: column; + } + + .stat-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + } + + .stat-label { + font-size: 0.8125rem; + color: var(--text-secondary); + } + + .dashboard-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: 1fr; + } + + @media (min-width: 768px) { + .dashboard-grid { + grid-template-columns: 1fr 2fr; + } + } + + .card { + background: white; + border-radius: 1rem; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + } + + .card-header h2 { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 1.125rem; + font-weight: 600; + } + + .card-header mat-icon { + color: var(--accent); + } + + .actions-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .action-btn { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem; + background: var(--bg-secondary); + border-radius: 0.75rem; + text-decoration: none; + color: inherit; + transition: all 0.2s; + } + + .action-btn:hover { + background: var(--border-light); + transform: translateX(4px); + } + + .action-btn mat-icon { + color: var(--accent); + } + + .action-btn div { + display: flex; + flex-direction: column; + } + + .action-btn span { + font-weight: 500; + } + + .action-btn small { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .subjects-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .subject-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: var(--bg-secondary); + border-radius: 0.5rem; + } + + .subject-info { + display: flex; + flex-direction: column; + gap: 0.125rem; + } + + .subject-name { + font-weight: 500; + font-size: 0.9375rem; + } + + .professor { + font-size: 0.75rem; + color: var(--text-secondary); + } + + .enrollment-bar { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 100px; + } + + .bar-fill { + height: 8px; + background: linear-gradient(90deg, #3b82f6, #6366f1); + border-radius: 4px; + min-width: 4px; + transition: width 0.3s ease; + } + + .enrollment-count { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + min-width: 20px; + text-align: right; + } + `] +}) +export class AdminDashboardComponent implements OnInit { + private apollo = inject(Apollo); + authService = inject(AuthService); + + stats = signal(null); + loading = signal(true); + error = signal(null); + + private maxEnrollments = 0; + + ngOnInit(): void { + this.loadStats(); + } + + loadStats(): void { + this.loading.set(true); + this.error.set(null); + + this.apollo + .query<{ adminStats: AdminStats }>({ + query: GET_ADMIN_STATS, + fetchPolicy: 'network-only', + }) + .subscribe({ + next: (result) => { + if (result.data?.adminStats) { + this.stats.set(result.data.adminStats); + this.maxEnrollments = Math.max( + ...result.data.adminStats.subjectStats.map(s => s.enrollmentCount), + 1 + ); + } + this.loading.set(false); + }, + error: (err) => { + this.error.set(err.message || 'Error al cargar estadisticas'); + this.loading.set(false); + }, + }); + } + + getBarWidth(count: number): number { + return (count / this.maxEnrollments) * 100; + } +}