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