workspace/projects/gamilit/docs/01-fase-alcance-inicial/EAI-005-admin-base/historias-usuario/US-ADM-001-gestion-aulas-crud.md
rckrdmrd ea1879f4ad feat: Initial workspace structure with multi-level Git configuration
- Configure workspace Git repository with comprehensive .gitignore
- Add Odoo as submodule for ERP reference code
- Include documentation: SETUP.md, GIT-STRUCTURE.md
- Add gitignore templates for projects (backend, frontend, database)
- Structure supports independent repos per project/subproject level

Workspace includes:
- core/ - Reusable patterns, modules, orchestration system
- projects/ - Active projects (erp-suite, gamilit, trading-platform, etc.)
- knowledge-base/ - Reference code and patterns (includes Odoo submodule)
- devtools/ - Development tools and templates
- customers/ - Client implementations template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 10:44:23 -06:00

21 KiB
Raw Permalink Blame History

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:

// 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:

{
  "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:

{
  "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:

// 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:

// 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:

// classroom.service.ts
@Injectable()
export class ClassroomService {
  constructor(
    @InjectRepository(Classroom)
    private classroomRepository: Repository<Classroom>
  ) {}

  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:

// 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:

// 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 <ClassroomListSkeleton />;

  return (
    <div className="classroom-list-container">
      <PageHeader
        title="Mis Aulas"
        action={
          <Button
            onClick={() => navigate('/teacher/classrooms/new')}
            leftIcon={<PlusIcon />}
          >
            Crear Aula
          </Button>
        }
      />

      {classrooms.length === 0 ? (
        <EmptyState
          icon={<ClassroomIcon />}
          title="No tienes aulas creadas"
          description="Crea tu primera aula para comenzar a organizar a tus estudiantes"
          action={
            <Button onClick={() => navigate('/teacher/classrooms/new')}>
              Crear Aula
            </Button>
          }
        />
      ) : (
        <div className="classroom-grid">
          {classrooms.map(classroom => (
            <ClassroomCard
              key={classroom.id}
              classroom={classroom}
              onEdit={() => navigate(`/teacher/classrooms/${classroom.id}/edit`)}
              onDelete={() => handleDelete(classroom.id)}
              onView={() => navigate(`/teacher/classroom/${classroom.id}/dashboard`)}
            />
          ))}
        </div>
      )}

      {classrooms.length >= 20 && (
        <Alert severity="warning">
          Has alcanzado el límite de 20 aulas. Elimina algunas para crear nuevas.
        </Alert>
      )}
    </div>
  );
};

Componente de Card:

// ClassroomCard.tsx
export const ClassroomCard = ({ classroom, onEdit, onDelete, onView }) => {
  return (
    <Card className="classroom-card">
      <CardHeader>
        <h3>{classroom.name}</h3>
        <Badge>{getLevelLabel(classroom.level)} - {classroom.grade}°</Badge>
      </CardHeader>

      <CardBody>
        {classroom.description && (
          <p className="description">{classroom.description}</p>
        )}

        <div className="stats">
          <Stat
            icon={<UsersIcon />}
            label="Estudiantes"
            value={classroom.studentCount}
          />
          <Stat
            icon={<ModulesIcon />}
            label="Módulos"
            value={classroom.moduleCount}
          />
        </div>

        <div className="meta">
          <span className="school-year">{classroom.schoolYear}</span>
          <span className="created-at">
            Creada: {formatDate(classroom.createdAt)}
          </span>
        </div>
      </CardBody>

      <CardFooter>
        <ButtonGroup>
          <Button variant="primary" onClick={onView}>
            Ver
          </Button>
          <Button variant="outline" onClick={onEdit}>
            Editar
          </Button>
          <Button variant="ghost" color="red" onClick={onDelete}>
            Eliminar
          </Button>
        </ButtonGroup>
      </CardFooter>
    </Card>
  );
};

Formulario de Crear/Editar:

// 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<ClassroomFormData>({
    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 <FormSkeleton />;

  return (
    <div className="classroom-form-container">
      <PageHeader
        title={isEdit ? 'Editar Aula' : 'Crear Aula'}
        breadcrumb={[
          { label: 'Mis Aulas', to: '/teacher/classrooms' },
          { label: isEdit ? 'Editar' : 'Crear' }
        ]}
      />

      <form onSubmit={handleSubmit(onSubmit)}>
        <FormSection title="Información General">
          <FormField
            label="Nombre del Aula"
            required
            error={errors.name?.message}
          >
            <Input
              {...register('name', {
                required: 'El nombre es requerido',
                maxLength: {
                  value: 100,
                  message: 'Máximo 100 caracteres'
                }
              })}
              placeholder="Ej: Matemáticas 6A"
            />
          </FormField>

          <FormField
            label="Descripción"
            error={errors.description?.message}
          >
            <Textarea
              {...register('description', {
                maxLength: {
                  value: 500,
                  message: 'Máximo 500 caracteres'
                }
              })}
              placeholder="Descripción opcional del aula"
              rows={3}
            />
          </FormField>
        </FormSection>

        <FormSection title="Nivel Educativo">
          <div className="form-row">
            <FormField
              label="Nivel"
              required
              error={errors.level?.message}
            >
              <Select
                {...register('level', {
                  required: 'El nivel es requerido'
                })}
                options={[
                  { value: 'primaria', label: 'Primaria' },
                  { value: 'secundaria', label: 'Secundaria' },
                  { value: 'preparatoria', label: 'Preparatoria' }
                ]}
              />
            </FormField>

            <FormField
              label="Grado"
              required
              error={errors.grade?.message}
            >
              <Select
                {...register('grade', {
                  required: 'El grado es requerido',
                  valueAsNumber: true
                })}
                options={getGradeOptions(selectedLevel)}
              />
            </FormField>
          </div>

          <FormField
            label="Ciclo Escolar"
            required
            error={errors.schoolYear?.message}
          >
            <Input
              {...register('schoolYear', {
                required: 'El ciclo escolar es requerido'
              })}
              placeholder="Ej: 2024-2025"
            />
          </FormField>
        </FormSection>

        <FormActions>
          <Button
            type="button"
            variant="outline"
            onClick={() => navigate('/teacher/classrooms')}
          >
            Cancelar
          </Button>
          <Button type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Guardando...' : (isEdit ? 'Actualizar' : 'Crear Aula')}
          </Button>
        </FormActions>
      </form>
    </div>
  );
};

function getGradeOptions(level: string) {
  const maxGrades = {
    primaria: 6,
    secundaria: 3,
    preparatoria: 3
  };

  const max = maxGrades[level] || 6;

  return Array.from({ length: max }, (_, i) => ({
    value: i + 1,
    label: `${i + 1}°`
  }));
}

Diseño UI/UX

Lista de Aulas

+-------------------------------------------------------------------+
|  Mis Aulas                                    [+ Crear Aula]     |
+-------------------------------------------------------------------+
|  +------------------+  +------------------+  +------------------+ |
|  | Matemáticas 6A   |  | Español 5B       |  | Ciencias 6A      | |
|  | Primaria - 6°    |  | Primaria - 5°    |  | Primaria - 6°    | |
|  |                  |  |                  |  |                  | |
|  | 👥 25 estudiantes|  | 👥 30 estudiantes|  | 👥 28 estudiantes| |
|  | 📚 5 módulos     |  | 📚 4 módulos     |  | 📚 6 módulos     | |
|  |                  |  |                  |  |                  | |
|  | 2024-2025        |  | 2024-2025        |  | 2024-2025        | |
|  | Creada: 1 Oct    |  | Creada: 1 Oct    |  | Creada: 1 Oct    | |
|  |                  |  |                  |  |                  | |
|  | [Ver][Editar][×] |  | [Ver][Editar][×] |  | [Ver][Editar][×] | |
|  +------------------+  +------------------+  +------------------+ |
+-------------------------------------------------------------------+

Alcance Básico vs Extensiones

EAI-005 (Este alcance - Admin Base):

  • CRUD básico completo (crear, listar, editar, eliminar)
  • Campos básicos de aula
  • Hard delete (eliminación permanente)
  • Límite de 20 aulas por profesor
  • Validaciones básicas

EXT-001 (Extensión futura - Portal Maestros Completo):

  • Soft delete y archivado de aulas
  • Templates de aula (crear desde plantilla)
  • Clonación de aulas (duplicar configuración)
  • Campos adicionales (horario, aula física, color)
  • Co-profesores (múltiples profesores por aula)
  • Importación masiva desde CSV
  • Estadísticas avanzadas por aula

Dependencias

Dependencias Técnicas:

  • Backend: Sistema de autenticación de profesores
  • Backend: Guard de TeacherGuard
  • Frontend: React Hook Form
  • Frontend: Modal/Dialog component

Dependencias de User Stories:

  • Ninguna (base de la plataforma de maestro)

Pruebas

Pruebas Unitarias:

  • Validación de límite de 20 aulas
  • Validación de campos requeridos
  • Cálculo de studentCount y moduleCount

Pruebas de Integración:

  • Crear aula asocia correctamente al profesor
  • Listar solo retorna aulas del profesor autenticado
  • Editar solo permite al profesor propietario
  • Eliminar solo permite al profesor propietario
  • Eliminar aula remueve relaciones en cascada

Pruebas E2E:

  • Profesor puede crear aula completa
  • Profesor ve solo sus aulas
  • Profesor puede editar su aula
  • Profesor puede eliminar su aula
  • Validación de límite funciona

Notas de Implementación

  1. Seguridad:

    • Validar en backend que el profesor solo accede a sus aulas
    • Guard de TeacherGuard en todos los endpoints
  2. Escalabilidad:

    • Límite de 20 aulas puede incrementarse en EXT-001
    • Considerar índice en (teacherId, createdAt) para queries rápidos
  3. UX:

    • Mensajes claros de confirmación
    • Skeleton loaders durante carga
    • Empty state motivador para crear primera aula

Estimación de Esfuerzo

Backend: 3 SP

  • CRUD endpoints
  • Validaciones
  • Service con lógica de negocio

Frontend: 4 SP

  • Lista de aulas
  • Formulario crear/editar
  • Modal de confirmación

Testing: 1 SP

Total: 8 SP = $3,200 MXN