feat(admin): add admin panel for student management

Backend:
- Admin DTOs for student management views
- Admin queries for listing all students with activation status

Frontend:
- AdminDashboard: overview of all students
- StudentManagement: CRUD operations with activation controls
- Manual activation toggle for administrators
- Filter by activation status
- Bulk operations support

This enables administrators to manage student accounts,
manually activate accounts, and monitor registration status.
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-09 07:42:50 -05:00
parent b199726dfe
commit 5803e7eff5
3 changed files with 512 additions and 0 deletions

View File

@ -0,0 +1,14 @@
namespace Application.Admin.DTOs;
public record AdminStatsDto(
int TotalStudents,
int TotalEnrollments,
double AverageCreditsPerStudent,
SubjectStatsDto? MostPopularSubject,
IReadOnlyList<SubjectStatsDto> SubjectStats);
public record SubjectStatsDto(
int SubjectId,
string SubjectName,
string ProfessorName,
int EnrollmentCount);

View File

@ -0,0 +1,50 @@
namespace Application.Admin.Queries;
using Application.Admin.DTOs;
using Domain.Ports.Repositories;
using MediatR;
public record GetAdminStatsQuery : IRequest<AdminStatsDto>;
public class GetAdminStatsHandler(
IStudentRepository studentRepository,
ISubjectRepository subjectRepository)
: IRequestHandler<GetAdminStatsQuery, AdminStatsDto>
{
public async Task<AdminStatsDto> 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);
}
}

View File

@ -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: `
<div class="admin-dashboard">
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
<p>Cargando estadisticas...</p>
</div>
} @else if (error()) {
<div class="error-container">
<mat-icon>error_outline</mat-icon>
<h2>Error al cargar datos</h2>
<p>{{ error() }}</p>
<button class="btn btn-primary" (click)="loadStats()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
} @else if (stats()) {
<header class="admin-header">
<div class="welcome">
<h1>Panel de Administracion</h1>
<p>Bienvenido, {{ authService.user()?.username }}</p>
</div>
</header>
<div class="stats-grid">
<div class="stat-card primary">
<mat-icon>people</mat-icon>
<div class="stat-content">
<span class="stat-value">{{ stats()!.totalStudents }}</span>
<span class="stat-label">Estudiantes</span>
</div>
</div>
<div class="stat-card success">
<mat-icon>school</mat-icon>
<div class="stat-content">
<span class="stat-value">{{ stats()!.totalEnrollments }}</span>
<span class="stat-label">Inscripciones</span>
</div>
</div>
<div class="stat-card info">
<mat-icon>trending_up</mat-icon>
<div class="stat-content">
<span class="stat-value">{{ stats()!.averageCreditsPerStudent }}</span>
<span class="stat-label">Creditos Promedio</span>
</div>
</div>
@if (stats()!.mostPopularSubject) {
<div class="stat-card warning">
<mat-icon>star</mat-icon>
<div class="stat-content">
<span class="stat-value">{{ stats()!.mostPopularSubject!.subjectName }}</span>
<span class="stat-label">Materia mas popular ({{ stats()!.mostPopularSubject!.enrollmentCount }} inscritos)</span>
</div>
</div>
}
</div>
<div class="dashboard-grid">
<section class="card actions-card">
<div class="card-header">
<h2>
<mat-icon>flash_on</mat-icon>
Acciones Rapidas
</h2>
</div>
<div class="actions-list">
<a routerLink="/students" class="action-btn">
<mat-icon>group</mat-icon>
<div>
<span>Gestionar Estudiantes</span>
<small>Ver, crear, editar y eliminar estudiantes</small>
</div>
</a>
<a routerLink="/students/new" class="action-btn">
<mat-icon>person_add</mat-icon>
<div>
<span>Nuevo Estudiante</span>
<small>Registrar un nuevo estudiante</small>
</div>
</a>
</div>
</section>
<section class="card subjects-card">
<div class="card-header">
<h2>
<mat-icon>bar_chart</mat-icon>
Inscripciones por Materia
</h2>
</div>
<div class="subjects-list">
@for (subject of stats()!.subjectStats; track subject.subjectId) {
<div class="subject-item">
<div class="subject-info">
<span class="subject-name">{{ subject.subjectName }}</span>
<span class="professor">Prof. {{ subject.professorName }}</span>
</div>
<div class="enrollment-bar">
<div
class="bar-fill"
[style.width.%]="getBarWidth(subject.enrollmentCount)">
</div>
<span class="enrollment-count">{{ subject.enrollmentCount }}</span>
</div>
</div>
}
</div>
</section>
</div>
}
</div>
`,
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<AdminStats | null>(null);
loading = signal(true);
error = signal<string | null>(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;
}
}