# US-ADM-001: Gestión de Aulas (CRUD Básico) **Épica:** EAI-005 (Plataforma de Maestro Básica) **Sprint:** Mes 1, Semana 2 **Story Points:** 8 SP **Presupuesto:** $3,200 MXN **Prioridad:** Alta (Alcance Inicial) **Estado:** ✅ Completada (Mes 1) --- ## Descripción Como profesor, quiero crear y gestionar mis aulas virtuales para organizar a mis estudiantes en diferentes grupos o clases. **Contexto del Alcance Inicial:** Esta funcionalidad proporciona el CRUD básico para que un profesor pueda crear, editar, listar y eliminar aulas. Es la funcionalidad base de la Plataforma de Maestro. NO incluye soft delete, archivado de aulas, templates de aula, ni clonación (eso va a EXT-001 Portal de Maestros Completo). --- ## Criterios de Aceptación ### CA-01: Crear Aula - [ ] Formulario de creación con campos: - Nombre del aula (requerido, máx 100 caracteres) - Descripción (opcional, máx 500 caracteres) - Nivel educativo (selector: Primaria, Secundaria, Preparatoria) - Grado (selector según nivel: 1-6, 1-3, 1-3) - Ciclo escolar (texto, ej: "2024-2025") - [ ] Validación de campos requeridos - [ ] El aula se crea asociada al profesor autenticado - [ ] Mensaje de confirmación al crear exitosamente - [ ] Redirección a la lista de aulas o al dashboard del aula creada ### CA-02: Listar Mis Aulas - [ ] Vista de grid/cards con todas las aulas del profesor - [ ] Para cada aula muestra: - Nombre del aula - Nivel y grado - # de estudiantes - Fecha de creación - Acciones (editar, eliminar, ver) - [ ] Ordenadas por fecha de creación (más recientes primero) - [ ] Mensaje amigable si no hay aulas creadas ### CA-03: Editar Aula - [ ] Formulario pre-poblado con datos actuales del aula - [ ] Campos editables: todos excepto profesor propietario - [ ] Validación de campos - [ ] Confirmación de cambios guardados - [ ] Actualización en tiempo real en la lista ### CA-04: Eliminar Aula - [ ] Modal de confirmación antes de eliminar - [ ] Advertencia si el aula tiene estudiantes - [ ] Eliminación permanente (hard delete en alcance inicial) - [ ] Eliminación en cascada: se remueven relaciones con estudiantes - [ ] No se eliminan los estudiantes (solo la relación) - [ ] Mensaje de confirmación al eliminar - [ ] Actualización de la lista ### CA-05: Ver Dashboard del Aula - [ ] Botón "Ver" en cada aula que lleva al dashboard (US-ANA-001) - [ ] Breadcrumb indicando el aula actual - [ ] Navegación entre aulas desde el header ### CA-06: Validaciones de Negocio - [ ] Un profesor puede crear hasta 20 aulas (límite hardcodeado) - [ ] Nombres de aula pueden repetirse (entre aulas del mismo profesor) - [ ] Al eliminar aula con estudiantes, mostrar advertencia pero permitir --- ## Especificaciones Técnicas ### Backend **Endpoints:** ```typescript // Crear aula POST /api/teacher/classrooms Body: { name: string; description?: string; level: 'primaria' | 'secundaria' | 'preparatoria'; grade: number; schoolYear: string; } // Listar mis aulas GET /api/teacher/classrooms // Obtener aula específica GET /api/teacher/classrooms/{classroomId} // Actualizar aula PATCH /api/teacher/classrooms/{classroomId} Body: { name?: string; description?: string; level?: string; grade?: number; schoolYear?: string; } // Eliminar aula DELETE /api/teacher/classrooms/{classroomId} ``` **Response de Crear/Actualizar:** ```json { "id": "classroom-uuid", "name": "Matemáticas 6A", "description": "Clase de matemáticas para sexto grado grupo A", "level": "primaria", "grade": 6, "schoolYear": "2024-2025", "teacherId": "teacher-uuid", "studentCount": 0, "createdAt": "2025-11-02T10:00:00Z", "updatedAt": "2025-11-02T10:00:00Z" } ``` **Response de Listar:** ```json { "classrooms": [ { "id": "classroom-uuid", "name": "Matemáticas 6A", "description": "Clase de matemáticas para sexto grado grupo A", "level": "primaria", "grade": 6, "schoolYear": "2024-2025", "studentCount": 25, "moduleCount": 5, "createdAt": "2025-11-02T10:00:00Z" } ], "total": 1 } ``` **Controller:** ```typescript // TeacherClassroomController.ts @Controller('teacher/classrooms') @UseGuards(AuthGuard, TeacherGuard) export class TeacherClassroomController { constructor(private classroomService: ClassroomService) {} @Post() async create( @Body() createDto: CreateClassroomDto, @CurrentUser() teacher: User ) { return this.classroomService.createClassroom(createDto, teacher.id); } @Get() async findAll(@CurrentUser() teacher: User) { return this.classroomService.findAllByTeacher(teacher.id); } @Get(':id') async findOne( @Param('id') id: string, @CurrentUser() teacher: User ) { return this.classroomService.findOneByTeacher(id, teacher.id); } @Patch(':id') async update( @Param('id') id: string, @Body() updateDto: UpdateClassroomDto, @CurrentUser() teacher: User ) { return this.classroomService.updateClassroom(id, updateDto, teacher.id); } @Delete(':id') async remove( @Param('id') id: string, @CurrentUser() teacher: User ) { await this.classroomService.removeClassroom(id, teacher.id); return { message: 'Aula eliminada exitosamente' }; } } ``` **DTOs:** ```typescript // create-classroom.dto.ts export class CreateClassroomDto { @IsString() @IsNotEmpty() @MaxLength(100) name: string; @IsString() @IsOptional() @MaxLength(500) description?: string; @IsIn(['primaria', 'secundaria', 'preparatoria']) level: string; @IsInt() @Min(1) @Max(6) grade: number; @IsString() @IsNotEmpty() schoolYear: string; } // update-classroom.dto.ts export class UpdateClassroomDto { @IsString() @IsOptional() @MaxLength(100) name?: string; @IsString() @IsOptional() @MaxLength(500) description?: string; @IsIn(['primaria', 'secundaria', 'preparatoria']) @IsOptional() level?: string; @IsInt() @Min(1) @Max(6) @IsOptional() grade?: number; @IsString() @IsOptional() schoolYear?: string; } ``` **Service:** ```typescript // classroom.service.ts @Injectable() export class ClassroomService { constructor( @InjectRepository(Classroom) private classroomRepository: Repository ) {} async createClassroom(dto: CreateClassroomDto, teacherId: string) { // Validar límite de aulas const count = await this.classroomRepository.count({ where: { teacherId } }); if (count >= 20) { throw new BadRequestException( 'Has alcanzado el límite de 20 aulas. Elimina algunas para crear nuevas.' ); } const classroom = this.classroomRepository.create({ ...dto, teacherId }); return this.classroomRepository.save(classroom); } async findAllByTeacher(teacherId: string) { const classrooms = await this.classroomRepository .createQueryBuilder('classroom') .leftJoin('classroom.students', 'students') .leftJoin('classroom.modules', 'modules') .select([ 'classroom.id', 'classroom.name', 'classroom.description', 'classroom.level', 'classroom.grade', 'classroom.schoolYear', 'classroom.createdAt' ]) .addSelect('COUNT(DISTINCT students.id)', 'studentCount') .addSelect('COUNT(DISTINCT modules.id)', 'moduleCount') .where('classroom.teacherId = :teacherId', { teacherId }) .groupBy('classroom.id') .orderBy('classroom.createdAt', 'DESC') .getRawAndEntities(); return { classrooms: classrooms.entities.map((classroom, index) => ({ ...classroom, studentCount: parseInt(classrooms.raw[index].studentCount), moduleCount: parseInt(classrooms.raw[index].moduleCount) })), total: classrooms.entities.length }; } async findOneByTeacher(classroomId: string, teacherId: string) { const classroom = await this.classroomRepository.findOne({ where: { id: classroomId, teacherId } }); if (!classroom) { throw new NotFoundException('Aula no encontrada'); } return classroom; } async updateClassroom( classroomId: string, dto: UpdateClassroomDto, teacherId: string ) { const classroom = await this.findOneByTeacher(classroomId, teacherId); Object.assign(classroom, dto); return this.classroomRepository.save(classroom); } async removeClassroom(classroomId: string, teacherId: string) { const classroom = await this.findOneByTeacher(classroomId, teacherId); // Hard delete (soft delete va a EXT-001) await this.classroomRepository.remove(classroom); } } ``` **Modelo:** ```typescript // classroom.entity.ts @Entity('classrooms') export class Classroom { @PrimaryGeneratedColumn('uuid') id: string; @Column({ length: 100 }) name: string; @Column({ length: 500, nullable: true }) description: string; @Column() level: string; // primaria, secundaria, preparatoria @Column('int') grade: number; @Column({ name: 'school_year' }) schoolYear: string; @Column({ name: 'teacher_id' }) teacherId: string; @ManyToOne(() => User) @JoinColumn({ name: 'teacher_id' }) teacher: User; @ManyToMany(() => Student, student => student.classrooms) @JoinTable({ name: 'classroom_students' }) students: Student[]; @ManyToMany(() => Module) @JoinTable({ name: 'classroom_modules' }) modules: Module[]; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt: Date; } ``` ### Frontend **Rutas:** ``` /teacher/classrooms -> Lista de aulas /teacher/classrooms/new -> Crear aula /teacher/classrooms/:id/edit -> Editar aula ``` **Componente de Lista:** ```typescript // ClassroomListView.tsx export const ClassroomListView = () => { const { classrooms, isLoading, refetch } = useClassrooms(); const navigate = useNavigate(); const handleDelete = async (classroomId: string) => { const confirmed = await confirm({ title: 'Eliminar Aula', message: '¿Estás seguro de que deseas eliminar esta aula? Esta acción no se puede deshacer.', confirmText: 'Eliminar', cancelText: 'Cancelar' }); if (confirmed) { await deleteClassroom(classroomId); toast.success('Aula eliminada exitosamente'); refetch(); } }; if (isLoading) return ; return (
navigate('/teacher/classrooms/new')} leftIcon={} > Crear Aula } /> {classrooms.length === 0 ? ( } title="No tienes aulas creadas" description="Crea tu primera aula para comenzar a organizar a tus estudiantes" action={ } /> ) : (
{classrooms.map(classroom => ( navigate(`/teacher/classrooms/${classroom.id}/edit`)} onDelete={() => handleDelete(classroom.id)} onView={() => navigate(`/teacher/classroom/${classroom.id}/dashboard`)} /> ))}
)} {classrooms.length >= 20 && ( Has alcanzado el límite de 20 aulas. Elimina algunas para crear nuevas. )}
); }; ``` **Componente de Card:** ```typescript // ClassroomCard.tsx export const ClassroomCard = ({ classroom, onEdit, onDelete, onView }) => { return (

{classroom.name}

{getLevelLabel(classroom.level)} - {classroom.grade}°
{classroom.description && (

{classroom.description}

)}
} label="Estudiantes" value={classroom.studentCount} /> } label="Módulos" value={classroom.moduleCount} />
{classroom.schoolYear} Creada: {formatDate(classroom.createdAt)}
); }; ``` **Formulario de Crear/Editar:** ```typescript // ClassroomForm.tsx export const ClassroomForm = ({ classroomId }: { classroomId?: string }) => { const navigate = useNavigate(); const isEdit = !!classroomId; const { classroom, isLoading: loadingClassroom } = useClassroom(classroomId); const { register, handleSubmit, watch, formState: { errors, isSubmitting } } = useForm({ defaultValues: classroom || {} }); const selectedLevel = watch('level'); const onSubmit = async (data: ClassroomFormData) => { try { if (isEdit) { await updateClassroom(classroomId, data); toast.success('Aula actualizada exitosamente'); } else { const newClassroom = await createClassroom(data); toast.success('Aula creada exitosamente'); navigate(`/teacher/classroom/${newClassroom.id}/dashboard`); } } catch (error) { toast.error(error.message); } }; if (loadingClassroom) return ; return (