workspace/projects/gamilit/docs/01-fase-alcance-inicial/EAI-005-admin-base/historias-usuario/US-ADM-005-gestion-grupos.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

15 KiB

US-ADM-005: Gestión de Grupos Básica

Épica: EAI-005 (Plataforma de Maestro Básica) Sprint: Mes 1, Semana 3 Story Points: 7 SP Presupuesto: $2,800 MXN Prioridad: Alta (Alcance Inicial) Estado: Completada (Mes 1)


Descripción

Como profesor, quiero crear grupos dentro de mi aula y asignar estudiantes a esos grupos para organizar mejor mi clase y facilitar actividades colaborativas.

Contexto del Alcance Inicial:

Esta funcionalidad permite crear grupos simples dentro de un aula y asignar estudiantes manualmente. NO incluye asignación automática de grupos, grupos dinámicos, rotación de grupos, ni analytics específicos de grupo (eso va a EXT-001 Portal de Maestros Completo).


Criterios de Aceptación

CA-01: Crear Grupo

  • Formulario con campos:
    • Nombre del grupo (requerido, ej: "Equipo A", "Grupo 1")
    • Color identificador (opcional, selector de colores predefinidos)
  • Validación: nombre único dentro del aula
  • Confirmación al crear

CA-02: Listar Grupos del Aula

  • Vista de lista/grid con todos los grupos del aula
  • Para cada grupo muestra:
    • Nombre y color
    • de estudiantes asignados

    • Lista de estudiantes (avatares)
    • Acciones (editar, eliminar)

CA-03: Asignar Estudiantes a Grupo

  • Modal de asignación con:
    • Lista de estudiantes disponibles (no asignados o de otros grupos)
    • Checkbox para selección múltiple
    • Botón "Asignar"
  • Un estudiante puede estar en múltiples grupos
  • Actualización inmediata

CA-04: Remover Estudiante de Grupo

  • Botón de remover en cada estudiante del grupo
  • Confirmación simple
  • Solo remueve del grupo, no del aula

CA-05: Editar/Eliminar Grupo

  • Editar nombre y color del grupo
  • Eliminar grupo con confirmación
  • Al eliminar grupo, estudiantes se mantienen en el aula (solo se elimina el grupo)

CA-06: Sin Límites

  • Sin límite de grupos por aula en alcance inicial
  • Sin límite de estudiantes por grupo

Especificaciones Técnicas

Backend

Endpoints:

// Listar grupos del aula
GET /api/teacher/classrooms/{classroomId}/groups

// Crear grupo
POST /api/teacher/classrooms/{classroomId}/groups
Body: { name: string; color?: string }

// Actualizar grupo
PATCH /api/teacher/classrooms/{classroomId}/groups/{groupId}
Body: { name?: string; color?: string }

// Eliminar grupo
DELETE /api/teacher/classrooms/{classroomId}/groups/{groupId}

// Asignar estudiantes a grupo
POST /api/teacher/classrooms/{classroomId}/groups/{groupId}/students
Body: { studentIds: string[] }

// Remover estudiante de grupo
DELETE /api/teacher/classrooms/{classroomId}/groups/{groupId}/students/{studentId}

Response de Listar:

{
  "classroomId": "uuid",
  "groups": [
    {
      "id": "group-uuid",
      "name": "Equipo A",
      "color": "#3b82f6",
      "studentCount": 5,
      "students": [
        {
          "id": "student-uuid",
          "name": "Juan Pérez",
          "avatarUrl": "/avatars/student.png"
        }
      ],
      "createdAt": "2025-11-01T10:00:00Z"
    }
  ],
  "total": 4
}

Controller:

@Controller('teacher/classrooms/:classroomId/groups')
@UseGuards(AuthGuard, TeacherGuard)
export class ClassroomGroupController {
  @Get()
  async getGroups(
    @Param('classroomId') classroomId: string,
    @CurrentUser() teacher: User
  ) {
    return this.groupService.getGroups(classroomId, teacher.id);
  }

  @Post()
  async createGroup(
    @Param('classroomId') classroomId: string,
    @Body() dto: CreateGroupDto,
    @CurrentUser() teacher: User
  ) {
    return this.groupService.createGroup(classroomId, dto, teacher.id);
  }

  @Patch(':groupId')
  async updateGroup(
    @Param('classroomId') classroomId: string,
    @Param('groupId') groupId: string,
    @Body() dto: UpdateGroupDto,
    @CurrentUser() teacher: User
  ) {
    return this.groupService.updateGroup(classroomId, groupId, dto, teacher.id);
  }

  @Delete(':groupId')
  async deleteGroup(
    @Param('classroomId') classroomId: string,
    @Param('groupId') groupId: string,
    @CurrentUser() teacher: User
  ) {
    await this.groupService.deleteGroup(classroomId, groupId, teacher.id);
    return { message: 'Grupo eliminado exitosamente' };
  }

  @Post(':groupId/students')
  async assignStudents(
    @Param('classroomId') classroomId: string,
    @Param('groupId') groupId: string,
    @Body() dto: AssignStudentsDto,
    @CurrentUser() teacher: User
  ) {
    await this.groupService.assignStudents(
      classroomId,
      groupId,
      dto.studentIds,
      teacher.id
    );
    return { message: 'Estudiantes asignados al grupo' };
  }

  @Delete(':groupId/students/:studentId')
  async removeStudent(
    @Param('classroomId') classroomId: string,
    @Param('groupId') groupId: string,
    @Param('studentId') studentId: string,
    @CurrentUser() teacher: User
  ) {
    await this.groupService.removeStudentFromGroup(
      classroomId,
      groupId,
      studentId,
      teacher.id
    );
    return { message: 'Estudiante removido del grupo' };
  }
}

DTOs:

export class CreateGroupDto {
  @IsString()
  @IsNotEmpty()
  @MaxLength(50)
  name: string;

  @IsString()
  @IsOptional()
  @Matches(/^#[0-9A-Fa-f]{6}$/)
  color?: string;
}

export class UpdateGroupDto {
  @IsString()
  @IsOptional()
  @MaxLength(50)
  name?: string;

  @IsString()
  @IsOptional()
  @Matches(/^#[0-9A-Fa-f]{6}$/)
  color?: string;
}

export class AssignStudentsDto {
  @IsArray()
  @IsUUID('4', { each: true })
  studentIds: string[];
}

Service:

async getGroups(classroomId: string, teacherId: string) {
  await this.validateTeacherAccess(classroomId, teacherId);

  const groups = await this.groupRepository.find({
    where: { classroomId },
    relations: ['students'],
    order: { createdAt: 'DESC' }
  });

  return {
    classroomId,
    groups: groups.map(g => ({
      id: g.id,
      name: g.name,
      color: g.color,
      studentCount: g.students.length,
      students: g.students.map(s => ({
        id: s.id,
        name: s.name,
        avatarUrl: s.avatarUrl
      })),
      createdAt: g.createdAt
    })),
    total: groups.length
  };
}

async createGroup(classroomId: string, dto: CreateGroupDto, teacherId: string) {
  await this.validateTeacherAccess(classroomId, teacherId);

  // Validar nombre único en el aula
  const existing = await this.groupRepository.findOne({
    where: { classroomId, name: dto.name }
  });

  if (existing) {
    throw new BadRequestException('Ya existe un grupo con este nombre en el aula');
  }

  const group = this.groupRepository.create({
    ...dto,
    classroomId,
    color: dto.color || this.getRandomColor()
  });

  return this.groupRepository.save(group);
}

async assignStudents(
  classroomId: string,
  groupId: string,
  studentIds: string[],
  teacherId: string
) {
  await this.validateTeacherAccess(classroomId, teacherId);

  const group = await this.groupRepository.findOne({
    where: { id: groupId, classroomId },
    relations: ['students']
  });

  if (!group) {
    throw new NotFoundException('Grupo no encontrado');
  }

  // Validar que los estudiantes están en el aula
  const classroomStudents = await this.classroomService.getStudents(classroomId);
  const validStudentIds = classroomStudents.map(s => s.id);

  const invalidIds = studentIds.filter(id => !validStudentIds.includes(id));
  if (invalidIds.length > 0) {
    throw new BadRequestException('Algunos estudiantes no pertenecen al aula');
  }

  // Agregar estudiantes al grupo (permite duplicados si ya están)
  const studentsToAdd = await this.studentRepository.findByIds(studentIds);

  // Evitar duplicados
  const existingIds = group.students.map(s => s.id);
  const newStudents = studentsToAdd.filter(s => !existingIds.includes(s.id));

  group.students.push(...newStudents);
  await this.groupRepository.save(group);
}

private getRandomColor(): string {
  const colors = [
    '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
    '#ec4899', '#06b6d4', '#84cc16'
  ];
  return colors[Math.floor(Math.random() * colors.length)];
}

Modelo:

@Entity('groups')
export class Group {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column()
  name: string;

  @Column({ default: '#3b82f6' })
  color: string;

  @Column({ name: 'classroom_id' })
  classroomId: string;

  @ManyToOne(() => Classroom)
  @JoinColumn({ name: 'classroom_id' })
  classroom: Classroom;

  @ManyToMany(() => Student)
  @JoinTable({ name: 'group_students' })
  students: Student[];

  @CreateDateColumn({ name: 'created_at' })
  createdAt: Date;
}

Frontend

Ruta:

/teacher/classroom/:classroomId/groups

Vista Principal:

// ClassroomGroupsView.tsx
export const ClassroomGroupsView = () => {
  const { classroomId } = useParams();
  const { groups, isLoading, refetch } = useClassroomGroups(classroomId);
  const [showCreateModal, setShowCreateModal] = useState(false);
  const [editingGroup, setEditingGroup] = useState(null);

  const handleDelete = async (groupId: string, groupName: string) => {
    const confirmed = await confirm({
      title: 'Eliminar Grupo',
      message: `¿Eliminar "${groupName}"? Los estudiantes permanecerán en el aula.`,
      confirmText: 'Eliminar'
    });

    if (confirmed) {
      await deleteGroup(classroomId, groupId);
      toast.success('Grupo eliminado');
      refetch();
    }
  };

  return (
    <div className="classroom-groups-container">
      <PageHeader
        title="Grupos del Aula"
        subtitle={`${groups.length} grupo${groups.length !== 1 ? 's' : ''}`}
        action={
          <Button
            onClick={() => setShowCreateModal(true)}
            leftIcon={<PlusIcon />}
          >
            Crear Grupo
          </Button>
        }
      />

      {groups.length === 0 ? (
        <EmptyState
          icon={<GroupIcon />}
          title="No hay grupos creados"
          description="Crea grupos para organizar a tus estudiantes"
          action={
            <Button onClick={() => setShowCreateModal(true)}>
              Crear Primer Grupo
            </Button>
          }
        />
      ) : (
        <GroupsGrid
          groups={groups}
          onEdit={setEditingGroup}
          onDelete={handleDelete}
          onAssignStudents={(groupId) => {
            // Abrir modal de asignación
          }}
        />
      )}

      {showCreateModal && (
        <CreateGroupModal
          classroomId={classroomId}
          onClose={() => setShowCreateModal(false)}
          onSuccess={refetch}
        />
      )}

      {editingGroup && (
        <EditGroupModal
          classroomId={classroomId}
          group={editingGroup}
          onClose={() => setEditingGroup(null)}
          onSuccess={refetch}
        />
      )}
    </div>
  );
};

Card de Grupo:

// GroupCard.tsx
export const GroupCard = ({ group, onEdit, onDelete, onAssignStudents }) => {
  return (
    <Card className="group-card" style={{ borderLeft: `4px solid ${group.color}` }}>
      <CardHeader>
        <div className="group-header">
          <ColorBadge color={group.color} />
          <h3>{group.name}</h3>
        </div>
        <div className="group-actions">
          <IconButton icon={<EditIcon />} onClick={() => onEdit(group)} />
          <IconButton icon={<DeleteIcon />} onClick={() => onDelete(group.id, group.name)} />
        </div>
      </CardHeader>

      <CardBody>
        <div className="student-count">
          {group.studentCount} estudiante{group.studentCount !== 1 ? 's' : ''}
        </div>

        {group.students.length > 0 ? (
          <div className="student-avatars">
            {group.students.slice(0, 5).map(student => (
              <Avatar
                key={student.id}
                src={student.avatarUrl}
                alt={student.name}
                size="sm"
                tooltip={student.name}
              />
            ))}
            {group.students.length > 5 && (
              <span className="more-count">+{group.students.length - 5}</span>
            )}
          </div>
        ) : (
          <EmptyState message="Sin estudiantes" size="sm" />
        )}
      </CardBody>

      <CardFooter>
        <Button
          variant="outline"
          size="sm"
          onClick={() => onAssignStudents(group.id)}
          fullWidth
        >
          Asignar Estudiantes
        </Button>
      </CardFooter>
    </Card>
  );
};

Modal de Crear/Editar:

// GroupFormModal.tsx
export const GroupFormModal = ({ classroomId, group, onClose, onSuccess }) => {
  const isEdit = !!group;
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting }
  } = useForm({
    defaultValues: group || { name: '', color: '#3b82f6' }
  });

  const onSubmit = async (data) => {
    try {
      if (isEdit) {
        await updateGroup(classroomId, group.id, data);
        toast.success('Grupo actualizado');
      } else {
        await createGroup(classroomId, data);
        toast.success('Grupo creado');
      }
      onSuccess();
      onClose();
    } catch (error) {
      toast.error(error.message);
    }
  };

  return (
    <Modal isOpen onClose={onClose}>
      <ModalHeader>
        <h2>{isEdit ? 'Editar Grupo' : 'Crear Grupo'}</h2>
      </ModalHeader>

      <ModalBody>
        <form onSubmit={handleSubmit(onSubmit)}>
          <FormField label="Nombre del Grupo" required error={errors.name?.message}>
            <Input
              {...register('name', {
                required: 'El nombre es requerido',
                maxLength: { value: 50, message: 'Máximo 50 caracteres' }
              })}
              placeholder="Ej: Equipo A"
            />
          </FormField>

          <FormField label="Color">
            <ColorPicker {...register('color')} />
          </FormField>

          <FormActions>
            <Button type="button" variant="outline" onClick={onClose}>
              Cancelar
            </Button>
            <Button type="submit" disabled={isSubmitting}>
              {isSubmitting ? 'Guardando...' : (isEdit ? 'Actualizar' : 'Crear')}
            </Button>
          </FormActions>
        </form>
      </ModalBody>
    </Modal>
  );
};

Alcance Básico vs Extensiones

EAI-005 (Este alcance - Admin Base):

  • CRUD de grupos básico
  • Asignación manual de estudiantes
  • Estudiante puede estar en múltiples grupos
  • Color identificador simple
  • Sin límites

EXT-001 (Extensión futura):

  • Asignación automática de grupos (aleatoria, por nivel)
  • Grupos dinámicos (basados en progreso)
  • Rotación automática de grupos
  • Analytics por grupo
  • Asignar actividades específicas a grupos
  • Grupos exclusivos (estudiante en solo un grupo)

Estimación de Esfuerzo

Backend: 3 SP Frontend: 3 SP Testing: 1 SP

Total: 7 SP = $2,800 MXN