feat(frontend): add Angular 21 SPA with Material Design

Core:
- Apollo Angular for GraphQL integration
- Student and Enrollment services
- Connectivity monitoring with health checks
- Error handling with user-friendly messages

Features:
- Students: list, create, edit, delete
- Enrollment: subject selection with validation feedback
- Classmates: view students in shared subjects

Shared Components:
- ConfirmDialog, EmptyState, LoadingSpinner
- ConnectivityOverlay for offline detection
- Custom pipes (credits, initials)

UI:
- Angular Material with custom theme
- Responsive layout with navigation
- Real-time validation feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andrés Eduardo García Márquez 2026-01-07 23:00:12 -05:00
parent 2b323adcb4
commit e30424cd1f
49 changed files with 24754 additions and 0 deletions

92
src/frontend/angular.json Normal file
View File

@ -0,0 +1,92 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"student-enrollment": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss",
"standalone": true,
"changeDetection": "OnPush"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/student-enrollment",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{ "glob": "**/*", "input": "src/assets", "output": "/assets" }
],
"styles": [
"@angular/material/prebuilt-themes/indigo-pink.css",
"src/styles/main.scss"
],
"scripts": []
},
"configurations": {
"production": {
"budgets": [
{ "type": "initial", "maximumWarning": "800kB", "maximumError": "1.5MB" },
{ "type": "anyComponentStyle", "maximumWarning": "8kB", "maximumError": "16kB" }
],
"outputHashing": "all",
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": { "buildTarget": "student-enrollment:build:production" },
"development": { "buildTarget": "student-enrollment:build:development" }
},
"defaultConfiguration": "development"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{ "glob": "**/*", "input": "src/assets", "output": "/assets" }
],
"styles": ["src/styles/main.scss"],
"scripts": []
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
},
"cli": {
"analytics": false
}
}

32
src/frontend/codegen.ts Normal file
View File

@ -0,0 +1,32 @@
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
overwrite: true,
schema: 'http://localhost:5000/graphql',
documents: 'src/**/*.ts',
generates: {
'src/app/core/graphql/generated/types.ts': {
plugins: [
'typescript',
'typescript-operations',
],
config: {
strictScalars: true,
scalars: {
DateTime: 'string',
Date: 'string',
UUID: 'string',
},
enumsAsTypes: true,
avoidOptionals: {
field: true,
inputValue: false,
object: true,
},
maybeValue: 'T | null',
},
},
},
};
export default config;

View File

@ -0,0 +1,78 @@
import { test, expect } from '@playwright/test';
import { setupGraphQLMocks } from './mocks/graphql.mock';
test.describe('Flujo: Ver Compañeros de Clase', () => {
test.beforeEach(async ({ page }) => {
await setupGraphQLMocks(page);
});
test('debe navegar a la página de compañeros desde el listado', async ({ page }) => {
await page.goto('/students');
// Click en el botón de ver compañeros del primer estudiante
await page.getByTestId('btn-classmates').first().click();
// Verificar navegación
await expect(page).toHaveURL(/\/classmates\/\d+/);
await expect(page.getByText('Compañeros de Clase')).toBeVisible();
});
test('debe mostrar los compañeros agrupados por materia', async ({ page }) => {
await page.goto('/classmates/1');
// Verificar que se muestran las materias
await expect(page.getByText('Matemáticas I')).toBeVisible();
await expect(page.getByText('Física I')).toBeVisible();
// Verificar que se muestran los compañeros
await expect(page.getByText('María García')).toBeVisible();
await expect(page.getByText('Carlos López')).toBeVisible();
await expect(page.getByText('Ana Rodríguez')).toBeVisible();
await expect(page.getByText('Pedro Sánchez')).toBeVisible();
});
test('debe mostrar el contador de compañeros por materia', async ({ page }) => {
await page.goto('/classmates/1');
// Verificar que se muestra el contador
await expect(page.getByText('2 compañeros')).toHaveCount(2); // 2 materias con 2 compañeros cada una
});
test('debe tener botón para volver a inscripción', async ({ page }) => {
await page.goto('/classmates/1');
// Verificar que existe el botón de ver inscripción
await expect(page.getByText('Ver Inscripción')).toBeVisible();
});
test('debe navegar a inscripción al hacer click en el botón', async ({ page }) => {
await page.goto('/classmates/1');
// Click en ver inscripción
await page.getByText('Ver Inscripción').click();
// Verificar navegación
await expect(page).toHaveURL(/\/enrollment\/\d+/);
});
test('debe mostrar las iniciales de los compañeros', async ({ page }) => {
await page.goto('/classmates/1');
// Verificar que se muestran avatares con iniciales
await expect(page.locator('.classmate-item-avatar')).toHaveCount(4);
// Verificar algunas iniciales específicas
await expect(page.locator('.classmate-item-avatar', { hasText: 'MG' })).toBeVisible();
await expect(page.locator('.classmate-item-avatar', { hasText: 'CL' })).toBeVisible();
});
test('debe volver al listado con el botón de volver', async ({ page }) => {
await page.goto('/classmates/1');
// Click en volver
await page.getByText('Volver a estudiantes').click();
// Verificar navegación
await expect(page).toHaveURL('/students');
});
});

View File

@ -0,0 +1,83 @@
import { test, expect } from '@playwright/test';
import { setupGraphQLMocks } from './mocks/graphql.mock';
test.describe('Flujo: Inscribir Materia', () => {
test.beforeEach(async ({ page }) => {
await setupGraphQLMocks(page);
});
test('debe navegar a la página de inscripción desde el listado', async ({ page }) => {
await page.goto('/students');
// Click en el botón de inscribir del primer estudiante
await page.getByTestId('btn-enroll').first().click();
// Verificar navegación
await expect(page).toHaveURL(/\/enrollment\/\d+/);
await expect(page.getByText('Inscripción:')).toBeVisible();
});
test('debe mostrar las materias inscritas y disponibles', async ({ page }) => {
await page.goto('/enrollment/1');
// Verificar sección de materias inscritas
await expect(page.getByText('Materias Inscritas')).toBeVisible();
await expect(page.getByTestId('enrolled-subject-name').first()).toContainText('Matemáticas I');
await expect(page.getByTestId('enrolled-subject-name').nth(1)).toContainText('Física I');
// Verificar sección de materias disponibles
await expect(page.getByText('Materias Disponibles')).toBeVisible();
await expect(page.getByText('Programación I', { exact: true })).toBeVisible();
});
test('debe mostrar el progreso de créditos correctamente', async ({ page }) => {
await page.goto('/enrollment/1');
// Verificar que muestra 6/9 créditos (2 materias de 3 créditos cada una)
await expect(page.getByText('6')).toBeVisible();
await expect(page.getByText('/9')).toBeVisible();
});
test('debe mostrar advertencia en materias con mismo profesor', async ({ page }) => {
await page.goto('/enrollment/1');
// Verificar que se muestra la advertencia de mismo profesor
await expect(page.locator('.subject-card-warning').first()).toBeVisible();
});
test('debe inscribir una materia y mostrar mensaje de éxito', async ({ page }) => {
await page.goto('/enrollment/1');
// Buscar botones de inscribir que no estén deshabilitados
const enabledButtons = page.locator('[data-testid="btn-enroll-subject"]:not([disabled])');
// Si hay botones habilitados, hacer click en el primero
const count = await enabledButtons.count();
if (count > 0) {
await enabledButtons.first().click();
// Verificar mensaje de éxito
await expect(page.getByText(/Inscrito en/)).toBeVisible({ timeout: 5000 });
} else {
// Si no hay botones habilitados, la prueba pasa porque significa que ya están todas las materias restringidas
await expect(page.locator('.subject-card-warning').first()).toBeVisible();
}
});
test('debe cancelar inscripción y mostrar mensaje de éxito', async ({ page }) => {
await page.goto('/enrollment/1');
// Click en cancelar inscripción de la primera materia
await page.getByTestId('btn-unenroll-subject').first().click();
// Verificar mensaje de éxito
await expect(page.getByText('Inscripción cancelada:')).toBeVisible({ timeout: 5000 });
});
test('los botones de materias con mismo profesor deben estar deshabilitados', async ({ page }) => {
await page.goto('/enrollment/1');
// Verificar que el botón de Matemáticas II está deshabilitado (mismo profesor que Matemáticas I)
const matematicasIICard = page.locator('.subject-card', { hasText: 'Matemáticas II' });
await expect(matematicasIICard.getByTestId('btn-enroll-subject')).toBeDisabled();
});
});

View File

@ -0,0 +1,136 @@
import { Page } from '@playwright/test';
export const mockStudents = [
{
id: 1,
name: 'Juan Pérez',
email: 'juan@test.com',
totalCredits: 6,
enrollments: [
{
id: 1,
subject: { id: 1, name: 'Matemáticas I', credits: 3, professor: { id: 1, name: 'Dr. García' } },
enrolledAt: '2024-01-01',
},
{
id: 2,
subject: { id: 3, name: 'Física I', credits: 3, professor: { id: 2, name: 'Dra. Martínez' } },
enrolledAt: '2024-01-01',
},
],
},
];
export const mockAvailableSubjects = [
{
subject: { id: 2, name: 'Matemáticas II', credits: 3, professor: { id: 1, name: 'Dr. García' } },
isAvailable: false,
unavailableReason: 'Ya tienes una materia con este profesor',
},
{
subject: { id: 4, name: 'Física II', credits: 3, professor: { id: 2, name: 'Dra. Martínez' } },
isAvailable: false,
unavailableReason: 'Ya tienes una materia con este profesor',
},
{
subject: { id: 5, name: 'Programación I', credits: 3, professor: { id: 3, name: 'Dr. López' } },
isAvailable: true,
unavailableReason: null,
},
{
subject: { id: 6, name: 'Programación II', credits: 3, professor: { id: 3, name: 'Dr. López' } },
isAvailable: true,
unavailableReason: null,
},
];
export const mockClassmates = [
{ subjectName: 'Matemáticas I', students: ['María García', 'Carlos López'] },
{ subjectName: 'Física I', students: ['Ana Rodríguez', 'Pedro Sánchez'] },
];
export async function setupGraphQLMocks(page: Page) {
await page.route('**/graphql', async (route) => {
const request = route.request();
const postData = request.postDataJSON();
const operationName = postData?.operationName;
let responseData: object;
switch (operationName) {
case 'GetStudents':
responseData = { data: { students: mockStudents } };
break;
case 'GetStudent':
const studentId = postData?.variables?.id;
const student = mockStudents.find((s) => s.id === studentId) || mockStudents[0];
responseData = { data: { student } };
break;
case 'CreateStudent':
const newStudent = {
id: Date.now(),
name: postData?.variables?.input?.name,
email: postData?.variables?.input?.email,
totalCredits: 0,
enrollments: [],
};
responseData = { data: { createStudent: { student: newStudent, errors: null } } };
break;
case 'UpdateStudent':
responseData = {
data: {
updateStudent: {
student: { ...mockStudents[0], ...postData?.variables?.input },
errors: null,
},
},
};
break;
case 'DeleteStudent':
responseData = { data: { deleteStudent: { success: true, errors: null } } };
break;
case 'GetAvailableSubjects':
responseData = { data: { availableSubjects: mockAvailableSubjects } };
break;
case 'EnrollStudent':
const subjectId = postData?.variables?.input?.subjectId;
const subject = mockAvailableSubjects.find((s) => s.subject.id === subjectId)?.subject;
responseData = {
data: {
enrollStudent: {
enrollment: {
id: Date.now(),
subject,
enrolledAt: new Date().toISOString(),
},
errors: null,
},
},
};
break;
case 'UnenrollStudent':
responseData = { data: { unenrollStudent: { success: true, errors: null } } };
break;
case 'GetClassmates':
responseData = { data: { classmates: mockClassmates } };
break;
default:
responseData = { data: null };
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(responseData),
});
});
}

View File

@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { setupGraphQLMocks } from './mocks/graphql.mock';
test.describe('Flujo: Crear Estudiante', () => {
test.beforeEach(async ({ page }) => {
await setupGraphQLMocks(page);
});
test('debe mostrar el listado de estudiantes', async ({ page }) => {
await page.goto('/students');
// Verificar que se muestra la tabla de estudiantes
await expect(page.getByTestId('students-table')).toBeVisible();
await expect(page.getByText('Juan Pérez')).toBeVisible();
});
test('debe navegar al formulario de nuevo estudiante', async ({ page }) => {
await page.goto('/students');
// Click en nuevo estudiante
await page.getByTestId('btn-new-student').click();
// Verificar navegación
await expect(page).toHaveURL('/students/new');
await expect(page.getByRole('heading', { name: 'Nuevo Estudiante' })).toBeVisible();
});
test('debe crear un estudiante y mostrar mensaje de éxito', async ({ page }) => {
await page.goto('/students/new');
// Llenar formulario
await page.getByTestId('input-name').fill('María García López');
await page.getByTestId('input-email').fill('maria@test.com');
// Enviar formulario
await page.getByTestId('btn-submit').click();
// Verificar mensaje de éxito en snackbar
await expect(page.getByText('Estudiante creado correctamente')).toBeVisible({ timeout: 5000 });
// Verificar redirección al listado
await expect(page).toHaveURL('/students');
});
test('debe mostrar errores de validación si los campos están vacíos', async ({ page }) => {
await page.goto('/students/new');
// Hacer focus y blur en los campos para activar validación
await page.getByTestId('input-name').focus();
await page.getByTestId('input-email').focus();
await page.getByTestId('input-name').blur();
// El botón debe estar deshabilitado
await expect(page.getByTestId('btn-submit')).toBeDisabled();
});
test('debe mostrar error si el nombre es muy corto', async ({ page }) => {
await page.goto('/students/new');
// Escribir nombre corto
await page.getByTestId('input-name').fill('AB');
await page.getByTestId('input-email').focus();
// Verificar mensaje de error
await expect(page.getByText('El nombre debe tener al menos 3 caracteres')).toBeVisible();
});
test('debe mostrar error si el email es inválido', async ({ page }) => {
await page.goto('/students/new');
// Escribir email inválido
await page.getByTestId('input-name').fill('Test User');
await page.getByTestId('input-email').fill('email-invalido');
await page.getByTestId('input-name').focus();
// Verificar mensaje de error
await expect(page.getByText('Ingresa un email válido')).toBeVisible();
});
});

View File

@ -0,0 +1,46 @@
import eslint from "@eslint/js";
import tseslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import angularEslint from "@angular-eslint/eslint-plugin";
import angularTemplateEslint from "@angular-eslint/eslint-plugin-template";
import angularTemplateParser from "@angular-eslint/template-parser";
export default [
{
ignores: ["node_modules/**", "dist/**", ".angular/**"],
},
{
files: ["**/*.ts"],
languageOptions: {
parser: tsParser,
parserOptions: {
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": tseslint,
"@angular-eslint": angularEslint,
},
rules: {
...tseslint.configs.recommended.rules,
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@angular-eslint/directive-selector": ["error", { type: "attribute", prefix: "app", style: "camelCase" }],
"@angular-eslint/component-selector": ["error", { type: "element", prefix: "app", style: "kebab-case" }],
},
},
{
files: ["**/*.html"],
languageOptions: {
parser: angularTemplateParser,
},
plugins: {
"@angular-eslint/template": angularTemplateEslint,
},
rules: {
"@angular-eslint/template/banana-in-box": "error",
"@angular-eslint/template/no-negated-async": "error",
},
},
];

20303
src/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

63
src/frontend/package.json Normal file
View File

@ -0,0 +1,63 @@
{
"name": "student-enrollment",
"version": "1.0.0",
"description": "Sistema de Registro de Estudiantes - Inter Rapidísimo",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"lint": "ng lint",
"codegen": "graphql-codegen --config codegen.ts",
"codegen:watch": "graphql-codegen --config codegen.ts --watch"
},
"private": true,
"dependencies": {
"@angular/animations": "21.0.7",
"@angular/cdk": "21.0.5",
"@angular/common": "21.0.7",
"@angular/compiler": "21.0.7",
"@angular/core": "21.0.7",
"@angular/forms": "21.0.7",
"@angular/material": "21.0.5",
"@angular/platform-browser": "21.0.7",
"@angular/platform-browser-dynamic": "21.0.7",
"@angular/router": "21.0.7",
"@apollo/client": "^4.0.1",
"apollo-angular": "^13.0.0",
"graphql": "^16.10.0",
"rxjs": "~7.8.1",
"tslib": "^2.8.1",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "21.0.5",
"@angular-eslint/builder": "21.1.0",
"@angular-eslint/eslint-plugin": "21.1.0",
"@angular-eslint/eslint-plugin-template": "21.1.0",
"@angular-eslint/schematics": "21.1.0",
"@angular-eslint/template-parser": "21.1.0",
"@angular/cli": "21.0.5",
"@angular/compiler-cli": "21.0.7",
"@graphql-codegen/cli": "^6.1.0",
"@graphql-codegen/typescript": "^5.0.7",
"@graphql-codegen/typescript-apollo-angular": "^4.0.1",
"@graphql-codegen/typescript-operations": "^5.0.7",
"@playwright/test": "^1.57.0",
"@types/jasmine": "~5.1.4",
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"jasmine-core": "~5.4.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.1",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "^5.9.0-beta"
}
}

View File

@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env['CI'],
retries: process.env['CI'] ? 2 : 0,
workers: process.env['CI'] ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:4200',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run start',
url: 'http://localhost:4200',
reuseExistingServer: !process.env['CI'],
timeout: 120000,
},
});

View File

@ -0,0 +1,143 @@
import { Component, ChangeDetectionStrategy, inject, OnInit } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { ConnectivityService } from '@core/services';
import { ConnectivityOverlayComponent } from '@shared/index';
@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet, RouterLink, RouterLinkActive,
MatToolbarModule, MatButtonModule, MatIconModule,
ConnectivityOverlayComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- Overlay de conectividad - bloquea UI si no hay conexión -->
<app-connectivity-overlay />
<div class="app-container">
<header class="app-header">
<a routerLink="/" class="logo">
<span class="logo-icon">S</span>
<span class="logo-text">Estudiantes</span>
</a>
<nav class="nav-links">
<a routerLink="/students" routerLinkActive="active" class="nav-link">
Estudiantes
</a>
</nav>
</header>
<main class="app-main">
<router-outlet />
</main>
</div>
`,
styles: [`
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-primary);
}
.app-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
height: 64px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 1px solid var(--border-light);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
text-decoration: none;
color: var(--text-primary);
}
.logo-icon {
width: 32px;
height: 32px;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 1.125rem;
}
.logo-text {
font-weight: 600;
font-size: 1.125rem;
letter-spacing: -0.02em;
}
.nav-links {
display: flex;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
border-radius: 8px;
text-decoration: none;
color: var(--text-secondary);
font-weight: 500;
font-size: 0.875rem;
transition: all 0.2s ease;
}
.nav-link:hover {
background: var(--bg-secondary);
color: var(--text-primary);
}
.nav-link.active {
background: var(--bg-secondary);
color: var(--accent);
}
.app-main {
flex: 1;
padding: 2rem;
max-width: 1200px;
width: 100%;
margin: 0 auto;
}
@media (max-width: 600px) {
.app-header {
padding: 0 1rem;
}
.logo-text {
display: none;
}
.app-main {
padding: 1rem;
}
}
`],
})
export class AppComponent implements OnInit {
private connectivity = inject(ConnectivityService);
ngOnInit(): void {
// Iniciar monitoreo de conectividad cada 5 segundos
this.connectivity.startMonitoring();
}
}

View File

@ -0,0 +1,51 @@
import { ApplicationConfig, inject, isDevMode } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { provideApollo } from 'apollo-angular';
import { HttpLink } from 'apollo-angular/http';
import { InMemoryCache } from '@apollo/client/core';
import { routes } from './app.routes';
import { environment } from '@env/environment';
import { errorInterceptor } from '@core/interceptors/error.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withInterceptors([errorInterceptor])),
provideAnimationsAsync(),
provideApollo(() => {
const httpLink = inject(HttpLink);
return {
link: httpLink.create({ uri: environment.graphqlUrl }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
students: { merge: (_, incoming) => incoming },
subjects: { merge: (_, incoming) => incoming },
availableSubjects: { merge: (_, incoming) => incoming },
classmates: { merge: (_, incoming) => incoming },
},
},
Student: {
keyFields: ['id'],
fields: {
enrollments: { merge: (_, incoming) => incoming },
},
},
Subject: { keyFields: ['id'] },
Professor: { keyFields: ['id'] },
Enrollment: { keyFields: ['id'] },
},
}),
defaultOptions: {
watchQuery: { fetchPolicy: 'cache-and-network', errorPolicy: 'all' },
mutate: { errorPolicy: 'all' },
query: { fetchPolicy: 'cache-first', errorPolicy: 'all' },
},
connectToDevTools: isDevMode(),
};
}),
],
};

View File

@ -0,0 +1,36 @@
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: 'students', pathMatch: 'full' },
{
path: 'students',
loadComponent: () =>
import('@features/students/pages/student-list/student-list.component')
.then(m => m.StudentListComponent),
},
{
path: 'students/new',
loadComponent: () =>
import('@features/students/pages/student-form/student-form.component')
.then(m => m.StudentFormComponent),
},
{
path: 'students/:id/edit',
loadComponent: () =>
import('@features/students/pages/student-form/student-form.component')
.then(m => m.StudentFormComponent),
},
{
path: 'enrollment/:studentId',
loadComponent: () =>
import('@features/enrollment/pages/enrollment-page/enrollment-page.component')
.then(m => m.EnrollmentPageComponent),
},
{
path: 'classmates/:studentId',
loadComponent: () =>
import('@features/classmates/pages/classmates-page/classmates-page.component')
.then(m => m.ClassmatesPageComponent),
},
{ path: '**', redirectTo: 'students' },
];

View File

@ -0,0 +1 @@
export * from './types';

View File

@ -0,0 +1,216 @@
// Auto-generated types - DO NOT EDIT manually
// Run `npm run codegen` to regenerate from GraphQL schema
export type Maybe<T> = T | null;
// ============== Scalars ==============
export type Scalars = {
ID: string;
String: string;
Boolean: boolean;
Int: number;
Float: number;
DateTime: string;
};
// ============== Enums ==============
export type EnrollmentStatus = 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
// ============== Types ==============
export type Student = {
__typename?: 'Student';
id: Scalars['Int'];
name: Scalars['String'];
email: Scalars['String'];
totalCredits: Scalars['Int'];
enrollments: Array<Enrollment>;
createdAt: Scalars['DateTime'];
updatedAt: Maybe<Scalars['DateTime']>;
};
export type Subject = {
__typename?: 'Subject';
id: Scalars['Int'];
name: Scalars['String'];
credits: Scalars['Int'];
professor: Professor;
professorId: Scalars['Int'];
};
export type Professor = {
__typename?: 'Professor';
id: Scalars['Int'];
name: Scalars['String'];
subjects: Array<Subject>;
};
export type Enrollment = {
__typename?: 'Enrollment';
id: Scalars['Int'];
studentId: Scalars['Int'];
student: Student;
subjectId: Scalars['Int'];
subject: Subject;
enrolledAt: Scalars['DateTime'];
status: EnrollmentStatus;
};
export type AvailableSubject = {
__typename?: 'AvailableSubject';
id: Scalars['Int'];
name: Scalars['String'];
credits: Scalars['Int'];
professor: Professor;
isAvailable: Scalars['Boolean'];
unavailableReason: Maybe<Scalars['String']>;
};
export type ClassmateGroup = {
__typename?: 'ClassmateGroup';
subjectId: Scalars['Int'];
subjectName: Scalars['String'];
students: Array<ClassmateInfo>;
};
export type ClassmateInfo = {
__typename?: 'ClassmateInfo';
id: Scalars['Int'];
name: Scalars['String'];
};
// ============== Input Types ==============
export type CreateStudentInput = {
name: Scalars['String'];
email: Scalars['String'];
};
export type UpdateStudentInput = {
name?: Maybe<Scalars['String']>;
email?: Maybe<Scalars['String']>;
};
export type EnrollStudentInput = {
studentId: Scalars['Int'];
subjectId: Scalars['Int'];
};
// ============== Mutation Payloads ==============
export type CreateStudentPayload = {
__typename?: 'CreateStudentPayload';
student: Maybe<Student>;
errors: Maybe<Array<Scalars['String']>>;
};
export type UpdateStudentPayload = {
__typename?: 'UpdateStudentPayload';
student: Maybe<Student>;
errors: Maybe<Array<Scalars['String']>>;
};
export type DeleteStudentPayload = {
__typename?: 'DeleteStudentPayload';
success: Scalars['Boolean'];
errors: Maybe<Array<Scalars['String']>>;
};
export type EnrollStudentPayload = {
__typename?: 'EnrollStudentPayload';
enrollment: Maybe<Enrollment>;
errors: Maybe<Array<Scalars['String']>>;
};
export type UnenrollStudentPayload = {
__typename?: 'UnenrollStudentPayload';
success: Scalars['Boolean'];
errors: Maybe<Array<Scalars['String']>>;
};
// ============== Query Types ==============
export type GetStudentsQuery = {
__typename?: 'Query';
students: Array<Student>;
};
export type GetStudentQuery = {
__typename?: 'Query';
student: Maybe<Student>;
};
export type GetStudentQueryVariables = {
id: Scalars['Int'];
};
export type GetSubjectsQuery = {
__typename?: 'Query';
subjects: Array<Subject>;
};
export type GetAvailableSubjectsQuery = {
__typename?: 'Query';
availableSubjects: Array<AvailableSubject>;
};
export type GetAvailableSubjectsQueryVariables = {
studentId: Scalars['Int'];
};
export type GetClassmatesQuery = {
__typename?: 'Query';
classmates: Array<ClassmateGroup>;
};
export type GetClassmatesQueryVariables = {
studentId: Scalars['Int'];
};
export type GetProfessorsQuery = {
__typename?: 'Query';
professors: Array<Professor>;
};
// ============== Mutation Types ==============
export type CreateStudentMutation = {
__typename?: 'Mutation';
createStudent: CreateStudentPayload;
};
export type CreateStudentMutationVariables = {
input: CreateStudentInput;
};
export type UpdateStudentMutation = {
__typename?: 'Mutation';
updateStudent: UpdateStudentPayload;
};
export type UpdateStudentMutationVariables = {
id: Scalars['Int'];
input: UpdateStudentInput;
};
export type DeleteStudentMutation = {
__typename?: 'Mutation';
deleteStudent: DeleteStudentPayload;
};
export type DeleteStudentMutationVariables = {
id: Scalars['Int'];
};
export type EnrollStudentMutation = {
__typename?: 'Mutation';
enrollStudent: EnrollStudentPayload;
};
export type EnrollStudentMutationVariables = {
input: EnrollStudentInput;
};
export type UnenrollStudentMutation = {
__typename?: 'Mutation';
unenrollStudent: UnenrollStudentPayload;
};
export type UnenrollStudentMutationVariables = {
enrollmentId: Scalars['Int'];
};

View File

@ -0,0 +1,74 @@
import { gql } from 'apollo-angular';
export const CREATE_STUDENT = gql`
mutation CreateStudent($input: CreateStudentInput!) {
createStudent(input: $input) {
student {
id
name
email
totalCredits
enrollments {
id
subjectId
subjectName
credits
professorName
enrolledAt
}
}
}
}
`;
export const UPDATE_STUDENT = gql`
mutation UpdateStudent($id: Int!, $input: UpdateStudentInput!) {
updateStudent(id: $id, input: $input) {
student {
id
name
email
totalCredits
enrollments {
id
subjectId
subjectName
credits
professorName
enrolledAt
}
}
}
}
`;
export const DELETE_STUDENT = gql`
mutation DeleteStudent($id: Int!) {
deleteStudent(id: $id) {
success
}
}
`;
export const ENROLL_STUDENT = gql`
mutation EnrollStudent($input: EnrollStudentInput!) {
enrollStudent(input: $input) {
enrollment {
id
subjectId
subjectName
credits
professorName
enrolledAt
}
}
}
`;
export const UNENROLL_STUDENT = gql`
mutation UnenrollStudent($enrollmentId: Int!) {
unenrollStudent(enrollmentId: $enrollmentId) {
success
}
}
`;

View File

@ -0,0 +1,77 @@
import { gql } from 'apollo-angular';
export const GET_STUDENTS = gql`
query GetStudents {
students {
id
name
email
totalCredits
enrollments {
id
subjectId
subjectName
credits
professorName
enrolledAt
}
}
}
`;
export const GET_STUDENT = gql`
query GetStudent($id: Int!) {
student(id: $id) {
id
name
email
totalCredits
enrollments {
id
subjectId
subjectName
credits
professorName
enrolledAt
}
}
}
`;
export const GET_AVAILABLE_SUBJECTS = gql`
query GetAvailableSubjects($studentId: Int!) {
availableSubjects(studentId: $studentId) {
id
name
credits
professorId
professorName
isAvailable
unavailableReason
}
}
`;
export const GET_CLASSMATES = gql`
query GetClassmates($studentId: Int!) {
classmates(studentId: $studentId) {
subjectId
subjectName
classmates {
studentId
name
}
}
}
`;
export const GET_SUBJECTS = gql`
query GetSubjects {
subjects {
id
name
credits
professorId
}
}
`;

View File

@ -0,0 +1,27 @@
import { HttpInterceptorFn, HttpErrorResponse } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotificationService } from '@core/services/notification.service';
export const errorInterceptor: HttpInterceptorFn = (req, next) => {
const notification = inject(NotificationService);
return next(req).pipe(
catchError((error: HttpErrorResponse) => {
let message = 'Ha ocurrido un error inesperado';
if (error.status === 0) {
message = 'No se puede conectar con el servidor';
} else if (error.status === 400) {
message = 'Datos inválidos';
} else if (error.status === 404) {
message = 'Recurso no encontrado';
} else if (error.status === 500) {
message = 'Error interno del servidor';
}
notification.error(message);
return throwError(() => error);
})
);
};

View File

@ -0,0 +1 @@
export * from './student.model';

View File

@ -0,0 +1,74 @@
export interface Student {
id: number;
name: string;
email: string;
totalCredits: number;
enrollments: Enrollment[];
}
export interface Enrollment {
id: number;
subjectId: number;
subjectName: string;
credits: number;
professorName: string;
enrolledAt: string;
}
export interface Subject {
id: number;
name: string;
credits: number;
professorId: number;
}
export interface AvailableSubject {
id: number;
name: string;
credits: number;
professorId: number;
professorName: string;
isAvailable: boolean;
unavailableReason?: string;
}
export interface ClassmateStudent {
studentId: number;
name: string;
}
export interface Classmate {
subjectId: number;
subjectName: string;
classmates: ClassmateStudent[];
}
export interface CreateStudentInput {
name: string;
email: string;
}
export interface UpdateStudentInput {
name?: string;
email?: string;
}
export interface EnrollInput {
studentId: number;
subjectId: number;
}
export interface StudentPayload {
student?: Student;
errors?: string[];
}
export interface EnrollmentPayload {
enrollment?: Enrollment;
errors?: string[];
}
export interface DeletePayload {
success: boolean;
errors?: string[];
}

View File

@ -0,0 +1,121 @@
import { Injectable, inject, signal, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@env/environment';
import { interval, Subscription, catchError, of, tap, timeout } from 'rxjs';
export interface HealthStatus {
status: 'Healthy' | 'Unhealthy' | 'Degraded';
timestamp: string;
checks: Array<{
name: string;
status: string;
description?: string;
duration: number;
}>;
}
export interface ConnectivityState {
isConnected: boolean;
lastCheck: Date | null;
error: string | null;
checking: boolean;
}
@Injectable({ providedIn: 'root' })
export class ConnectivityService implements OnDestroy {
private http = inject(HttpClient);
private subscription: Subscription | null = null;
private consecutiveFailures = 0;
private readonly MAX_FAILURES_BEFORE_DISCONNECT = 2;
/** Estado de conectividad reactivo */
readonly state = signal<ConnectivityState>({
isConnected: true, // Asumimos conectado al inicio
lastCheck: null,
error: null,
checking: false,
});
/** Indica si hay conexión con el servidor */
readonly isConnected = () => this.state().isConnected;
/** Inicia el monitoreo de conectividad */
startMonitoring(): void {
if (this.subscription) return; // Ya está monitoreando
// Check inicial inmediato
this.checkHealth();
// Polling cada N segundos
this.subscription = interval(environment.healthCheckInterval)
.pipe(tap(() => this.checkHealth()))
.subscribe();
}
/** Detiene el monitoreo */
stopMonitoring(): void {
this.subscription?.unsubscribe();
this.subscription = null;
}
/** Verifica la salud del servidor manualmente */
checkHealth(): void {
this.state.update(s => ({ ...s, checking: true }));
this.http.get<HealthStatus>(environment.healthCheckUrl)
.pipe(
timeout(4000), // Timeout de 4 segundos
catchError(error => {
this.handleError(error);
return of(null);
})
)
.subscribe(response => {
if (response) {
this.handleSuccess(response);
}
});
}
private handleSuccess(response: HealthStatus): void {
this.consecutiveFailures = 0;
const dbCheck = response.checks.find(c => c.name === 'database');
const isDbHealthy = dbCheck?.status === 'Healthy';
this.state.set({
isConnected: response.status === 'Healthy' && isDbHealthy,
lastCheck: new Date(),
error: isDbHealthy ? null : 'La base de datos no está disponible',
checking: false,
});
}
private handleError(error: unknown): void {
this.consecutiveFailures++;
// Solo marcar como desconectado después de varios fallos consecutivos
// para evitar falsos positivos por latencia de red
const shouldDisconnect = this.consecutiveFailures >= this.MAX_FAILURES_BEFORE_DISCONNECT;
let errorMessage = 'No se pudo conectar con el servidor';
if (error instanceof Error) {
if (error.name === 'TimeoutError') {
errorMessage = 'El servidor no responde (timeout)';
} else if (error.message.includes('0 Unknown Error')) {
errorMessage = 'El servidor no está disponible. Verifica que esté en ejecución.';
}
}
this.state.set({
isConnected: !shouldDisconnect,
lastCheck: new Date(),
error: shouldDisconnect ? errorMessage : null,
checking: false,
});
}
ngOnDestroy(): void {
this.stopMonitoring();
}
}

View File

@ -0,0 +1,104 @@
import { Injectable, inject } from '@angular/core';
import { Apollo } from 'apollo-angular';
import { map, Observable } from 'rxjs';
import {
AvailableSubject, Classmate, EnrollmentPayload, DeletePayload, EnrollInput, Student
} from '@core/models';
import { GET_AVAILABLE_SUBJECTS, GET_CLASSMATES, GET_STUDENT } from '@core/graphql/queries/students.queries';
import { ENROLL_STUDENT, UNENROLL_STUDENT } from '@core/graphql/mutations/students.mutations';
interface AvailableSubjectsResult {
availableSubjects: AvailableSubject[];
}
interface ClassmatesResult {
classmates: Classmate[];
}
interface StudentResult {
student: Student | null;
}
interface EnrollResult {
enrollStudent: EnrollmentPayload;
}
interface UnenrollResult {
unenrollStudent: DeletePayload;
}
@Injectable({ providedIn: 'root' })
export class EnrollmentService {
private apollo = inject(Apollo);
getStudentWithEnrollments(studentId: number): Observable<{ data: Student | null; loading: boolean }> {
return this.apollo
.watchQuery<StudentResult>({
query: GET_STUDENT,
variables: { id: studentId },
fetchPolicy: 'cache-and-network',
})
.valueChanges.pipe(
map(result => ({
data: (result.data?.student ?? null) as Student | null,
loading: result.loading,
}))
);
}
getAvailableSubjects(studentId: number): Observable<{ data: AvailableSubject[]; loading: boolean }> {
return this.apollo
.watchQuery<AvailableSubjectsResult>({
query: GET_AVAILABLE_SUBJECTS,
variables: { studentId },
fetchPolicy: 'network-only',
})
.valueChanges.pipe(
map(result => ({
data: (result.data?.availableSubjects ?? []) as AvailableSubject[],
loading: result.loading,
}))
);
}
getClassmates(studentId: number): Observable<{ data: Classmate[]; loading: boolean }> {
return this.apollo
.watchQuery<ClassmatesResult>({
query: GET_CLASSMATES,
variables: { studentId },
fetchPolicy: 'cache-and-network',
})
.valueChanges.pipe(
map(result => ({
data: (result.data?.classmates ?? []) as Classmate[],
loading: result.loading,
}))
);
}
enrollStudent(input: EnrollInput): Observable<EnrollmentPayload> {
return this.apollo
.mutate<EnrollResult>({
mutation: ENROLL_STUDENT,
variables: { input },
refetchQueries: [
{ query: GET_STUDENT, variables: { id: input.studentId } },
{ query: GET_AVAILABLE_SUBJECTS, variables: { studentId: input.studentId } },
],
})
.pipe(map(result => result.data?.enrollStudent ?? {}));
}
unenrollStudent(enrollmentId: number, studentId: number): Observable<DeletePayload> {
return this.apollo
.mutate<UnenrollResult>({
mutation: UNENROLL_STUDENT,
variables: { enrollmentId },
refetchQueries: [
{ query: GET_STUDENT, variables: { id: studentId } },
{ query: GET_AVAILABLE_SUBJECTS, variables: { studentId } },
],
})
.pipe(map(result => result.data?.unenrollStudent ?? { success: false }));
}
}

View File

@ -0,0 +1,226 @@
import { Injectable, inject, isDevMode } from '@angular/core';
import { NotificationService } from './notification.service';
/** Estructura de error de Apollo (sin importar ApolloError deprecado) */
interface GraphQLErrorLike {
message: string;
extensions?: Record<string, unknown>;
path?: readonly (string | number)[];
}
interface ApolloErrorLike {
graphQLErrors?: readonly GraphQLErrorLike[];
networkError?: { status?: number; message?: string } | null;
message: string;
}
/** Códigos de error del backend GraphQL */
type ErrorCode =
| 'MAX_ENROLLMENTS'
| 'SAME_PROFESSOR'
| 'STUDENT_NOT_FOUND'
| 'SUBJECT_NOT_FOUND'
| 'ENROLLMENT_NOT_FOUND'
| 'DUPLICATE_ENROLLMENT'
| 'VALIDATION_ERROR'
| 'INVALID_ARGUMENT'
| 'NETWORK_ERROR'
| 'SERVER_ERROR'
| 'UNKNOWN';
interface ErrorInfo {
/** Mensaje amigable para el usuario */
userMessage: string;
/** Mensaje técnico para desarrolladores */
devMessage: string;
/** Sugerencia de acción para el usuario */
suggestion?: string;
/** Código de error */
code: ErrorCode;
}
/** Mapeo de códigos de error a mensajes amigables */
const ERROR_MESSAGES: Record<ErrorCode, { user: string; suggestion?: string }> = {
MAX_ENROLLMENTS: {
user: 'Has alcanzado el límite máximo de 3 materias inscritas',
suggestion: 'Debes cancelar una inscripción antes de agregar otra materia',
},
SAME_PROFESSOR: {
user: 'Ya tienes una materia con este profesor',
suggestion: 'No puedes inscribir dos materias del mismo profesor',
},
STUDENT_NOT_FOUND: {
user: 'El estudiante no existe en el sistema',
suggestion: 'Verifica que el estudiante esté registrado',
},
SUBJECT_NOT_FOUND: {
user: 'La materia seleccionada no existe',
suggestion: 'Recarga la página e intenta nuevamente',
},
ENROLLMENT_NOT_FOUND: {
user: 'La inscripción no fue encontrada',
suggestion: 'Es posible que ya haya sido cancelada',
},
DUPLICATE_ENROLLMENT: {
user: 'Ya estás inscrito en esta materia',
},
VALIDATION_ERROR: {
user: 'Los datos ingresados no son válidos',
suggestion: 'Revisa los campos marcados en rojo',
},
INVALID_ARGUMENT: {
user: 'Datos inválidos enviados al servidor',
},
NETWORK_ERROR: {
user: 'No se pudo conectar con el servidor',
suggestion: 'Verifica tu conexión a internet y que el servidor esté activo',
},
SERVER_ERROR: {
user: 'Error interno del servidor',
suggestion: 'Intenta nuevamente en unos momentos',
},
UNKNOWN: {
user: 'Ocurrió un error inesperado',
suggestion: 'Si el problema persiste, contacta al administrador',
},
};
@Injectable({ providedIn: 'root' })
export class ErrorHandlerService {
private notification = inject(NotificationService);
/**
* Procesa un error y retorna información estructurada
*/
parseError(error: unknown): ErrorInfo {
// Error de Apollo/GraphQL
if (this.isApolloError(error)) {
return this.parseApolloError(error);
}
// Error HTTP genérico
if (this.isHttpError(error)) {
return this.parseHttpError(error);
}
// Error desconocido
return this.createErrorInfo('UNKNOWN', String(error));
}
/**
* Procesa el error, muestra notificación y loguea para desarrolladores
*/
handle(error: unknown, context?: string): ErrorInfo {
const errorInfo = this.parseError(error);
// Notificación al usuario
const message = errorInfo.suggestion
? `${errorInfo.userMessage}. ${errorInfo.suggestion}`
: errorInfo.userMessage;
this.notification.error(message);
// Logging detallado para desarrolladores
this.logForDevelopers(errorInfo, error, context);
return errorInfo;
}
/**
* Solo loguea para desarrolladores sin mostrar notificación
*/
logError(error: unknown, context?: string): void {
const errorInfo = this.parseError(error);
this.logForDevelopers(errorInfo, error, context);
}
private isApolloError(error: unknown): error is ApolloErrorLike {
return typeof error === 'object' && error !== null &&
('graphQLErrors' in error || 'networkError' in error);
}
private isHttpError(error: unknown): error is { status: number; message: string } {
return typeof error === 'object' && error !== null && 'status' in error;
}
private parseApolloError(error: ApolloErrorLike): ErrorInfo {
// Error de red (servidor no disponible)
if (error.networkError) {
const netError = error.networkError as { status?: number; message?: string };
const devMessage = this.formatNetworkError(netError);
return this.createErrorInfo('NETWORK_ERROR', devMessage);
}
// Errores de GraphQL
if (error.graphQLErrors?.length) {
const gqlError = error.graphQLErrors[0];
const code = (gqlError.extensions?.['code'] as ErrorCode) || 'UNKNOWN';
const devMessage = this.formatGraphQLError(gqlError);
// Errores de validación pueden tener múltiples campos
if (code === 'VALIDATION_ERROR' && gqlError.extensions?.['errors']) {
const validationErrors = gqlError.extensions['errors'] as Array<{
PropertyName: string;
ErrorMessage: string;
}>;
const details = validationErrors
.map(e => `${e.PropertyName}: ${e.ErrorMessage}`)
.join(', ');
return this.createErrorInfo(code, details);
}
return this.createErrorInfo(code, devMessage);
}
return this.createErrorInfo('UNKNOWN', error.message);
}
private parseHttpError(error: { status: number; message: string }): ErrorInfo {
if (error.status === 0) {
return this.createErrorInfo('NETWORK_ERROR', 'Sin respuesta del servidor (status 0)');
}
if (error.status >= 500) {
return this.createErrorInfo('SERVER_ERROR', `HTTP ${error.status}: ${error.message}`);
}
return this.createErrorInfo('UNKNOWN', `HTTP ${error.status}: ${error.message}`);
}
private createErrorInfo(code: ErrorCode, devMessage: string): ErrorInfo {
const config = ERROR_MESSAGES[code] || ERROR_MESSAGES.UNKNOWN;
return {
code,
userMessage: config.user,
suggestion: config.suggestion,
devMessage,
};
}
private formatNetworkError(error: { status?: number; message?: string }): string {
if (error.status === 0 || !error.status) {
return 'NETWORK_ERROR: No se pudo establecer conexión con el servidor. ' +
'Posibles causas: servidor apagado, URL incorrecta, CORS bloqueado, o sin internet.';
}
return `NETWORK_ERROR: HTTP ${error.status} - ${error.message || 'Sin mensaje'}`;
}
private formatGraphQLError(error: { message: string; path?: readonly (string | number)[] }): string {
const path = error.path?.join('.') || 'root';
return `GraphQL Error en "${path}": ${error.message}`;
}
private logForDevelopers(info: ErrorInfo, originalError: unknown, context?: string): void {
if (!isDevMode()) return;
const timestamp = new Date().toISOString();
const contextStr = context ? ` [${context}]` : '';
console.group(`🚨 Error${contextStr} - ${timestamp}`);
console.log('%c Código:', 'font-weight: bold', info.code);
console.log('%c Usuario verá:', 'font-weight: bold; color: #e74c3c', info.userMessage);
if (info.suggestion) {
console.log('%c Sugerencia:', 'font-weight: bold; color: #3498db', info.suggestion);
}
console.log('%c Detalle técnico:', 'font-weight: bold; color: #95a5a6', info.devMessage);
console.log('%c Error original:', 'font-weight: bold', originalError);
console.groupEnd();
}
}

View File

@ -0,0 +1,5 @@
export * from './student.service';
export * from './enrollment.service';
export * from './notification.service';
export * from './error-handler.service';
export * from './connectivity.service';

View File

@ -0,0 +1,66 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { NotificationService } from './notification.service';
describe('NotificationService', () => {
let service: NotificationService;
let snackBarSpy: jasmine.SpyObj<MatSnackBar>;
beforeEach(() => {
snackBarSpy = jasmine.createSpyObj('MatSnackBar', ['open']);
TestBed.configureTestingModule({
imports: [MatSnackBarModule],
providers: [
NotificationService,
{ provide: MatSnackBar, useValue: snackBarSpy },
],
});
service = TestBed.inject(NotificationService);
});
describe('success', () => {
it('should show success snackbar with correct config', () => {
service.success('Operación exitosa');
expect(snackBarSpy.open).toHaveBeenCalledWith(
'Operación exitosa',
'Cerrar',
jasmine.objectContaining({
duration: 4000,
panelClass: ['success-snackbar'],
})
);
});
});
describe('error', () => {
it('should show error snackbar with correct config', () => {
service.error('Error en la operación');
expect(snackBarSpy.open).toHaveBeenCalledWith(
'Error en la operación',
'Cerrar',
jasmine.objectContaining({
duration: 6000,
panelClass: ['error-snackbar'],
})
);
});
});
describe('info', () => {
it('should show info snackbar with correct config', () => {
service.info('Información importante');
expect(snackBarSpy.open).toHaveBeenCalledWith(
'Información importante',
'Cerrar',
jasmine.objectContaining({
duration: 4000,
})
);
});
});
});

View File

@ -0,0 +1,33 @@
import { Injectable, inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
@Injectable({ providedIn: 'root' })
export class NotificationService {
private snackBar = inject(MatSnackBar);
success(message: string): void {
this.snackBar.open(message, 'Cerrar', {
duration: 4000,
panelClass: ['success-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom',
});
}
error(message: string): void {
this.snackBar.open(message, 'Cerrar', {
duration: 6000,
panelClass: ['error-snackbar'],
horizontalPosition: 'center',
verticalPosition: 'bottom',
});
}
info(message: string): void {
this.snackBar.open(message, 'Cerrar', {
duration: 4000,
horizontalPosition: 'center',
verticalPosition: 'bottom',
});
}
}

View File

@ -0,0 +1,144 @@
import { TestBed } from '@angular/core/testing';
import { ApolloTestingController, ApolloTestingModule } from 'apollo-angular/testing';
import { StudentService } from './student.service';
import { GET_STUDENTS, GET_STUDENT } from '@core/graphql/queries/students.queries';
import { CREATE_STUDENT, DELETE_STUDENT } from '@core/graphql/mutations/students.mutations';
describe('StudentService', () => {
let service: StudentService;
let controller: ApolloTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ApolloTestingModule],
providers: [StudentService],
});
service = TestBed.inject(StudentService);
controller = TestBed.inject(ApolloTestingController);
});
afterEach(() => {
controller.verify();
});
describe('getStudents', () => {
it('should return students list', (done) => {
const mockStudents = [
{ id: 1, name: 'Juan Pérez', email: 'juan@test.com', totalCredits: 6, enrollments: [] },
{ id: 2, name: 'María García', email: 'maria@test.com', totalCredits: 3, enrollments: [] },
];
service.getStudents().subscribe((result) => {
expect(result.data).toEqual(mockStudents);
done();
});
const op = controller.expectOne(GET_STUDENTS);
expect(op.operation.operationName).toBe('GetStudents');
op.flush({ data: { students: mockStudents } });
});
it('should return empty array when no students', (done) => {
service.getStudents().subscribe((result) => {
expect(result.data).toEqual([]);
done();
});
const op = controller.expectOne(GET_STUDENTS);
op.flush({ data: { students: [] } });
});
});
describe('getStudent', () => {
it('should return student by id', (done) => {
const mockStudent = {
id: 1, name: 'Juan Pérez', email: 'juan@test.com',
totalCredits: 6, enrollments: []
};
service.getStudent(1).subscribe((result) => {
expect(result.data).toEqual(mockStudent);
done();
});
const op = controller.expectOne(GET_STUDENT);
expect(op.operation.variables['id']).toBe(1);
op.flush({ data: { student: mockStudent } });
});
it('should return null when student not found', (done) => {
service.getStudent(999).subscribe((result) => {
expect(result.data).toBeNull();
done();
});
const op = controller.expectOne(GET_STUDENT);
op.flush({ data: { student: null } });
});
});
describe('createStudent', () => {
it('should create student and return payload', (done) => {
const input = { name: 'Nuevo Estudiante', email: 'nuevo@test.com' };
const mockResponse = {
student: { id: 3, name: 'Nuevo Estudiante', email: 'nuevo@test.com', totalCredits: 0, enrollments: [] },
errors: null,
};
service.createStudent(input).subscribe((result) => {
expect(result.student).toBeDefined();
expect(result.student?.name).toBe('Nuevo Estudiante');
done();
});
const op = controller.expectOne(CREATE_STUDENT);
expect(op.operation.variables['input']).toEqual(input);
op.flush({ data: { createStudent: mockResponse } });
// Flush refetch query
const refetch = controller.expectOne(GET_STUDENTS);
refetch.flush({ data: { students: [] } });
});
it('should return errors when validation fails', (done) => {
const input = { name: '', email: 'invalid' };
const mockResponse = {
student: null,
errors: ['Name is required', 'Invalid email format'],
};
service.createStudent(input).subscribe((result) => {
expect(result.student).toBeNull();
expect(result.errors).toContain('Name is required');
done();
});
const op = controller.expectOne(CREATE_STUDENT);
op.flush({ data: { createStudent: mockResponse } });
// Flush refetch query
const refetch = controller.expectOne(GET_STUDENTS);
refetch.flush({ data: { students: [] } });
});
});
describe('deleteStudent', () => {
it('should delete student and return success', (done) => {
const mockResponse = { success: true, errors: null };
service.deleteStudent(1).subscribe((result) => {
expect(result.success).toBeTrue();
done();
});
const op = controller.expectOne(DELETE_STUDENT);
expect(op.operation.variables['id']).toBe(1);
op.flush({ data: { deleteStudent: mockResponse } });
// Flush refetch query
const refetch = controller.expectOne(GET_STUDENTS);
refetch.flush({ data: { students: [] } });
});
});
});

View File

@ -0,0 +1,93 @@
import { Injectable, inject } from '@angular/core';
import { Apollo } from 'apollo-angular';
import { map, Observable } from 'rxjs';
import {
Student, StudentPayload, DeletePayload,
CreateStudentInput, UpdateStudentInput
} from '@core/models';
import { GET_STUDENTS, GET_STUDENT } from '@core/graphql/queries/students.queries';
import { CREATE_STUDENT, UPDATE_STUDENT, DELETE_STUDENT } from '@core/graphql/mutations/students.mutations';
interface StudentsQueryResult {
students: Student[];
}
interface StudentQueryResult {
student: Student | null;
}
interface CreateStudentResult {
createStudent: StudentPayload;
}
interface UpdateStudentResult {
updateStudent: StudentPayload;
}
interface DeleteStudentResult {
deleteStudent: DeletePayload;
}
@Injectable({ providedIn: 'root' })
export class StudentService {
private apollo = inject(Apollo);
getStudents(): Observable<{ data: Student[]; loading: boolean }> {
return this.apollo
.watchQuery<StudentsQueryResult>({
query: GET_STUDENTS,
fetchPolicy: 'cache-and-network',
})
.valueChanges.pipe(
map(result => ({
data: (result.data?.students ?? []) as Student[],
loading: result.loading,
}))
);
}
getStudent(id: number): Observable<{ data: Student | null; loading: boolean }> {
return this.apollo
.watchQuery<StudentQueryResult>({
query: GET_STUDENT,
variables: { id },
fetchPolicy: 'cache-and-network',
})
.valueChanges.pipe(
map(result => ({
data: (result.data?.student ?? null) as Student | null,
loading: result.loading,
}))
);
}
createStudent(input: CreateStudentInput): Observable<StudentPayload> {
return this.apollo
.mutate<CreateStudentResult>({
mutation: CREATE_STUDENT,
variables: { input },
refetchQueries: [{ query: GET_STUDENTS }],
})
.pipe(map(result => result.data?.createStudent ?? {}));
}
updateStudent(id: number, input: UpdateStudentInput): Observable<StudentPayload> {
return this.apollo
.mutate<UpdateStudentResult>({
mutation: UPDATE_STUDENT,
variables: { id, input },
refetchQueries: [{ query: GET_STUDENTS }],
})
.pipe(map(result => result.data?.updateStudent ?? {}));
}
deleteStudent(id: number): Observable<DeletePayload> {
return this.apollo
.mutate<DeleteStudentResult>({
mutation: DELETE_STUDENT,
variables: { id },
refetchQueries: [{ query: GET_STUDENTS }],
})
.pipe(map(result => result.data?.deleteStudent ?? { success: false }));
}
}

View File

@ -0,0 +1,230 @@
import { Component, ChangeDetectionStrategy, signal, inject, OnInit, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { EnrollmentService, NotificationService } from '@core/services';
import { Classmate, Student } from '@core/models';
import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
import { InitialsPipe } from '@shared/pipes/initials.pipe';
@Component({
selector: 'app-classmates-page',
standalone: true,
imports: [RouterLink, MatIconModule, LoadingSpinnerComponent, EmptyStateComponent, InitialsPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page">
<a routerLink="/students" class="back-link">
<mat-icon>arrow_back</mat-icon>
Volver a estudiantes
</a>
@if (loading()) {
<app-loading-spinner message="Cargando compañeros..." />
} @else if (!student()) {
<app-empty-state
icon="error"
title="Estudiante no encontrado"
description="El estudiante solicitado no existe"
>
<a routerLink="/students" class="btn btn-primary">Volver</a>
</app-empty-state>
} @else {
<header class="page-header">
<div>
<h1>Compañeros de Clase</h1>
<p class="page-subtitle">{{ student()?.name }}</p>
</div>
<a [routerLink]="['/enrollment', studentId()]" class="btn btn-secondary">
<mat-icon>school</mat-icon>
Ver Inscripción
</a>
</header>
@if (classmates().length === 0) {
<div class="card">
<app-empty-state
icon="group_off"
title="Sin materias inscritas"
description="Inscríbete en materias para ver a tus compañeros de clase"
>
<a [routerLink]="['/enrollment', studentId()]" class="btn btn-primary">
<mat-icon>add</mat-icon>
Inscribir Materias
</a>
</app-empty-state>
</div>
} @else {
<div class="classmates-list">
@for (item of classmates(); track item.subjectId) {
<section class="classmates-section card">
<h2 class="classmates-section-title">
<mat-icon>book</mat-icon>
{{ item.subjectName }}
<span class="classmates-count">{{ item.classmates.length }} compañeros</span>
</h2>
@if (item.classmates.length === 0) {
<div class="no-classmates">
<mat-icon>person_off</mat-icon>
<span>Aún no hay otros estudiantes en esta materia</span>
</div>
} @else {
<div class="classmates-grid">
@for (classmate of item.classmates; track classmate.studentId) {
<div class="classmate-item">
<div class="classmate-item-avatar">
{{ classmate.name | initials }}
</div>
<span class="classmate-item-name">{{ classmate.name }}</span>
</div>
}
</div>
}
</section>
}
</div>
}
}
</div>
`,
styles: [`
.page {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.classmates-list {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.classmates-section {
padding: 1.5rem;
}
.classmates-section-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1.25rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-light);
color: var(--text-primary);
mat-icon {
color: var(--accent);
}
}
.classmates-count {
margin-left: auto;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 0.25rem 0.75rem;
border-radius: 100px;
}
.classmates-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
}
.classmate-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
transition: all var(--transition);
&:hover {
background: var(--bg-primary);
}
}
.classmate-item-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #5856D6 0%, #AF52DE 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.8125rem;
font-weight: 600;
flex-shrink: 0;
}
.classmate-item-name {
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-primary);
}
.no-classmates {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 2rem;
color: var(--text-tertiary);
font-size: 0.9375rem;
mat-icon {
font-size: 1.5rem;
width: 1.5rem;
height: 1.5rem;
}
}
`],
})
export class ClassmatesPageComponent implements OnInit {
studentId = input.required<string>();
private enrollmentService = inject(EnrollmentService);
private notification = inject(NotificationService);
student = signal<Student | null>(null);
classmates = signal<Classmate[]>([]);
loading = signal(true);
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
const id = parseInt(this.studentId(), 10);
this.enrollmentService.getStudentWithEnrollments(id).subscribe({
next: ({ data }) => {
this.student.set(data);
},
error: () => {
this.notification.error('Error al cargar el estudiante');
},
});
this.enrollmentService.getClassmates(id).subscribe({
next: ({ data, loading }) => {
this.classmates.set(data);
this.loading.set(loading);
},
error: () => {
this.loading.set(false);
this.notification.error('Error al cargar compañeros');
},
});
}
}

View File

@ -0,0 +1,431 @@
import { Component, ChangeDetectionStrategy, signal, inject, OnInit, input, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatTooltipModule } from '@angular/material/tooltip';
import { EnrollmentService, NotificationService, ErrorHandlerService } from '@core/services';
import { Student, AvailableSubject, Enrollment } from '@core/models';
import { LoadingSpinnerComponent, EmptyStateComponent } from '@shared/index';
@Component({
selector: 'app-enrollment-page',
standalone: true,
imports: [
RouterLink, MatIconModule, MatButtonModule, MatTooltipModule,
LoadingSpinnerComponent, EmptyStateComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page">
<a routerLink="/students" class="back-link">
<mat-icon>arrow_back</mat-icon>
Volver a estudiantes
</a>
@if (loading()) {
<app-loading-spinner message="Cargando inscripciones..." />
} @else if (!student()) {
<app-empty-state
icon="error"
title="Estudiante no encontrado"
description="El estudiante solicitado no existe"
>
<a routerLink="/students" class="btn btn-primary">Volver</a>
</app-empty-state>
} @else {
<header class="page-header">
<div>
<h1>Inscripción: {{ student()?.name }}</h1>
<p class="page-subtitle">{{ student()?.email }}</p>
</div>
<a [routerLink]="['/classmates', studentId()]" class="btn btn-secondary"
[class.disabled]="enrollments().length === 0">
<mat-icon>group</mat-icon>
Ver Compañeros
</a>
</header>
<!-- Progress Section -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-header">
<mat-icon>school</mat-icon>
<span>Créditos</span>
</div>
<div class="stat-value">
<span class="stat-current">{{ student()?.totalCredits || 0 }}</span>
<span class="stat-max">/9</span>
</div>
<div class="progress-bar">
<div class="progress-bar-fill" [style.width.%]="creditsProgress()"></div>
</div>
</div>
<div class="stat-card">
<div class="stat-header">
<mat-icon>book</mat-icon>
<span>Materias</span>
</div>
<div class="stat-value">
<span class="stat-current">{{ enrollments().length }}</span>
<span class="stat-max">/3</span>
</div>
<div class="progress-bar">
<div class="progress-bar-fill" [style.width.%]="subjectsProgress()"></div>
</div>
</div>
</div>
<!-- Enrolled Subjects -->
@if (enrollments().length > 0) {
<section class="section">
<h2 class="section-title">
<mat-icon>check_circle</mat-icon>
Materias Inscritas
</h2>
<div class="subjects-grid">
@for (enrollment of enrollments(); track enrollment.id) {
<div class="subject-card enrolled">
<div class="subject-card-info">
<div class="subject-card-name" data-testid="enrolled-subject-name">{{ enrollment.subjectName }}</div>
<div class="subject-card-professor">
<mat-icon>person</mat-icon>
{{ enrollment.professorName }}
</div>
<div class="subject-card-credits">{{ enrollment.credits }} créditos</div>
</div>
<button
class="btn btn-sm btn-danger"
(click)="unenroll(enrollment)"
[disabled]="processingId() === enrollment.id"
matTooltip="Cancelar inscripción"
data-testid="btn-unenroll-subject"
>
@if (processingId() === enrollment.id) {
<span class="btn-loading"></span>
} @else {
<mat-icon>close</mat-icon>
Cancelar
}
</button>
</div>
}
</div>
</section>
}
<!-- Available Subjects -->
<section class="section">
<h2 class="section-title">
<mat-icon>add_circle</mat-icon>
Materias Disponibles
</h2>
@if (loadingAvailable()) {
<app-loading-spinner [size]="32" />
} @else if (availableSubjects().length === 0) {
<app-empty-state
icon="check_circle"
title="Inscripción completa"
description="Ya has seleccionado el máximo de materias permitidas"
/>
} @else {
<div class="subjects-grid">
@for (item of availableSubjects(); track item.id) {
<div class="subject-card" [class.unavailable]="!item.isAvailable">
<div class="subject-card-info">
<div class="subject-card-name">{{ item.name }}</div>
<div class="subject-card-professor">
<mat-icon>person</mat-icon>
{{ item.professorName }}
</div>
<div class="subject-card-credits">{{ item.credits }} créditos</div>
@if (!item.isAvailable && item.unavailableReason) {
<div class="subject-card-warning">
<mat-icon>warning</mat-icon>
{{ item.unavailableReason }}
</div>
}
</div>
<button
class="btn btn-sm btn-primary"
(click)="enroll(item)"
[disabled]="!item.isAvailable || processingId() === item.id || enrollments().length >= 3"
[matTooltip]="!item.isAvailable ? item.unavailableReason || '' : 'Inscribir'"
data-testid="btn-enroll-subject"
>
@if (processingId() === item.id) {
<span class="btn-loading"></span>
} @else {
<mat-icon>add</mat-icon>
Inscribir
}
</button>
</div>
}
</div>
}
</section>
}
</div>
`,
styles: [`
.page {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
padding: 1.25rem;
}
.stat-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-size: 0.875rem;
margin-bottom: 0.5rem;
mat-icon {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
}
}
.stat-value {
font-size: 2rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.stat-current {
color: var(--text-primary);
}
.stat-max {
color: var(--text-tertiary);
}
.section {
margin-bottom: 2rem;
}
.section-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: var(--text-primary);
mat-icon {
color: var(--accent);
}
}
.subjects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.subject-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
transition: all var(--transition);
&:hover {
border-color: var(--border-medium);
box-shadow: var(--shadow-sm);
}
&.enrolled {
border-color: var(--success);
background: rgba(52, 199, 89, 0.04);
}
&.unavailable {
opacity: 0.7;
background: var(--bg-tertiary);
}
}
.subject-card-info {
flex: 1;
min-width: 0;
}
.subject-card-name {
font-weight: 600;
margin-bottom: 0.375rem;
color: var(--text-primary);
}
.subject-card-professor {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
.subject-card-credits {
font-size: 0.8125rem;
color: var(--text-tertiary);
}
.subject-card-warning {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
color: var(--warning);
margin-top: 0.5rem;
mat-icon {
font-size: 0.875rem;
width: 0.875rem;
height: 0.875rem;
}
}
.btn-loading {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.disabled {
pointer-events: none;
opacity: 0.5;
}
`],
})
export class EnrollmentPageComponent implements OnInit {
studentId = input.required<string>();
private enrollmentService = inject(EnrollmentService);
private notification = inject(NotificationService);
private errorHandler = inject(ErrorHandlerService);
student = signal<Student | null>(null);
availableSubjects = signal<AvailableSubject[]>([]);
loading = signal(true);
loadingAvailable = signal(true);
processingId = signal<number | null>(null);
enrollments = computed(() => this.student()?.enrollments ?? []);
creditsProgress = computed(() => ((this.student()?.totalCredits ?? 0) / 9) * 100);
subjectsProgress = computed(() => (this.enrollments().length / 3) * 100);
ngOnInit(): void {
this.loadData();
}
private loadData(): void {
const id = parseInt(this.studentId(), 10);
this.enrollmentService.getStudentWithEnrollments(id).subscribe({
next: ({ data, loading }) => {
this.student.set(data);
this.loading.set(loading);
},
error: (error) => {
this.loading.set(false);
this.errorHandler.handle(error, 'Enrollment.cargarEstudiante');
},
});
this.enrollmentService.getAvailableSubjects(id).subscribe({
next: ({ data, loading }) => {
this.availableSubjects.set(data);
this.loadingAvailable.set(loading);
},
error: (error) => {
this.loadingAvailable.set(false);
this.errorHandler.handle(error, 'Enrollment.cargarMaterias');
},
});
}
enroll(item: AvailableSubject): void {
if (!item.isAvailable) return;
const studentId = parseInt(this.studentId(), 10);
this.processingId.set(item.id);
this.enrollmentService.enrollStudent({
studentId,
subjectId: item.id,
}).subscribe({
next: (result) => {
this.processingId.set(null);
if (result.enrollment) {
this.notification.success(`Inscrito en ${item.name}`);
} else if (result.errors?.length) {
this.notification.error(result.errors.join('. '));
}
},
error: (error) => {
this.processingId.set(null);
this.errorHandler.handle(error, `Enrollment.inscribir(${item.name})`);
},
});
}
unenroll(enrollment: Enrollment): void {
const studentId = parseInt(this.studentId(), 10);
this.processingId.set(enrollment.id);
this.enrollmentService.unenrollStudent(enrollment.id, studentId).subscribe({
next: (result) => {
this.processingId.set(null);
if (result.success) {
this.notification.success(`Inscripción cancelada: ${enrollment.subjectName}`);
} else if (result.errors?.length) {
this.notification.error(result.errors.join('. '));
}
},
error: (error) => {
this.processingId.set(null);
this.errorHandler.handle(error, `Enrollment.cancelar(${enrollment.subjectName})`);
},
});
}
}

View File

@ -0,0 +1,260 @@
import { Component, ChangeDetectionStrategy, signal, inject, OnInit, input } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { StudentService, NotificationService, ErrorHandlerService } from '@core/services';
import { LoadingSpinnerComponent } from '@shared/index';
@Component({
selector: 'app-student-form',
standalone: true,
imports: [
RouterLink, ReactiveFormsModule, MatIconModule, MatButtonModule,
MatFormFieldModule, MatInputModule, LoadingSpinnerComponent
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page">
<a routerLink="/students" class="back-link">
<mat-icon>arrow_back</mat-icon>
Volver a estudiantes
</a>
<header class="page-header">
<div>
<h1>{{ isEditing() ? 'Editar Estudiante' : 'Nuevo Estudiante' }}</h1>
<p class="page-subtitle">
{{ isEditing() ? 'Modifica los datos del estudiante' : 'Registra un nuevo estudiante en el sistema' }}
</p>
</div>
</header>
@if (loadingStudent()) {
<app-loading-spinner message="Cargando datos..." />
} @else {
<div class="card form-card">
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="name">Nombre completo</label>
<input
id="name"
type="text"
class="input"
formControlName="name"
placeholder="Ej: Juan Pérez García"
[class.error]="showError('name')"
data-testid="input-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']) {
El nombre debe tener al menos 3 caracteres
}
</span>
}
</div>
<div class="form-group">
<label for="email">Correo electrónico</label>
<input
id="email"
type="email"
class="input"
formControlName="email"
placeholder="Ej: juan.perez&#64;ejemplo.com"
[class.error]="showError('email')"
data-testid="input-email"
>
@if (showError('email')) {
<span class="error-message">
@if (form.get('email')?.errors?.['required']) {
El email es requerido
} @else if (form.get('email')?.errors?.['email']) {
Ingresa un email válido
}
</span>
}
</div>
@if (serverError()) {
<div class="server-error">
<mat-icon>error</mat-icon>
{{ serverError() }}
</div>
}
<div class="form-actions">
<a routerLink="/students" class="btn btn-secondary">Cancelar</a>
<button
type="submit"
class="btn btn-primary"
[disabled]="form.invalid || saving()"
data-testid="btn-submit"
>
@if (saving()) {
<span class="btn-loading"></span>
Guardando...
} @else {
<mat-icon>save</mat-icon>
{{ isEditing() ? 'Guardar Cambios' : 'Crear Estudiante' }}
}
</button>
</div>
</form>
</div>
}
</div>
`,
styles: [`
.page {
animation: fadeIn 0.3s ease;
max-width: 560px;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.form-card {
padding: 2rem;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-light);
}
.server-error {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1rem;
background: rgba(255, 59, 48, 0.08);
border: 1px solid rgba(255, 59, 48, 0.2);
border-radius: var(--radius-md);
color: var(--error);
font-size: 0.875rem;
margin-bottom: 1rem;
mat-icon {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
}
}
.btn-loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
`],
})
export class StudentFormComponent implements OnInit {
id = input<string>();
private fb = inject(FormBuilder);
private router = inject(Router);
private studentService = inject(StudentService);
private notification = inject(NotificationService);
private errorHandler = inject(ErrorHandlerService);
form: FormGroup = this.fb.group({
name: ['', [Validators.required, Validators.minLength(3)]],
email: ['', [Validators.required, Validators.email]],
});
isEditing = signal(false);
loadingStudent = signal(false);
saving = signal(false);
serverError = signal<string | null>(null);
ngOnInit(): void {
const studentId = this.id();
if (studentId) {
this.isEditing.set(true);
this.loadStudent(parseInt(studentId, 10));
}
}
private loadStudent(id: number): void {
this.loadingStudent.set(true);
this.studentService.getStudent(id).subscribe({
next: ({ data }) => {
this.loadingStudent.set(false);
if (data) {
this.form.patchValue({ name: data.name, email: data.email });
} else {
this.notification.error('Estudiante no encontrado');
this.router.navigate(['/students']);
}
},
error: (error) => {
this.loadingStudent.set(false);
this.errorHandler.handle(error, 'StudentForm.cargar');
this.router.navigate(['/students']);
},
});
}
showError(field: string): boolean {
const control = this.form.get(field);
return !!(control?.invalid && control?.touched);
}
onSubmit(): void {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
this.saving.set(true);
this.serverError.set(null);
const studentId = this.id();
const operation = studentId
? this.studentService.updateStudent(parseInt(studentId, 10), this.form.value)
: this.studentService.createStudent(this.form.value);
const action = studentId ? 'actualizar' : 'crear';
operation.subscribe({
next: (result) => {
this.saving.set(false);
if (result.student) {
this.notification.success(
studentId ? 'Estudiante actualizado correctamente' : 'Estudiante creado correctamente'
);
this.router.navigate(['/students']);
} else if (result.errors?.length) {
// Errores de validación del backend
this.serverError.set(result.errors.join('. '));
} else {
this.serverError.set(`No se pudo ${action} el estudiante. Intenta nuevamente.`);
}
},
error: (error) => {
this.saving.set(false);
const errorInfo = this.errorHandler.handle(error, `StudentForm.${action}`);
this.serverError.set(errorInfo.userMessage);
},
});
}
}

View File

@ -0,0 +1,260 @@
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { Router, RouterLink } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { StudentService, NotificationService, ErrorHandlerService } from '@core/services';
import { Student } from '@core/models';
import { LoadingSpinnerComponent, EmptyStateComponent, ConfirmDialogComponent, ConfirmDialogData } from '@shared/index';
import { CreditsPipe } from '@shared/pipes/credits.pipe';
@Component({
selector: 'app-student-list',
standalone: true,
imports: [
RouterLink, MatIconModule, MatButtonModule, MatTooltipModule,
LoadingSpinnerComponent, EmptyStateComponent, CreditsPipe
],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page">
<header class="page-header">
<div>
<h1>Estudiantes</h1>
<p class="page-subtitle">Gestiona el registro de estudiantes</p>
</div>
<a routerLink="/students/new" class="btn btn-primary" data-testid="btn-new-student">
<mat-icon>add</mat-icon>
Nuevo Estudiante
</a>
</header>
@if (loading()) {
<app-loading-spinner message="Cargando estudiantes..." />
} @else if (students().length === 0) {
<div class="card">
<app-empty-state
icon="school"
title="Sin estudiantes registrados"
description="Comienza agregando tu primer estudiante al sistema"
>
<a routerLink="/students/new" class="btn btn-primary">
<mat-icon>add</mat-icon>
Agregar Estudiante
</a>
</app-empty-state>
</div>
} @else {
<div class="card">
<div class="table-container">
<table class="data-table" data-testid="students-table">
<thead>
<tr>
<th>Estudiante</th>
<th>Email</th>
<th>Créditos</th>
<th>Materias</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
@for (student of students(); track student.id) {
<tr>
<td>
<div class="student-cell">
<div class="avatar">{{ getInitials(student.name) }}</div>
<span class="student-name">{{ student.name }}</span>
</div>
</td>
<td class="email-cell">{{ student.email }}</td>
<td>
<div class="credits-cell">
<div class="progress-bar">
<div
class="progress-bar-fill"
[style.width.%]="(student.totalCredits / 9) * 100"
></div>
</div>
<span class="credits-text">{{ student.totalCredits | credits }}</span>
</div>
</td>
<td>
<span class="badge badge-info">{{ student.enrollments.length }}/3</span>
</td>
<td>
<div class="actions">
<button
class="btn btn-icon btn-ghost"
[routerLink]="['/students', student.id, 'edit']"
matTooltip="Editar"
>
<mat-icon>edit</mat-icon>
</button>
<button
class="btn btn-icon btn-ghost"
[routerLink]="['/enrollment', student.id]"
matTooltip="Inscribir materias"
data-testid="btn-enroll"
>
<mat-icon>school</mat-icon>
</button>
<button
class="btn btn-icon btn-ghost"
[routerLink]="['/classmates', student.id]"
matTooltip="Ver compañeros"
[disabled]="student.enrollments.length === 0"
data-testid="btn-classmates"
>
<mat-icon>group</mat-icon>
</button>
<button
class="btn btn-icon btn-danger"
(click)="confirmDelete(student)"
matTooltip="Eliminar"
>
<mat-icon>delete</mat-icon>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</div>
`,
styles: [`
.page {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.table-container {
overflow-x: auto;
}
.student-cell {
display: flex;
align-items: center;
gap: 0.75rem;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #007AFF 0%, #5856D6 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.8125rem;
font-weight: 600;
flex-shrink: 0;
}
.student-name {
font-weight: 500;
}
.email-cell {
color: var(--text-secondary);
}
.credits-cell {
display: flex;
flex-direction: column;
gap: 0.375rem;
min-width: 120px;
}
.credits-text {
font-size: 0.8125rem;
color: var(--text-secondary);
}
.actions {
display: flex;
gap: 0.25rem;
}
@media (max-width: 768px) {
.email-cell {
display: none;
}
}
`],
})
export class StudentListComponent implements OnInit {
private studentService = inject(StudentService);
private notification = inject(NotificationService);
private errorHandler = inject(ErrorHandlerService);
private dialog = inject(MatDialog);
private router = inject(Router);
students = signal<Student[]>([]);
loading = signal(true);
ngOnInit(): void {
this.loadStudents();
}
private loadStudents(): void {
this.studentService.getStudents().subscribe({
next: ({ data, loading }) => {
this.students.set(data);
this.loading.set(loading);
},
error: (error) => {
this.loading.set(false);
this.errorHandler.handle(error, 'StudentList.cargar');
},
});
}
getInitials(name: string): string {
const parts = name.trim().split(' ');
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
confirmDelete(student: Student): void {
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Eliminar estudiante',
message: `¿Estás seguro de eliminar a "${student.name}"? Esta acción no se puede deshacer.`,
confirmText: 'Eliminar',
cancelText: 'Cancelar',
type: 'danger',
} as ConfirmDialogData,
});
dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.deleteStudent(student.id);
}
});
}
private deleteStudent(id: number): void {
this.studentService.deleteStudent(id).subscribe({
next: (result) => {
if (result.success) {
this.notification.success('Estudiante eliminado correctamente');
} else if (result.errors?.length) {
this.notification.error(result.errors.join('. '));
} else {
this.notification.error('No se pudo eliminar el estudiante');
}
},
error: (error) => this.errorHandler.handle(error, 'StudentList.eliminar'),
});
}
}

View File

@ -0,0 +1,66 @@
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
export interface ConfirmDialogData {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
type?: 'danger' | 'warning' | 'info';
}
@Component({
selector: 'app-confirm-dialog',
standalone: true,
imports: [MatDialogModule, MatButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="dialog">
<h2 class="dialog-title">{{ data.title }}</h2>
<p class="dialog-message">{{ data.message }}</p>
<div class="dialog-actions">
<button class="btn btn-secondary" (click)="dialogRef.close(false)">
{{ data.cancelText || 'Cancelar' }}
</button>
<button
class="btn"
[class.btn-danger]="data.type === 'danger'"
[class.btn-primary]="data.type !== 'danger'"
(click)="dialogRef.close(true)"
>
{{ data.confirmText || 'Confirmar' }}
</button>
</div>
</div>
`,
styles: [`
.dialog {
padding: 1.5rem;
min-width: 320px;
}
.dialog-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--text-primary);
}
.dialog-message {
color: var(--text-secondary);
margin-bottom: 1.5rem;
line-height: 1.5;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
`],
})
export class ConfirmDialogComponent {
dialogRef = inject(MatDialogRef<ConfirmDialogComponent>);
data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
}

View File

@ -0,0 +1,249 @@
import { Component, ChangeDetectionStrategy, inject, computed } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { MatButtonModule } from '@angular/material/button';
import { ConnectivityService } from '@core/services';
@Component({
selector: 'app-connectivity-overlay',
standalone: true,
imports: [MatIconModule, MatButtonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@if (showOverlay()) {
<div class="overlay" role="alertdialog" aria-modal="true" aria-labelledby="connectivity-title">
<div class="overlay-content">
<div class="icon-container">
<mat-icon class="error-icon">cloud_off</mat-icon>
<div class="pulse-ring"></div>
</div>
<h2 id="connectivity-title">Sin conexión al servidor</h2>
<p class="error-message">{{ errorMessage() }}</p>
<div class="details">
<div class="detail-item">
<mat-icon>info</mat-icon>
<span>Verifica que el servidor backend esté en ejecución</span>
</div>
<div class="detail-item">
<mat-icon>terminal</mat-icon>
<code>cd src/backend && dotnet run --project Host</code>
</div>
<div class="detail-item">
<mat-icon>storage</mat-icon>
<span>Verifica que SQL Server esté activo</span>
</div>
</div>
<div class="actions">
<button
class="btn btn-primary"
(click)="retry()"
[disabled]="isChecking()"
>
@if (isChecking()) {
<span class="btn-loading"></span>
Verificando...
} @else {
<mat-icon>refresh</mat-icon>
Reintentar conexión
}
</button>
</div>
<p class="auto-retry">
<mat-icon>schedule</mat-icon>
Reintentando automáticamente cada 5 segundos...
</p>
</div>
</div>
}
`,
styles: [`
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.overlay-content {
background: var(--bg-primary, #1c1c1e);
border: 1px solid var(--border-light, #38383a);
border-radius: 16px;
padding: 2.5rem;
max-width: 480px;
width: 90%;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.icon-container {
position: relative;
display: inline-flex;
margin-bottom: 1.5rem;
}
.error-icon {
font-size: 64px;
width: 64px;
height: 64px;
color: #ff453a;
}
.pulse-ring {
position: absolute;
inset: -8px;
border: 2px solid #ff453a;
border-radius: 50%;
animation: pulse 2s ease-out infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
h2 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary, #fff);
margin: 0 0 0.75rem;
}
.error-message {
color: var(--text-secondary, #98989d);
margin-bottom: 1.5rem;
font-size: 0.9375rem;
line-height: 1.5;
}
.details {
background: var(--bg-secondary, #2c2c2e);
border-radius: 12px;
padding: 1rem;
margin-bottom: 1.5rem;
text-align: left;
}
.detail-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 0;
color: var(--text-secondary, #98989d);
font-size: 0.875rem;
&:not(:last-child) {
border-bottom: 1px solid var(--border-light, #38383a);
}
mat-icon {
font-size: 1.125rem;
width: 1.125rem;
height: 1.125rem;
color: var(--text-tertiary, #636366);
flex-shrink: 0;
}
code {
background: var(--bg-tertiary, #3a3a3c);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8125rem;
color: #ff9f0a;
word-break: break-all;
}
}
.actions {
margin-bottom: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #007aff;
color: white;
&:hover:not(:disabled) {
background: #0056b3;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btn-loading {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.auto-retry {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
color: var(--text-tertiary, #636366);
font-size: 0.8125rem;
margin: 0;
mat-icon {
font-size: 1rem;
width: 1rem;
height: 1rem;
}
}
`],
})
export class ConnectivityOverlayComponent {
private connectivity = inject(ConnectivityService);
showOverlay = computed(() => !this.connectivity.state().isConnected);
isChecking = computed(() => this.connectivity.state().checking);
errorMessage = computed(() =>
this.connectivity.state().error || 'No se pudo establecer conexión con el servidor'
);
retry(): void {
this.connectivity.checkHealth();
}
}

View File

@ -0,0 +1,53 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
@Component({
selector: 'app-empty-state',
standalone: true,
imports: [MatIconModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="empty-state">
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
<h3 class="empty-state-title">{{ title() }}</h3>
@if (description()) {
<p class="empty-state-description">{{ description() }}</p>
}
<ng-content />
</div>
`,
styles: [`
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.empty-state-icon {
font-size: 4rem;
width: 4rem;
height: 4rem;
color: var(--text-tertiary);
margin-bottom: 1rem;
}
.empty-state-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.empty-state-description {
color: var(--text-secondary);
margin-bottom: 1.5rem;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
`],
})
export class EmptyStateComponent {
icon = input('inbox');
title = input('Sin resultados');
description = input<string>();
}

View File

@ -0,0 +1,52 @@
import { Component, ChangeDetectionStrategy, input } from '@angular/core';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
@Component({
selector: 'app-loading-spinner',
standalone: true,
imports: [MatProgressSpinnerModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="spinner-container" [class.overlay]="overlay()">
<mat-spinner [diameter]="size()" />
@if (message()) {
<p class="spinner-message">{{ message() }}</p>
}
</div>
`,
styles: [`
.spinner-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 1rem;
}
.spinner-container.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
z-index: 1000;
}
.spinner-message {
color: var(--text-secondary);
font-size: 0.9375rem;
}
::ng-deep .mat-mdc-progress-spinner {
--mdc-circular-progress-active-indicator-color: var(--accent);
}
`],
})
export class LoadingSpinnerComponent {
size = input(40);
message = input<string>();
overlay = input(false);
}

View File

@ -0,0 +1,9 @@
// Components
export * from './components/ui/confirm-dialog/confirm-dialog.component';
export * from './components/ui/loading-spinner/loading-spinner.component';
export * from './components/ui/empty-state/empty-state.component';
export * from './components/ui/connectivity-overlay/connectivity-overlay.component';
// Pipes
export * from './pipes/credits.pipe';
export * from './pipes/initials.pipe';

View File

@ -0,0 +1,33 @@
import { CreditsPipe } from './credits.pipe';
describe('CreditsPipe', () => {
let pipe: CreditsPipe;
beforeEach(() => {
pipe = new CreditsPipe();
});
it('should create an instance', () => {
expect(pipe).toBeTruthy();
});
it('should format credits with default max', () => {
expect(pipe.transform(6)).toBe('6/9 créditos');
});
it('should format zero credits', () => {
expect(pipe.transform(0)).toBe('0/9 créditos');
});
it('should format max credits', () => {
expect(pipe.transform(9)).toBe('9/9 créditos');
});
it('should use custom max credits', () => {
expect(pipe.transform(5, 12)).toBe('5/12 créditos');
});
it('should handle negative values', () => {
expect(pipe.transform(-1)).toBe('-1/9 créditos');
});
});

View File

@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'credits',
standalone: true,
})
export class CreditsPipe implements PipeTransform {
transform(value: number, maxCredits = 9): string {
return `${value}/${maxCredits} créditos`;
}
}

View File

@ -0,0 +1,42 @@
import { InitialsPipe } from './initials.pipe';
describe('InitialsPipe', () => {
let pipe: InitialsPipe;
beforeEach(() => {
pipe = new InitialsPipe();
});
it('should create an instance', () => {
expect(pipe).toBeTruthy();
});
it('should return initials for two words', () => {
expect(pipe.transform('Juan Pérez')).toBe('JP');
});
it('should return initials for three words', () => {
expect(pipe.transform('María García López')).toBe('ML');
});
it('should return single initial for single word', () => {
expect(pipe.transform('Carlos')).toBe('C');
});
it('should handle empty string', () => {
expect(pipe.transform('')).toBe('');
});
it('should handle null-like values', () => {
expect(pipe.transform(null as any)).toBe('');
expect(pipe.transform(undefined as any)).toBe('');
});
it('should uppercase initials', () => {
expect(pipe.transform('ana maría')).toBe('AM');
});
it('should handle extra whitespace', () => {
expect(pipe.transform(' Juan Pérez ')).toBe('JP');
});
});

View File

@ -0,0 +1,14 @@
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'initials',
standalone: true,
})
export class InitialsPipe implements PipeTransform {
transform(name: string): string {
if (!name) return '';
const parts = name.trim().split(' ');
if (parts.length === 1) return parts[0].charAt(0).toUpperCase();
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
}

View File

@ -0,0 +1,6 @@
export const environment = {
production: true,
graphqlUrl: '/graphql',
healthCheckUrl: '/health',
healthCheckInterval: 5000, // 5 segundos
};

View File

@ -0,0 +1,6 @@
export const environment = {
production: false,
graphqlUrl: 'http://localhost:5000/graphql',
healthCheckUrl: 'http://localhost:5000/health',
healthCheckInterval: 5000, // 5 segundos
};

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Sistema de Estudiantes</title>
<base href="/">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Sistema de Registro de Estudiantes - Inter Rapidísimo">
<link rel="icon" type="image/x-icon" href="favicon.ico">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
</head>
<body>
<app-root></app-root>
</body>
</html>

6
src/frontend/src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -0,0 +1,527 @@
// Apple-inspired Minimalist Design System
// =========================================
:root {
// Colors - Light Theme
--bg-primary: #F5F5F7;
--bg-secondary: #FFFFFF;
--bg-tertiary: #F2F2F7;
--text-primary: #1D1D1F;
--text-secondary: #86868B;
--text-tertiary: #AEAEB2;
--accent: #007AFF;
--accent-hover: #0066D6;
--success: #34C759;
--warning: #FF9500;
--error: #FF3B30;
--border-light: rgba(0, 0, 0, 0.08);
--border-medium: rgba(0, 0, 0, 0.12);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--transition: 0.2s ease;
}
// Reset & Base
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
letter-spacing: -0.01em;
}
// Typography
h1, h2, h3, h4, h5, h6 {
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.2;
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
// Card Component
.card {
background: var(--bg-secondary);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border-light);
overflow: hidden;
transition: box-shadow var(--transition);
&:hover {
box-shadow: var(--shadow-md);
}
&-header {
padding: 1.5rem;
border-bottom: 1px solid var(--border-light);
}
&-body {
padding: 1.5rem;
}
}
// Buttons
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
transition: all var(--transition);
text-decoration: none;
&-primary {
background: var(--accent);
color: white;
&:hover {
background: var(--accent-hover);
}
}
&-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--border-medium);
}
}
&-ghost {
background: transparent;
color: var(--accent);
&:hover {
background: rgba(0, 122, 255, 0.08);
}
}
&-danger {
background: rgba(255, 59, 48, 0.1);
color: var(--error);
&:hover {
background: rgba(255, 59, 48, 0.15);
}
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&-sm {
padding: 0.5rem 1rem;
font-size: 0.8125rem;
}
&-icon {
padding: 0.5rem;
border-radius: var(--radius-sm);
}
}
// Form Elements
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
}
.input {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
border: 1px solid var(--border-medium);
border-radius: var(--radius-md);
background: var(--bg-secondary);
color: var(--text-primary);
transition: all var(--transition);
&:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0, 122, 255, 0.15);
}
&::placeholder {
color: var(--text-tertiary);
}
&.error {
border-color: var(--error);
&:focus {
box-shadow: 0 0 0 3px rgba(255, 59, 48, 0.15);
}
}
}
.error-message {
color: var(--error);
font-size: 0.8125rem;
margin-top: 0.5rem;
}
// Page Header
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
h1 {
font-size: 1.75rem;
}
}
.page-subtitle {
color: var(--text-secondary);
font-size: 0.9375rem;
margin-top: 0.25rem;
}
// Back Link
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--accent);
text-decoration: none;
font-size: 0.9375rem;
font-weight: 500;
margin-bottom: 1.5rem;
transition: opacity var(--transition);
&:hover {
opacity: 0.8;
}
mat-icon {
font-size: 1.25rem;
width: 1.25rem;
height: 1.25rem;
}
}
// Table
.data-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 1rem 1.25rem;
text-align: left;
}
th {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-light);
}
td {
border-bottom: 1px solid var(--border-light);
font-size: 0.9375rem;
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: var(--bg-tertiary);
}
}
// Badge
.badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
border-radius: 100px;
&-info {
background: rgba(0, 122, 255, 0.1);
color: var(--accent);
}
&-success {
background: rgba(52, 199, 89, 0.1);
color: var(--success);
}
&-warning {
background: rgba(255, 149, 0, 0.1);
color: var(--warning);
}
&-error {
background: rgba(255, 59, 48, 0.1);
color: var(--error);
}
}
// Progress Bar
.progress-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 100px;
overflow: hidden;
&-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent) 0%, #5856D6 100%);
border-radius: 100px;
transition: width 0.3s ease;
}
}
// Empty State
.empty-state {
text-align: center;
padding: 4rem 2rem;
&-icon {
font-size: 4rem;
color: var(--text-tertiary);
margin-bottom: 1rem;
}
&-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
&-description {
color: var(--text-secondary);
margin-bottom: 1.5rem;
}
}
// Loading Skeleton
.skeleton {
background: linear-gradient(90deg, var(--bg-tertiary) 25%, var(--bg-secondary) 50%, var(--bg-tertiary) 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
// Toast Notification
.toast {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
padding: 1rem 1.5rem;
background: var(--text-primary);
color: white;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 1000;
animation: toast-in 0.3s ease;
&-success { background: var(--success); }
&-error { background: var(--error); }
}
@keyframes toast-in {
from {
opacity: 0;
transform: translate(-50%, 1rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
// Actions Group
.actions {
display: flex;
gap: 0.5rem;
}
// Subject Card (for enrollment)
.subject-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
transition: all var(--transition);
&:hover {
border-color: var(--border-medium);
}
&.enrolled {
border-color: var(--success);
background: rgba(52, 199, 89, 0.05);
}
&.unavailable {
opacity: 0.6;
}
&-info {
flex: 1;
}
&-name {
font-weight: 500;
margin-bottom: 0.25rem;
}
&-professor {
font-size: 0.875rem;
color: var(--text-secondary);
}
&-credits {
font-size: 0.75rem;
color: var(--text-tertiary);
}
}
// Classmates List
.classmates-section {
margin-bottom: 2rem;
&-title {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid var(--border-light);
}
}
.classmate-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
&-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-tertiary);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
}
&-name {
font-size: 0.9375rem;
}
}
// Angular Material Overrides
.mat-mdc-snack-bar-container {
&.success-snackbar {
--mdc-snackbar-container-color: var(--success);
}
&.error-snackbar {
--mdc-snackbar-container-color: var(--error);
}
}
.mat-mdc-dialog-container {
--mdc-dialog-container-shape: var(--radius-lg);
}
.mat-mdc-form-field {
width: 100%;
}
// Responsive
@media (max-width: 768px) {
.page-header {
flex-direction: column;
align-items: flex-start;
}
.data-table {
display: block;
overflow-x: auto;
}
}
@media (max-width: 600px) {
h1 { font-size: 1.75rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
.card {
border-radius: var(--radius-md);
&-header, &-body {
padding: 1rem;
}
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View File

@ -0,0 +1,35 @@
{
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"sourceMap": true,
"declaration": false,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "dom"],
"baseUrl": "./",
"paths": {
"@core/*": ["./src/app/core/*"],
"@shared/*": ["./src/app/shared/*"],
"@features/*": ["./src/app/features/*"],
"@env/*": ["./src/environments/*"]
}
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["jasmine"]
},
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
}