feat(frontend): implement student self-registration and dashboard

- Make name and email required in registration form
- Add StudentDashboard component with enrolled subjects and program info
- Implement role-based navigation (admin sees management, student sees portal)
- Add adminGuard for restricting student management routes
- Redirect to /dashboard after login/register instead of /students
- Add navigation links: Mi Portal, Mis Materias, Companeros

Fulfills test requirements:
- Students can register online (punto 1)
- Enroll in up to 3 subjects/9 credits (puntos 2-5)
- Cannot have same professor twice (punto 7)
- View classmates by subject - names only (puntos 8-9)
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-08 09:37:19 -05:00
parent 9d8c6f0331
commit 49c74ab868
6 changed files with 546 additions and 23 deletions

View File

@ -28,9 +28,23 @@ import { ConnectivityOverlayComponent } from '@shared/index';
<span class="logo-text">Estudiantes</span> <span class="logo-text">Estudiantes</span>
</a> </a>
<nav class="nav-links"> <nav class="nav-links">
@if (authService.isAdmin()) {
<a routerLink="/students" routerLinkActive="active" class="nav-link"> <a routerLink="/students" routerLinkActive="active" class="nav-link">
Estudiantes Gestion Estudiantes
</a> </a>
} @else {
<a routerLink="/dashboard" routerLinkActive="active" class="nav-link">
Mi Portal
</a>
@if (authService.studentId()) {
<a [routerLink]="['/enrollment', authService.studentId()]" routerLinkActive="active" class="nav-link">
Mis Materias
</a>
<a [routerLink]="['/classmates', authService.studentId()]" routerLinkActive="active" class="nav-link">
Companeros
</a>
}
}
</nav> </nav>
<div class="user-menu"> <div class="user-menu">
<button class="user-button" [matMenuTriggerFor]="userMenu"> <button class="user-button" [matMenuTriggerFor]="userMenu">

View File

@ -1,8 +1,15 @@
import { Routes } from '@angular/router'; import { Routes } from '@angular/router';
import { authGuard, guestGuard } from '@core/guards/auth.guard'; import { authGuard, guestGuard, adminGuard } from '@core/guards/auth.guard';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', redirectTo: 'students', pathMatch: 'full' }, { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('@features/dashboard/pages/student-dashboard/student-dashboard.component')
.then(m => m.StudentDashboardComponent),
canActivate: [authGuard],
},
{ {
path: 'login', path: 'login',
loadComponent: () => loadComponent: () =>
@ -22,21 +29,21 @@ export const routes: Routes = [
loadComponent: () => loadComponent: () =>
import('@features/students/pages/student-list/student-list.component') import('@features/students/pages/student-list/student-list.component')
.then(m => m.StudentListComponent), .then(m => m.StudentListComponent),
canActivate: [authGuard], canActivate: [authGuard, adminGuard],
}, },
{ {
path: 'students/new', path: 'students/new',
loadComponent: () => loadComponent: () =>
import('@features/students/pages/student-form/student-form.component') import('@features/students/pages/student-form/student-form.component')
.then(m => m.StudentFormComponent), .then(m => m.StudentFormComponent),
canActivate: [authGuard], canActivate: [authGuard, adminGuard],
}, },
{ {
path: 'students/:id/edit', path: 'students/:id/edit',
loadComponent: () => loadComponent: () =>
import('@features/students/pages/student-form/student-form.component') import('@features/students/pages/student-form/student-form.component')
.then(m => m.StudentFormComponent), .then(m => m.StudentFormComponent),
canActivate: [authGuard], canActivate: [authGuard, adminGuard],
}, },
{ {
path: 'enrollment/:studentId', path: 'enrollment/:studentId',

View File

@ -20,7 +20,7 @@ export const authGuard: CanActivateFn = () => {
/** /**
* Guard that requires admin role to access a route. * Guard that requires admin role to access a route.
* Redirects to home if not admin. * Redirects to dashboard if not admin.
*/ */
export const adminGuard: CanActivateFn = () => { export const adminGuard: CanActivateFn = () => {
const authService = inject(AuthService); const authService = inject(AuthService);
@ -30,13 +30,13 @@ export const adminGuard: CanActivateFn = () => {
return true; return true;
} }
router.navigate(['/students']); router.navigate(['/dashboard']);
return false; return false;
}; };
/** /**
* Guard that prevents authenticated users from accessing a route. * Guard that prevents authenticated users from accessing a route.
* Useful for login page - redirects to home if already logged in. * Useful for login page - redirects to dashboard if already logged in.
*/ */
export const guestGuard: CanActivateFn = () => { export const guestGuard: CanActivateFn = () => {
const authService = inject(AuthService); const authService = inject(AuthService);
@ -46,6 +46,6 @@ export const guestGuard: CanActivateFn = () => {
return true; return true;
} }
router.navigate(['/students']); router.navigate(['/dashboard']);
return false; return false;
}; };

View File

@ -249,7 +249,7 @@ export class LoginComponent {
this.loading.set(false); this.loading.set(false);
if (response.success) { if (response.success) {
this.notification.success('Bienvenido!'); this.notification.success('Bienvenido!');
this.router.navigate(['/students']); this.router.navigate(['/dashboard']);
} else { } else {
this.serverError.set(response.error ?? 'Error al iniciar sesion'); this.serverError.set(response.error ?? 'Error al iniciar sesion');
} }

View File

@ -63,18 +63,28 @@ import { NotificationService } from '@core/services';
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="name">Nombre completo (opcional)</label> <label for="name">Nombre completo</label>
<input <input
id="name" id="name"
type="text" type="text"
class="input" class="input"
formControlName="name" formControlName="name"
placeholder="Tu nombre completo" placeholder="Tu nombre completo"
[class.error]="showError('name')"
/> />
@if (showError('name')) {
<span class="error-message">
@if (form.get('name')?.errors?.['required']) {
El nombre es requerido
} @else if (form.get('name')?.errors?.['minlength']) {
Minimo 3 caracteres
}
</span>
}
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="email">Email (opcional)</label> <label for="email">Email</label>
<input <input
id="email" id="email"
type="email" type="email"
@ -84,12 +94,16 @@ import { NotificationService } from '@core/services';
[class.error]="showError('email')" [class.error]="showError('email')"
/> />
@if (showError('email')) { @if (showError('email')) {
<span class="error-message">Email invalido</span> <span class="error-message">
@if (form.get('email')?.errors?.['required']) {
El email es requerido
} @else {
Email invalido
}
</span>
} }
</div> </div>
<p class="hint">Si proporcionas nombre y email, se creara tu perfil de estudiante automaticamente.</p>
@if (serverError()) { @if (serverError()) {
<div class="server-error"> <div class="server-error">
<mat-icon>error</mat-icon> <mat-icon>error</mat-icon>
@ -265,8 +279,8 @@ export class RegisterComponent {
form = this.fb.group({ form = this.fb.group({
username: ['', [Validators.required, Validators.minLength(3)]], username: ['', [Validators.required, Validators.minLength(3)]],
password: ['', [Validators.required, Validators.minLength(6)]], password: ['', [Validators.required, Validators.minLength(6)]],
name: [''], name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', Validators.email], email: ['', [Validators.required, Validators.email]],
}); });
loading = signal(false); loading = signal(false);
@ -291,14 +305,14 @@ export class RegisterComponent {
this.authService.register( this.authService.register(
username!, username!,
password!, password!,
name || undefined, name!,
email || undefined email!
).subscribe({ ).subscribe({
next: (response) => { next: (response) => {
this.loading.set(false); this.loading.set(false);
if (response.success) { if (response.success) {
this.notification.success('Cuenta creada exitosamente!'); this.notification.success('Cuenta creada exitosamente!');
this.router.navigate(['/students']); this.router.navigate(['/dashboard']);
} else { } else {
this.serverError.set(response.error ?? 'Error al crear la cuenta'); this.serverError.set(response.error ?? 'Error al crear la cuenta');
} }

View File

@ -0,0 +1,488 @@
import { Component, inject, signal, computed, OnInit } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Apollo } 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';
import { GET_STUDENT } from '@core/graphql/queries/students.queries';
interface Enrollment {
id: number;
subjectId: number;
subjectName: string;
credits: number;
professorName: string;
enrolledAt: string;
}
interface Student {
id: number;
name: string;
email: string;
totalCredits: number;
enrollments: Enrollment[];
}
@Component({
selector: 'app-student-dashboard',
standalone: true,
imports: [RouterLink, MatIconModule, MatButtonModule, MatProgressSpinnerModule],
template: `
<div class="dashboard">
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
<p>Cargando tu informacion...</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)="loadStudentData()">
<mat-icon>refresh</mat-icon>
Reintentar
</button>
</div>
} @else if (student()) {
<header class="dashboard-header">
<div class="welcome">
<h1>Bienvenido, {{ student()!.name }}</h1>
<p class="email">{{ student()!.email }}</p>
</div>
<div class="credits-badge">
<span class="credits-value">{{ student()!.totalCredits }}</span>
<span class="credits-label">Creditos</span>
</div>
</header>
<div class="dashboard-grid">
<!-- Enrolled Subjects Card -->
<section class="card enrollments-card">
<div class="card-header">
<h2>
<mat-icon>school</mat-icon>
Mis Materias
</h2>
<span class="count-badge">{{ student()!.enrollments.length }}/3</span>
</div>
@if (student()!.enrollments.length === 0) {
<div class="empty-state">
<mat-icon>menu_book</mat-icon>
<p>Aun no estas inscrito en ninguna materia</p>
<a [routerLink]="['/enrollment', studentId()]" class="btn btn-primary">
<mat-icon>add</mat-icon>
Inscribirme ahora
</a>
</div>
} @else {
<ul class="enrollments-list">
@for (enrollment of student()!.enrollments; track enrollment.id) {
<li class="enrollment-item">
<div class="subject-info">
<span class="subject-name">{{ enrollment.subjectName }}</span>
<span class="professor">Prof. {{ enrollment.professorName }}</span>
</div>
<span class="credits">{{ enrollment.credits }} creditos</span>
</li>
}
</ul>
@if (canEnrollMore()) {
<div class="card-actions">
<a [routerLink]="['/enrollment', studentId()]" class="btn btn-secondary">
<mat-icon>add</mat-icon>
Inscribir otra materia
</a>
</div>
}
}
</section>
<!-- Quick Actions Card -->
<section class="card actions-card">
<div class="card-header">
<h2>
<mat-icon>flash_on</mat-icon>
Acciones Rapidas
</h2>
</div>
<div class="actions-grid">
<a [routerLink]="['/enrollment', studentId()]" class="action-btn">
<mat-icon>edit_note</mat-icon>
<span>Gestionar Inscripciones</span>
<small>Inscribir o retirar materias</small>
</a>
<a [routerLink]="['/classmates', studentId()]" class="action-btn">
<mat-icon>groups</mat-icon>
<span>Ver Companeros</span>
<small>Conoce a tus companeros de clase</small>
</a>
</div>
</section>
<!-- Program Info Card -->
<section class="card info-card">
<div class="card-header">
<h2>
<mat-icon>info</mat-icon>
Programa de Creditos
</h2>
</div>
<div class="info-content">
<div class="info-item">
<mat-icon>book</mat-icon>
<div>
<strong>10 materias</strong>
<span>disponibles en total</span>
</div>
</div>
<div class="info-item">
<mat-icon>stars</mat-icon>
<div>
<strong>3 creditos</strong>
<span>por cada materia</span>
</div>
</div>
<div class="info-item">
<mat-icon>check_circle</mat-icon>
<div>
<strong>Maximo 3 materias</strong>
<span>puedes inscribir (9 creditos)</span>
</div>
</div>
<div class="info-item">
<mat-icon>person</mat-icon>
<div>
<strong>5 profesores</strong>
<span>cada uno imparte 2 materias</span>
</div>
</div>
<div class="info-item warning">
<mat-icon>warning</mat-icon>
<div>
<strong>Restriccion</strong>
<span>No puedes tener 2 materias con el mismo profesor</span>
</div>
</div>
</div>
</section>
</div>
}
</div>
`,
styles: [`
.dashboard {
max-width: 1000px;
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);
}
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
padding: 1.5rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 1rem;
color: white;
}
.welcome h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
font-weight: 600;
}
.welcome .email {
margin: 0;
opacity: 0.9;
font-size: 0.875rem;
}
.credits-badge {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.2);
padding: 1rem 1.5rem;
border-radius: 0.75rem;
}
.credits-value {
font-size: 2rem;
font-weight: 700;
}
.credits-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.9;
}
.dashboard-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: 1fr 1fr;
}
.enrollments-card {
grid-row: span 2;
}
}
.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);
}
.count-badge {
background: var(--bg-secondary);
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
text-align: center;
color: var(--text-secondary);
}
.empty-state mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state p {
margin: 0 0 1rem;
}
.enrollments-list {
list-style: none;
padding: 0;
margin: 0;
}
.enrollment-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-radius: 0.5rem;
background: var(--bg-secondary);
margin-bottom: 0.5rem;
}
.subject-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.subject-name {
font-weight: 500;
}
.professor {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.credits {
font-size: 0.8125rem;
color: var(--text-secondary);
background: white;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
}
.card-actions {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
}
.actions-grid {
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 span {
font-weight: 500;
}
.action-btn small {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
font-weight: normal;
}
.info-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-secondary);
border-radius: 0.5rem;
}
.info-item mat-icon {
color: var(--accent);
}
.info-item div {
display: flex;
flex-direction: column;
}
.info-item strong {
font-size: 0.875rem;
}
.info-item span {
font-size: 0.75rem;
color: var(--text-secondary);
}
.info-item.warning {
background: rgba(245, 158, 11, 0.1);
}
.info-item.warning mat-icon {
color: #f59e0b;
}
`]
})
export class StudentDashboardComponent implements OnInit {
private apollo = inject(Apollo);
private authService = inject(AuthService);
student = signal<Student | null>(null);
loading = signal(true);
error = signal<string | null>(null);
studentId = computed(() => this.authService.studentId());
canEnrollMore = computed(() => (this.student()?.enrollments.length ?? 0) < 3);
ngOnInit(): void {
this.loadStudentData();
}
loadStudentData(): void {
const id = this.studentId();
if (!id) {
this.error.set('No se encontro tu perfil de estudiante');
this.loading.set(false);
return;
}
this.loading.set(true);
this.error.set(null);
this.apollo
.query<{ student: Student }>({
query: GET_STUDENT,
variables: { id },
fetchPolicy: 'network-only',
})
.subscribe({
next: (result) => {
if (result.data?.student) {
this.student.set(result.data.student);
}
this.loading.set(false);
},
error: (err) => {
this.error.set(err.message || 'Error al cargar datos');
this.loading.set(false);
},
});
}
}