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:
parent
2b323adcb4
commit
e30424cd1f
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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' },
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './types';
|
||||||
|
|
@ -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'];
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './student.model';
|
||||||
|
|
@ -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[];
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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: [] } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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})`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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@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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
graphqlUrl: '/graphql',
|
||||||
|
healthCheckUrl: '/health',
|
||||||
|
healthCheckInterval: 5000, // 5 segundos
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
graphqlUrl: 'http://localhost:5000/graphql',
|
||||||
|
healthCheckUrl: 'http://localhost:5000/health',
|
||||||
|
healthCheckInterval: 5000, // 5 segundos
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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));
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/app",
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"files": ["src/main.ts"],
|
||||||
|
"include": ["src/**/*.d.ts"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./out-tsc/spec",
|
||||||
|
"types": ["jasmine"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue