# US-ADM-002: Gestión de Estudiantes en Aula
**Épica:** EAI-005 (Plataforma de Maestro Básica)
**Sprint:** Mes 1, Semana 2
**Story Points:** 10 SP
**Presupuesto:** $4,000 MXN
**Prioridad:** Alta (Alcance Inicial)
**Estado:** ✅ Completada (Mes 1)
---
## Descripción
Como profesor, quiero agregar y remover estudiantes de mi aula para controlar quién tiene acceso a los contenidos y actividades de la clase.
**Contexto del Alcance Inicial:**
Esta funcionalidad permite la gestión manual de estudiantes en un aula: ver lista, agregar uno por uno, y remover. NO incluye importación masiva desde CSV, invitaciones por email, auto-registro con código de clase, ni transferencia entre aulas (eso va a EXT-001 Portal de Maestros Completo).
---
## Criterios de Aceptación
### CA-01: Ver Lista de Estudiantes del Aula
- [ ] Vista de tabla/lista con todos los estudiantes del aula
- [ ] Para cada estudiante muestra:
- Avatar/foto
- Nombre completo
- Email (si disponible)
- Fecha de ingreso al aula
- Estado (activo)
- Acción de remover
- [ ] Contador total de estudiantes
- [ ] Búsqueda simple por nombre
### CA-02: Agregar Estudiante al Aula
- [ ] Botón "Agregar Estudiante"
- [ ] Modal/formulario con dos opciones:
- **Opción A:** Buscar estudiante existente en la plataforma (por email o nombre)
- **Opción B:** Crear nuevo estudiante y agregarlo al aula
- [ ] Formulario de nuevo estudiante:
- Nombre completo (requerido)
- Email (opcional)
- Crear cuenta automáticamente con credenciales temporales
- [ ] Validación: no agregar estudiante duplicado
- [ ] Confirmación al agregar exitosamente
### CA-03: Remover Estudiante del Aula
- [ ] Botón/ícono de remover en cada fila
- [ ] Modal de confirmación
- [ ] Advertencia: "Esto removerá al estudiante del aula pero no eliminará su cuenta"
- [ ] Al confirmar, se remueve la relación aula-estudiante
- [ ] El estudiante y su progreso se conservan
- [ ] Actualización inmediata de la lista
### CA-04: Búsqueda de Estudiantes Existentes
- [ ] Campo de búsqueda en modal de agregar
- [ ] Búsqueda por nombre o email
- [ ] Resultados en tiempo real (debounce 300ms)
- [ ] Muestra si el estudiante ya está en el aula
- [ ] Botón "Agregar" para cada resultado válido
### CA-05: Creación Rápida de Estudiante
- [ ] Formulario simple en modal
- [ ] Crear cuenta de estudiante con:
- username: generado automáticamente (primernombre.apellido)
- password temporal: generada automáticamente
- role: student
- [ ] Mostrar credenciales generadas al profesor
- [ ] Opción de copiar credenciales
- [ ] Agregar automáticamente al aula tras crear
### CA-06: Validaciones
- [ ] No agregar estudiante que ya está en el aula
- [ ] Validar email único en la plataforma (si se proporciona)
- [ ] Límite de 100 estudiantes por aula (hardcodeado)
- [ ] Mensajes de error claros
---
## Especificaciones Técnicas
### Backend
**Endpoints:**
```typescript
// Listar estudiantes del aula
GET /api/teacher/classrooms/{classroomId}/students
Query params: ?search=juan
// Agregar estudiante existente al aula
POST /api/teacher/classrooms/{classroomId}/students
Body: { studentId: string }
// Crear estudiante y agregarlo al aula
POST /api/teacher/classrooms/{classroomId}/students/create
Body: {
name: string;
email?: string;
}
// Remover estudiante del aula
DELETE /api/teacher/classrooms/{classroomId}/students/{studentId}
// Buscar estudiantes disponibles
GET /api/teacher/students/search
Query params: ?q=juan&classroomId=uuid
```
**Response de Listar:**
```json
{
"classroomId": "uuid",
"students": [
{
"id": "student-uuid",
"name": "Juan Pérez García",
"email": "juan.perez@example.com",
"avatarUrl": "/avatars/student.png",
"addedAt": "2025-10-01T10:00:00Z"
}
],
"total": 25
}
```
**Response de Crear Estudiante:**
```json
{
"student": {
"id": "student-uuid",
"name": "María López",
"email": "maria.lopez@example.com",
"username": "maria.lopez"
},
"credentials": {
"username": "maria.lopez",
"temporaryPassword": "Temp123!Maria"
},
"message": "Estudiante creado y agregado al aula exitosamente"
}
```
**Response de Búsqueda:**
```json
{
"students": [
{
"id": "student-uuid",
"name": "Carlos Gómez",
"email": "carlos@example.com",
"isInClassroom": false
},
{
"id": "student-uuid-2",
"name": "Ana García",
"email": "ana@example.com",
"isInClassroom": true
}
]
}
```
**Controller:**
```typescript
// TeacherClassroomController.ts
@Controller('teacher/classrooms/:classroomId')
@UseGuards(AuthGuard, TeacherGuard)
export class TeacherClassroomController {
@Get('students')
async getStudents(
@Param('classroomId') classroomId: string,
@Query('search') search: string,
@CurrentUser() teacher: User
) {
return this.classroomService.getClassroomStudents(
classroomId,
teacher.id,
search
);
}
@Post('students')
async addStudent(
@Param('classroomId') classroomId: string,
@Body() dto: AddStudentDto,
@CurrentUser() teacher: User
) {
await this.classroomService.addStudentToClassroom(
classroomId,
dto.studentId,
teacher.id
);
return { message: 'Estudiante agregado exitosamente' };
}
@Post('students/create')
async createAndAddStudent(
@Param('classroomId') classroomId: string,
@Body() dto: CreateStudentDto,
@CurrentUser() teacher: User
) {
return this.classroomService.createAndAddStudent(
classroomId,
dto,
teacher.id
);
}
@Delete('students/:studentId')
async removeStudent(
@Param('classroomId') classroomId: string,
@Param('studentId') studentId: string,
@CurrentUser() teacher: User
) {
await this.classroomService.removeStudentFromClassroom(
classroomId,
studentId,
teacher.id
);
return { message: 'Estudiante removido del aula exitosamente' };
}
}
@Controller('teacher/students')
@UseGuards(AuthGuard, TeacherGuard)
export class TeacherStudentController {
@Get('search')
async searchStudents(
@Query('q') query: string,
@Query('classroomId') classroomId: string
) {
return this.studentService.searchStudents(query, classroomId);
}
}
```
**DTOs:**
```typescript
// add-student.dto.ts
export class AddStudentDto {
@IsUUID()
studentId: string;
}
// create-student.dto.ts
export class CreateStudentDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsEmail()
@IsOptional()
email?: string;
}
```
**Service:**
```typescript
// classroom.service.ts
async getClassroomStudents(
classroomId: string,
teacherId: string,
search?: string
) {
// Validar acceso del profesor
await this.validateTeacherAccess(classroomId, teacherId);
let query = this.classroomRepository
.createQueryBuilder('classroom')
.innerJoinAndSelect('classroom.students', 'student')
.innerJoin('classroom_students', 'cs', 'cs.student_id = student.id AND cs.classroom_id = :classroomId', { classroomId })
.where('classroom.id = :classroomId', { classroomId });
if (search) {
query = query.andWhere('LOWER(student.name) LIKE LOWER(:search)', {
search: `%${search}%`
});
}
const classroom = await query.getOne();
return {
classroomId,
students: classroom?.students.map(student => ({
id: student.id,
name: student.name,
email: student.email,
avatarUrl: student.avatarUrl,
addedAt: student.createdAt // Idealmente desde tabla pivot
})) || [],
total: classroom?.students.length || 0
};
}
async addStudentToClassroom(
classroomId: string,
studentId: string,
teacherId: string
) {
// Validar acceso del profesor
await this.validateTeacherAccess(classroomId, teacherId);
const classroom = await this.classroomRepository.findOne({
where: { id: classroomId },
relations: ['students']
});
// Validar límite de 100 estudiantes
if (classroom.students.length >= 100) {
throw new BadRequestException(
'Has alcanzado el límite de 100 estudiantes por aula'
);
}
// Validar que el estudiante existe
const student = await this.studentRepository.findOne({
where: { id: studentId }
});
if (!student) {
throw new NotFoundException('Estudiante no encontrado');
}
// Validar que no está ya en el aula
const alreadyInClassroom = classroom.students.some(s => s.id === studentId);
if (alreadyInClassroom) {
throw new BadRequestException('El estudiante ya está en esta aula');
}
// Agregar estudiante
classroom.students.push(student);
await this.classroomRepository.save(classroom);
}
async createAndAddStudent(
classroomId: string,
dto: CreateStudentDto,
teacherId: string
) {
// Validar acceso del profesor
await this.validateTeacherAccess(classroomId, teacherId);
// Generar username y password
const username = this.generateUsername(dto.name);
const temporaryPassword = this.generateTemporaryPassword(dto.name);
// Validar que el email no existe (si se proporciona)
if (dto.email) {
const existingUser = await this.userRepository.findOne({
where: { email: dto.email }
});
if (existingUser) {
throw new BadRequestException('Ya existe un usuario con este email');
}
}
// Crear usuario estudiante
const hashedPassword = await bcrypt.hash(temporaryPassword, 10);
const user = this.userRepository.create({
name: dto.name,
email: dto.email,
username,
password: hashedPassword,
role: 'student'
});
const savedUser = await this.userRepository.save(user);
// Crear estudiante
const student = this.studentRepository.create({
userId: savedUser.id,
name: dto.name,
email: dto.email,
avatarUrl: this.getDefaultAvatar()
});
await this.studentRepository.save(student);
// Agregar al aula
await this.addStudentToClassroom(classroomId, student.id, teacherId);
return {
student: {
id: student.id,
name: student.name,
email: student.email,
username
},
credentials: {
username,
temporaryPassword
},
message: 'Estudiante creado y agregado al aula exitosamente'
};
}
async removeStudentFromClassroom(
classroomId: string,
studentId: string,
teacherId: string
) {
// Validar acceso del profesor
await this.validateTeacherAccess(classroomId, teacherId);
const classroom = await this.classroomRepository.findOne({
where: { id: classroomId },
relations: ['students']
});
// Remover relación (no eliminar estudiante)
classroom.students = classroom.students.filter(s => s.id !== studentId);
await this.classroomRepository.save(classroom);
}
private generateUsername(name: string): string {
// Convertir "Juan Pérez García" -> "juan.perez"
const parts = name.toLowerCase().split(' ');
return `${parts[0]}.${parts[1] || parts[0]}`;
}
private generateTemporaryPassword(name: string): string {
// Generar password: Temp123!PrimerNombre
const firstName = name.split(' ')[0];
return `Temp123!${firstName}`;
}
private getDefaultAvatar(): string {
// Avatar placeholder
return '/avatars/default-student.png';
}
// student.service.ts
async searchStudents(query: string, classroomId: string) {
const students = await this.studentRepository
.createQueryBuilder('student')
.leftJoin('student.classrooms', 'classroom', 'classroom.id = :classroomId', { classroomId })
.where('LOWER(student.name) LIKE LOWER(:query)', { query: `%${query}%` })
.orWhere('LOWER(student.email) LIKE LOWER(:query)', { query: `%${query}%` })
.select([
'student.id',
'student.name',
'student.email',
'classroom.id'
])
.limit(10)
.getMany();
return {
students: students.map(student => ({
id: student.id,
name: student.name,
email: student.email,
isInClassroom: !!student.classrooms?.length
}))
};
}
```
### Frontend
**Ruta:**
```
/teacher/classroom/:classroomId/students
```
**Componente Principal:**
```typescript
// ClassroomStudentsView.tsx
export const ClassroomStudentsView = () => {
const { classroomId } = useParams();
const [search, setSearch] = useState('');
const [showAddModal, setShowAddModal] = useState(false);
const { students, total, isLoading, refetch } = useClassroomStudents(
classroomId,
search
);
const handleRemove = async (studentId: string, studentName: string) => {
const confirmed = await confirm({
title: 'Remover Estudiante',
message: `¿Remover a ${studentName} del aula? Esto no eliminará su cuenta ni su progreso.`,
confirmText: 'Remover',
cancelText: 'Cancelar'
});
if (confirmed) {
await removeStudentFromClassroom(classroomId, studentId);
toast.success('Estudiante removido exitosamente');
refetch();
}
};
if (isLoading) return
{student.email}
Guarda estas credenciales. Compártelas con el estudiante para que pueda ingresar.
{credentials.username}
{credentials.temporaryPassword}