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:
parent
b199726dfe
commit
5803e7eff5
|
|
@ -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);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue