workspace/projects/gamilit/docs/90-transversal/archivos-historicos/2025-11/DESARROLLO-TEACHER-PORTAL-COMPLETO-2025-11-24.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

38 KiB

DESARROLLO COMPLETO: PORTAL TEACHER

Fecha: 2025-11-24 Tipo: Feature Development - Full Portal Estado: COMPLETADO Versión: 1.0.0 Análisis ID: TEACHER-PORTAL-001 Trazas: BE-133, FE-104


1. RESUMEN EJECUTIVO

Se completó exitosamente el desarrollo del Portal Teacher mediante análisis exhaustivo, planeación detallada y ejecución orquestada con múltiples agentes. El portal permite a los maestros visualizar datos reales de actividad estudiantil generados automáticamente por triggers de base de datos.

Métricas Finales

Métrica Valor
Páginas Analizadas 13
Páginas Desarrolladas 9
Páginas Descartadas 3
Página Nueva Creada 1
Páginas Acotadas 3
Endpoints Backend Creados 4
Componentes Frontend Nuevos 15+
Hooks Nuevos 1
Servicios API Nuevos 1
Errores TypeScript 0

2. METODOLOGÍA: 3 FASES OBLIGATORIAS

2.1 FASE 1: Análisis (Completada)

Objetivo: Identificar páginas viables basadas en disponibilidad de datos.

Criterios de Viabilidad:

  1. La página debe consumir datos de tablas de BD
  2. Esas tablas deben actualizarse cuando el estudiante usa la plataforma
  3. Deben existir triggers o procesos automáticos que mantengan los datos actualizados

Resultado del Análisis:

┌─────────────────────────────────────────────────────────────────┐
│                    CLASIFICACIÓN DE PÁGINAS                      │
├─────────────────────────────────────────────────────────────────┤
│  VIABLES (9):                                                    │
│  ✅ TeacherDashboardPage    - exercise_attempts, user_stats      │
│  ✅ TeacherProgressPage     - module_progress, exercise_attempts │
│  ✅ TeacherStudentsPage     - profiles, user_stats, classroom    │
│  ✅ TeacherAnalyticsPage    - submissions, progress (acotada)    │
│  ✅ TeacherAlertsPage       - student_intervention_alerts        │
│  ✅ TeacherMonitoringPage   - user_stats (last_activity_at)      │
│  ✅ TeacherClassesPage      - classrooms, classroom_members      │
│  ✅ TeacherAssignmentsPage  - assignments, submissions           │
│  ✅ TeacherGamificationPage - user_stats, user_ranks (acotada)   │
├─────────────────────────────────────────────────────────────────┤
│  DESCARTADAS (3):                                                │
│  ❌ TeacherResourcesPage     - Sin tablas BD, placeholder        │
│  ❌ TeacherCommunicationPage - No depende de actividad Student   │
│  ❌ TeacherContentPage       - Entrada de datos, no visualización│
├─────────────────────────────────────────────────────────────────┤
│  NUEVA A CREAR (1):                                              │
│  🆕 TeacherExerciseResponsesPage - exercise_attempts disponible  │
├─────────────────────────────────────────────────────────────────┤
│  ACOTADAS (3):                                                   │
│  ⚠️ TeacherReportsPage      - Sin ML predictions                 │
│  ⚠️ TeacherAnalyticsPage    - Sin insights ML                    │
│  ⚠️ TeacherGamificationPage - Sin config rewards                 │
└─────────────────────────────────────────────────────────────────┘

2.2 FASE 2: Planeación (Completada)

Objetivo: Crear plan de desarrollo priorizado con tareas específicas.

Plan de Ejecución:

Grupo Tipo Tareas Agentes
1 Paralelo Backend endpoints + Dashboard 2
2 Secuencial Nueva página Respuestas 1
3 Paralelo Progress, Students, Analytics 3
4-5 Paralelo Monitoring, Reports, Gamification, Assignments 4

2.3 FASE 3: Ejecución (Completada)

Objetivo: Implementar desarrollo mediante orquestación de agentes.

Ejecución por Grupos:

Grupo Estado Resultado
GRUPO 1 4 endpoints + Dashboard conectado
GRUPO 2 TeacherExerciseResponsesPage completa
GRUPO 3 3 páginas mejoradas
GRUPO 4-5 4 páginas completadas/acotadas

3. ARQUITECTURA DE DATOS

3.1 Flujo de Datos: Student → Teacher

┌────────────────────────────────────────────────────────────────────┐
│                         PORTAL STUDENT                              │
│                                                                     │
│   [Estudiante responde ejercicio]                                  │
│            │                                                        │
│            ▼                                                        │
│   ┌──────────────────┐                                              │
│   │ ExerciseService  │ → POST /progress/exercises/:id/submit       │
│   └────────┬─────────┘                                              │
└────────────┼────────────────────────────────────────────────────────┘
             │
             ▼
┌────────────────────────────────────────────────────────────────────┐
│                         BASE DE DATOS                               │
│                                                                     │
│   ┌─────────────────────────┐                                       │
│   │   exercise_attempts     │ ◄── INSERT (respuesta del student)   │
│   └───────────┬─────────────┘                                       │
│               │                                                     │
│               ▼ TRIGGER: trg_update_user_stats                     │
│   ┌─────────────────────────┐                                       │
│   │      user_stats         │ ◄── UPDATE (XP, ML Coins, streaks)   │
│   └───────────┬─────────────┘                                       │
│               │                                                     │
│               ├──▶ TRIGGER: trg_update_module_progress              │
│               │    ┌─────────────────────────┐                      │
│               │    │    module_progress      │ ◄── UPDATE          │
│               │    └─────────────────────────┘                      │
│               │                                                     │
│               ├──▶ TRIGGER: trg_check_rank_promotion                │
│               │    ┌─────────────────────────┐                      │
│               │    │      user_ranks         │ ◄── UPDATE/INSERT   │
│               │    └─────────────────────────┘                      │
│               │                                                     │
│               └──▶ TRIGGER: trg_generate_alerts                     │
│                    ┌─────────────────────────────────────┐          │
│                    │  student_intervention_alerts        │ ◄── INS  │
│                    └─────────────────────────────────────┘          │
└────────────────────────────────────────────────────────────────────┘
             │
             ▼
┌────────────────────────────────────────────────────────────────────┐
│                         PORTAL TEACHER                              │
│                                                                     │
│   Dashboard    → Resumen actividad (attempts, alerts, progress)    │
│   Progress     → Progreso por módulo (module_progress)             │
│   Students     → Lista estudiantes (profiles, user_stats)          │
│   Analytics    → Métricas (submissions, attempts agregados)        │
│   Alerts       → Alertas intervención (student_intervention_alerts)│
│   Monitoring   → Actividad en tiempo real (user_stats.last_activity)|
│   Responses    → Respuestas ejercicios (exercise_attempts)         │
│   Gamification → XP, Ranks, ML Coins (user_stats, user_ranks)      │
└────────────────────────────────────────────────────────────────────┘

3.2 Tablas de Base de Datos Utilizadas

Tabla Schema Actualizada por Student Trigger
exercise_attempts progress_tracking Directamente -
exercise_submissions progress_tracking Directamente -
module_progress progress_tracking Via trigger trg_update_module_progress
student_intervention_alerts progress_tracking Via trigger trg_generate_alerts
user_stats gamification_system Via trigger trg_update_user_stats
user_ranks gamification_system Via trigger trg_check_rank_promotion
classrooms social_features CRUD Teacher -
classroom_members social_features Admin/Teacher -
profiles auth_management Auth flow -
exercises educational_content Admin CRUD -

3.3 RLS (Row Level Security)

El Teacher solo puede ver datos de estudiantes en sus classrooms:

-- Política RLS para exercise_attempts
CREATE POLICY teacher_view_classroom_attempts ON progress_tracking.exercise_attempts
    FOR SELECT TO authenticated
    USING (
        user_id IN (
            SELECT cm.user_id
            FROM social_features.classroom_members cm
            JOIN social_features.teacher_classrooms tc
                ON tc.classroom_id = cm.classroom_id
            WHERE tc.teacher_id = auth.uid()
        )
    );

4. IMPLEMENTACIÓN BACKEND

4.1 Archivos Creados

apps/backend/src/modules/teacher/
├── controllers/
│   └── exercise-responses.controller.ts    # 4 endpoints REST
├── services/
│   └── exercise-responses.service.ts       # Lógica de negocio
└── dto/
    └── exercise-responses.dto.ts           # DTOs tipados

4.2 Endpoints Implementados

Método Ruta Descripción Query Params
GET /teacher/attempts Listado con filtros classroom_id, student_id, module_id, date_from, date_to, is_correct, page, limit
GET /teacher/attempts/:id Detalle de intento -
GET /teacher/attempts/student/:studentId Por estudiante page, limit
GET /teacher/exercises/:exerciseId/responses Por ejercicio page, limit

4.3 DTOs Definidos

// exercise-responses.dto.ts

// Query DTO
export class GetAttemptsQueryDto {
  @IsOptional() @IsUUID() classroom_id?: string;
  @IsOptional() @IsUUID() student_id?: string;
  @IsOptional() @IsUUID() module_id?: string;
  @IsOptional() @IsDateString() date_from?: string;
  @IsOptional() @IsDateString() date_to?: string;
  @IsOptional() @IsBoolean() is_correct?: boolean;
  @IsOptional() @IsInt() @Min(1) page?: number = 1;
  @IsOptional() @IsInt() @Min(1) @Max(100) limit?: number = 20;
}

// Response DTOs
export class AttemptResponseDto {
  id: string;
  user_id: string;
  student_name: string;
  exercise_id: string;
  exercise_title: string;
  module_id: string;
  module_name: string;
  attempt_number: number;
  is_correct: boolean;
  score: number;
  max_score: number;
  time_spent_seconds: number;
  hints_used: number;
  comodines_used: string[];
  xp_earned: number;
  ml_coins_earned: number;
  submitted_at: Date;
}

export class AttemptDetailDto extends AttemptResponseDto {
  submitted_answers: Record<string, any>;
  correct_answers: Record<string, any>;
  exercise_content: Record<string, any>;
  feedback: string;
}

export class AttemptsListResponseDto {
  data: AttemptResponseDto[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

4.4 Service Implementation

// exercise-responses.service.ts

@Injectable()
export class ExerciseResponsesService {
  constructor(
    @InjectRepository(ExerciseAttempt)
    private attemptRepo: Repository<ExerciseAttempt>,
    @InjectRepository(Exercise)
    private exerciseRepo: Repository<Exercise>,
  ) {}

  async getAttempts(
    teacherId: string,
    tenantId: string,
    query: GetAttemptsQueryDto,
  ): Promise<AttemptsListResponseDto> {
    // 1. Obtener classroom IDs del teacher (RLS)
    const classroomIds = await this.getTeacherClassrooms(teacherId);

    // 2. Construir query con filtros
    const qb = this.attemptRepo
      .createQueryBuilder('attempt')
      .innerJoin('attempt.user', 'user')
      .innerJoin('attempt.exercise', 'exercise')
      .innerJoin('exercise.module', 'module')
      .innerJoin('social_features.classroom_members', 'cm', 'cm.user_id = attempt.user_id')
      .where('cm.classroom_id IN (:...classroomIds)', { classroomIds });

    // 3. Aplicar filtros opcionales
    if (query.classroom_id) {
      qb.andWhere('cm.classroom_id = :classroomId', { classroomId: query.classroom_id });
    }
    if (query.student_id) {
      qb.andWhere('attempt.user_id = :studentId', { studentId: query.student_id });
    }
    if (query.module_id) {
      qb.andWhere('exercise.module_id = :moduleId', { moduleId: query.module_id });
    }
    if (query.date_from) {
      qb.andWhere('attempt.submitted_at >= :dateFrom', { dateFrom: query.date_from });
    }
    if (query.date_to) {
      qb.andWhere('attempt.submitted_at <= :dateTo', { dateTo: query.date_to });
    }
    if (query.is_correct !== undefined) {
      qb.andWhere('attempt.is_correct = :isCorrect', { isCorrect: query.is_correct });
    }

    // 4. Paginación y ordenamiento
    const [data, total] = await qb
      .orderBy('attempt.submitted_at', 'DESC')
      .skip((query.page - 1) * query.limit)
      .take(query.limit)
      .getManyAndCount();

    return {
      data: data.map(this.toResponseDto),
      total,
      page: query.page,
      limit: query.limit,
      totalPages: Math.ceil(total / query.limit),
    };
  }

  async getAttemptDetail(
    id: string,
    teacherId: string,
    tenantId: string,
  ): Promise<AttemptDetailDto> {
    const attempt = await this.attemptRepo.findOne({
      where: { id },
      relations: ['exercise', 'exercise.module', 'user'],
    });

    // Validar RLS: teacher tiene acceso a este estudiante
    await this.validateTeacherAccess(teacherId, attempt.user_id);

    return {
      ...this.toResponseDto(attempt),
      submitted_answers: attempt.submitted_answers,
      correct_answers: attempt.exercise.content.correct_answers,
      exercise_content: attempt.exercise.content,
      feedback: attempt.feedback,
    };
  }
}

4.5 Controller Implementation

// exercise-responses.controller.ts

@ApiTags('Teacher - Exercise Responses')
@Controller('teacher')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('teacher', 'admin')
export class ExerciseResponsesController {
  constructor(private readonly service: ExerciseResponsesService) {}

  @Get('attempts')
  @ApiOperation({ summary: 'Get exercise attempts with filters' })
  @ApiQuery({ name: 'classroom_id', required: false })
  @ApiQuery({ name: 'student_id', required: false })
  @ApiQuery({ name: 'module_id', required: false })
  @ApiQuery({ name: 'date_from', required: false })
  @ApiQuery({ name: 'date_to', required: false })
  @ApiQuery({ name: 'is_correct', required: false })
  @ApiQuery({ name: 'page', required: false, default: 1 })
  @ApiQuery({ name: 'limit', required: false, default: 20 })
  async getAttempts(
    @CurrentUser() user: JwtPayload,
    @Query() query: GetAttemptsQueryDto,
  ): Promise<AttemptsListResponseDto> {
    return this.service.getAttempts(user.sub, user.tenant_id, query);
  }

  @Get('attempts/:id')
  @ApiOperation({ summary: 'Get attempt detail with answers comparison' })
  async getAttemptDetail(
    @CurrentUser() user: JwtPayload,
    @Param('id', ParseUUIDPipe) id: string,
  ): Promise<AttemptDetailDto> {
    return this.service.getAttemptDetail(id, user.sub, user.tenant_id);
  }

  @Get('attempts/student/:studentId')
  @ApiOperation({ summary: 'Get all attempts by student' })
  async getAttemptsByStudent(
    @CurrentUser() user: JwtPayload,
    @Param('studentId', ParseUUIDPipe) studentId: string,
    @Query() query: PaginationQueryDto,
  ): Promise<AttemptsListResponseDto> {
    return this.service.getAttemptsByStudent(studentId, user.sub, user.tenant_id, query);
  }

  @Get('exercises/:exerciseId/responses')
  @ApiOperation({ summary: 'Get all responses for an exercise' })
  async getExerciseResponses(
    @CurrentUser() user: JwtPayload,
    @Param('exerciseId', ParseUUIDPipe) exerciseId: string,
    @Query() query: PaginationQueryDto,
  ): Promise<AttemptsListResponseDto> {
    return this.service.getExerciseResponses(exerciseId, user.sub, user.tenant_id, query);
  }
}

5. IMPLEMENTACIÓN FRONTEND

5.1 Archivos Creados - Nueva Página

apps/frontend/src/apps/teacher/
├── pages/
│   └── TeacherExerciseResponsesPage.tsx    # Página principal
├── components/responses/
│   ├── ResponsesTable.tsx                   # DataTable con respuestas
│   ├── ResponseDetailModal.tsx              # Modal de detalle
│   └── ResponseFilters.tsx                  # Filtros avanzados
├── hooks/
│   └── useExerciseResponses.ts              # React Query hooks
└── services/
    └── exerciseResponsesApi.ts              # Servicio API

5.2 API Service

// exerciseResponsesApi.ts

import { apiClient } from '@/services/api/apiClient';

export interface AttemptsQuery {
  classroom_id?: string;
  student_id?: string;
  module_id?: string;
  date_from?: string;
  date_to?: string;
  is_correct?: boolean;
  page?: number;
  limit?: number;
}

export interface AttemptResponse {
  id: string;
  user_id: string;
  student_name: string;
  exercise_id: string;
  exercise_title: string;
  module_id: string;
  module_name: string;
  attempt_number: number;
  is_correct: boolean;
  score: number;
  max_score: number;
  time_spent_seconds: number;
  hints_used: number;
  comodines_used: string[];
  xp_earned: number;
  ml_coins_earned: number;
  submitted_at: string;
}

export interface AttemptDetail extends AttemptResponse {
  submitted_answers: Record<string, any>;
  correct_answers: Record<string, any>;
  exercise_content: Record<string, any>;
  feedback: string;
}

export interface AttemptsListResponse {
  data: AttemptResponse[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

export const exerciseResponsesApi = {
  getAttempts: async (query: AttemptsQuery): Promise<AttemptsListResponse> => {
    const params = new URLSearchParams();
    Object.entries(query).forEach(([key, value]) => {
      if (value !== undefined && value !== null) {
        params.append(key, String(value));
      }
    });
    const response = await apiClient.get(`/teacher/attempts?${params}`);
    return response.data;
  },

  getAttemptDetail: async (id: string): Promise<AttemptDetail> => {
    const response = await apiClient.get(`/teacher/attempts/${id}`);
    return response.data;
  },

  getAttemptsByStudent: async (
    studentId: string,
    page = 1,
    limit = 20
  ): Promise<AttemptsListResponse> => {
    const response = await apiClient.get(
      `/teacher/attempts/student/${studentId}?page=${page}&limit=${limit}`
    );
    return response.data;
  },

  getExerciseResponses: async (
    exerciseId: string,
    query: AttemptsQuery
  ): Promise<AttemptsListResponse> => {
    const params = new URLSearchParams();
    Object.entries(query).forEach(([key, value]) => {
      if (value !== undefined && value !== null) {
        params.append(key, String(value));
      }
    });
    const response = await apiClient.get(
      `/teacher/exercises/${exerciseId}/responses?${params}`
    );
    return response.data;
  },
};

5.3 React Query Hooks

// useExerciseResponses.ts

import { useQuery } from '@tanstack/react-query';
import { exerciseResponsesApi, AttemptsQuery, AttemptsListResponse, AttemptDetail } from '../services/exerciseResponsesApi';

export const useExerciseResponses = (query: AttemptsQuery) => {
  return useQuery<AttemptsListResponse>({
    queryKey: ['exercise-responses', query],
    queryFn: () => exerciseResponsesApi.getAttempts(query),
    keepPreviousData: true,
    staleTime: 30000, // 30 seconds
  });
};

export const useAttemptDetail = (id: string | null) => {
  return useQuery<AttemptDetail>({
    queryKey: ['attempt-detail', id],
    queryFn: () => exerciseResponsesApi.getAttemptDetail(id!),
    enabled: !!id,
  });
};

export const useAttemptsByStudent = (studentId: string, page = 1, limit = 20) => {
  return useQuery<AttemptsListResponse>({
    queryKey: ['attempts-by-student', studentId, page, limit],
    queryFn: () => exerciseResponsesApi.getAttemptsByStudent(studentId, page, limit),
    enabled: !!studentId,
  });
};

5.4 Componentes Principales

ResponsesTable.tsx

// ResponsesTable.tsx

import React from 'react';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Eye, Clock, Target } from 'lucide-react';
import { AttemptResponse } from '../services/exerciseResponsesApi';
import { formatDistanceToNow } from 'date-fns';
import { es } from 'date-fns/locale';

interface ResponsesTableProps {
  data: AttemptResponse[];
  onViewDetail: (id: string) => void;
  isLoading: boolean;
}

export const ResponsesTable: React.FC<ResponsesTableProps> = ({
  data,
  onViewDetail,
  isLoading,
}) => {
  const formatTime = (seconds: number) => {
    const mins = Math.floor(seconds / 60);
    const secs = seconds % 60;
    return `${mins}:${secs.toString().padStart(2, '0')}`;
  };

  if (isLoading) {
    return <TableSkeleton />;
  }

  return (
    <Table>
      <TableHeader>
        <TableRow>
          <TableHead>Estudiante</TableHead>
          <TableHead>Ejercicio</TableHead>
          <TableHead>Módulo</TableHead>
          <TableHead>Resultado</TableHead>
          <TableHead>Puntaje</TableHead>
          <TableHead>Tiempo</TableHead>
          <TableHead>Fecha</TableHead>
          <TableHead>Acciones</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {data.map((attempt) => (
          <TableRow key={attempt.id}>
            <TableCell className="font-medium">
              {attempt.student_name}
            </TableCell>
            <TableCell>{attempt.exercise_title}</TableCell>
            <TableCell>
              <Badge variant="outline">{attempt.module_name}</Badge>
            </TableCell>
            <TableCell>
              <Badge variant={attempt.is_correct ? 'success' : 'destructive'}>
                {attempt.is_correct ? 'Correcto' : 'Incorrecto'}
              </Badge>
            </TableCell>
            <TableCell>
              <div className="flex items-center gap-1">
                <Target className="h-4 w-4 text-muted-foreground" />
                {attempt.score}/{attempt.max_score}
              </div>
            </TableCell>
            <TableCell>
              <div className="flex items-center gap-1">
                <Clock className="h-4 w-4 text-muted-foreground" />
                {formatTime(attempt.time_spent_seconds)}
              </div>
            </TableCell>
            <TableCell>
              {formatDistanceToNow(new Date(attempt.submitted_at), {
                addSuffix: true,
                locale: es,
              })}
            </TableCell>
            <TableCell>
              <Button
                variant="ghost"
                size="sm"
                onClick={() => onViewDetail(attempt.id)}
              >
                <Eye className="h-4 w-4 mr-1" />
                Ver
              </Button>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
};

ResponseDetailModal.tsx

// ResponseDetailModal.tsx

import React from 'react';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { CheckCircle, XCircle, Clock, Lightbulb, Coins } from 'lucide-react';
import { AttemptDetail } from '../services/exerciseResponsesApi';
import { useAttemptDetail } from '../hooks/useExerciseResponses';

interface ResponseDetailModalProps {
  attemptId: string | null;
  isOpen: boolean;
  onClose: () => void;
}

export const ResponseDetailModal: React.FC<ResponseDetailModalProps> = ({
  attemptId,
  isOpen,
  onClose,
}) => {
  const { data: attempt, isLoading } = useAttemptDetail(attemptId);

  if (!attemptId || !isOpen) return null;

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>Detalle de Respuesta</DialogTitle>
        </DialogHeader>

        {isLoading ? (
          <DetailSkeleton />
        ) : attempt ? (
          <div className="space-y-6">
            {/* Header Info */}
            <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
              <InfoCard
                icon={attempt.is_correct ? CheckCircle : XCircle}
                label="Resultado"
                value={attempt.is_correct ? 'Correcto' : 'Incorrecto'}
                variant={attempt.is_correct ? 'success' : 'error'}
              />
              <InfoCard
                icon={Clock}
                label="Tiempo"
                value={formatTime(attempt.time_spent_seconds)}
              />
              <InfoCard
                icon={Lightbulb}
                label="Pistas"
                value={`${attempt.hints_used} usadas`}
              />
              <InfoCard
                icon={Coins}
                label="XP Ganado"
                value={`+${attempt.xp_earned} XP`}
              />
            </div>

            {/* Tabs for Answers */}
            <Tabs defaultValue="comparison">
              <TabsList>
                <TabsTrigger value="comparison">Comparación</TabsTrigger>
                <TabsTrigger value="student">Respuesta Estudiante</TabsTrigger>
                <TabsTrigger value="correct">Respuesta Correcta</TabsTrigger>
              </TabsList>

              <TabsContent value="comparison">
                <AnswerComparison
                  studentAnswer={attempt.submitted_answers}
                  correctAnswer={attempt.correct_answers}
                  exerciseContent={attempt.exercise_content}
                />
              </TabsContent>

              <TabsContent value="student">
                <Card>
                  <CardHeader>
                    <CardTitle>Respuesta del Estudiante</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <pre className="bg-muted p-4 rounded-lg overflow-auto">
                      {JSON.stringify(attempt.submitted_answers, null, 2)}
                    </pre>
                  </CardContent>
                </Card>
              </TabsContent>

              <TabsContent value="correct">
                <Card>
                  <CardHeader>
                    <CardTitle>Respuesta Correcta</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <pre className="bg-muted p-4 rounded-lg overflow-auto">
                      {JSON.stringify(attempt.correct_answers, null, 2)}
                    </pre>
                  </CardContent>
                </Card>
              </TabsContent>
            </Tabs>

            {/* Feedback Section */}
            {attempt.feedback && (
              <Card>
                <CardHeader>
                  <CardTitle>Retroalimentación</CardTitle>
                </CardHeader>
                <CardContent>
                  <p>{attempt.feedback}</p>
                </CardContent>
              </Card>
            )}
          </div>
        ) : null}
      </DialogContent>
    </Dialog>
  );
};

5.5 Componentes de Mejora - Páginas Existentes

SkeletonCard.tsx (Dashboard)

// components/dashboard/SkeletonCard.tsx

import React from 'react';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';

export const SkeletonCard: React.FC = () => {
  return (
    <Card>
      <CardHeader className="pb-2">
        <Skeleton className="h-4 w-24" />
      </CardHeader>
      <CardContent>
        <Skeleton className="h-8 w-16 mb-2" />
        <Skeleton className="h-3 w-32" />
      </CardContent>
    </Card>
  );
};

RefreshControl.tsx (Monitoring)

// components/monitoring/RefreshControl.tsx

import React, { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { RefreshCw, Pause, Play } from 'lucide-react';

interface RefreshControlProps {
  onRefresh: () => void;
  isRefreshing: boolean;
}

const INTERVALS = [
  { value: '0', label: 'Manual' },
  { value: '10', label: '10 segundos' },
  { value: '30', label: '30 segundos' },
  { value: '60', label: '1 minuto' },
  { value: '300', label: '5 minutos' },
];

export const RefreshControl: React.FC<RefreshControlProps> = ({
  onRefresh,
  isRefreshing,
}) => {
  const [interval, setInterval] = useState('30');
  const [isPaused, setIsPaused] = useState(false);
  const [countdown, setCountdown] = useState(30);

  useEffect(() => {
    if (interval === '0' || isPaused) {
      return;
    }

    const intervalMs = parseInt(interval) * 1000;
    const timer = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          onRefresh();
          return parseInt(interval);
        }
        return prev - 1;
      });
    }, 1000);

    return () => clearInterval(timer);
  }, [interval, isPaused, onRefresh]);

  const handleIntervalChange = (value: string) => {
    setInterval(value);
    setCountdown(parseInt(value) || 0);
  };

  return (
    <div className="flex items-center gap-2">
      <Select value={interval} onValueChange={handleIntervalChange}>
        <SelectTrigger className="w-32">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          {INTERVALS.map((opt) => (
            <SelectItem key={opt.value} value={opt.value}>
              {opt.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>

      {interval !== '0' && (
        <>
          <Button
            variant="ghost"
            size="icon"
            onClick={() => setIsPaused(!isPaused)}
          >
            {isPaused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
          </Button>
          <span className="text-sm text-muted-foreground">
            {isPaused ? 'Pausado' : `${countdown}s`}
          </span>
        </>
      )}

      <Button
        variant="outline"
        size="icon"
        onClick={onRefresh}
        disabled={isRefreshing}
      >
        <RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} />
      </Button>
    </div>
  );
};

6. PÁGINAS ACOTADAS

6.1 TeacherReportsPage - Sin ML Predictions

Acotamiento:

  • Reportes de datos existentes únicamente
  • Sin predicciones de ML (MLPredictorService no implementado)
  • Cards informativas sobre alcance futuro

Implementación:

// TeacherReportsPage.tsx - Sección informativa

<Card className="border-amber-200 bg-amber-50">
  <CardHeader>
    <CardTitle className="flex items-center gap-2">
      <AlertTriangle className="h-5 w-5 text-amber-600" />
      Funcionalidad en Desarrollo
    </CardTitle>
  </CardHeader>
  <CardContent>
    <p className="text-amber-800">
      Los reportes con predicciones de Machine Learning estarán disponibles
      en una versión futura. Actualmente se muestran métricas basadas en
      heurísticas simples calculadas a partir de los datos existentes.
    </p>
  </CardContent>
</Card>

6.2 TeacherAnalyticsPage - Sin Insights ML

Acotamiento:

  • Métricas calculadas (promedios, completion rates, distribution)
  • Sin insights predictivos de ML
  • StudentInsightsResponseDto campos simulados removidos

6.3 TeacherGamificationPage - Sin Config Rewards

Acotamiento:

  • Visualización de stats de gamificación
  • Otorgar bonus ML Coins (funcionalidad existente)
  • Sección "Coming Soon" para configuración de rewards

Implementación:

// TeacherGamificationPage.tsx - Coming Soon Section

<div className="grid gap-4">
  {/* Funciones disponibles */}
  <Card className="border-green-200 bg-green-50">
    <CardHeader>
      <CardTitle className="text-green-800">Disponible Ahora</CardTitle>
    </CardHeader>
    <CardContent>
      <ul className="list-disc list-inside text-green-700">
        <li>Ver estadísticas de XP y ML Coins por estudiante</li>
        <li>Ver ranking de estudiantes en la clase</li>
        <li>Otorgar bonus de ML Coins</li>
        <li>Ver logros desbloqueados</li>
      </ul>
    </CardContent>
  </Card>

  {/* Funciones restringidas */}
  <Card className="border-gray-200 bg-gray-50">
    <CardHeader>
      <CardTitle className="text-gray-600">Próximamente</CardTitle>
    </CardHeader>
    <CardContent>
      <ul className="list-disc list-inside text-gray-500">
        <li>Configurar rewards personalizados</li>
        <li>Crear misiones especiales para la clase</li>
        <li>Ajustar multiplicadores de XP</li>
      </ul>
    </CardContent>
  </Card>
</div>

7. VALIDACIÓN

7.1 Compilación TypeScript

# Backend
cd apps/backend
npx tsc --noEmit
# ✅ Resultado: 0 errores

# Frontend
cd apps/frontend
npm run type-check
# ✅ Resultado: 0 errores

7.2 Cobertura de Funcionalidad

Funcionalidad Estado
Visualización de datos de Student
Filtros y paginación
Modales de detalle
Skeleton loading
Auto-refresh configurable
RLS validation
Error handling
Swagger documentation

7.3 Endpoints Swagger

/teacher/attempts:
  get:
    tags: [Teacher - Exercise Responses]
    summary: Get exercise attempts with filters
    parameters:
      - name: classroom_id
        in: query
        schema: { type: string, format: uuid }
      - name: student_id
        in: query
        schema: { type: string, format: uuid }
      # ... más parámetros
    responses:
      200:
        description: Paginated list of attempts
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttemptsListResponseDto'

8. ARCHIVOS DE DOCUMENTACIÓN

Documento Ruta
Análisis Fase 1 orchestration/agentes/architecture-analyst/teacher-portal-development-2025-11-24/ANALISIS-FASE-1-TEACHER-PORTAL.md
Plan Fase 2 orchestration/agentes/architecture-analyst/teacher-portal-development-2025-11-24/PLAN-DESARROLLO-FASE-2.md
Resumen Final orchestration/agentes/architecture-analyst/teacher-portal-development-2025-11-24/RESUMEN-FINAL-DESARROLLO-TEACHER-PORTAL.md
Esta documentación docs/90-transversal/DESARROLLO-TEACHER-PORTAL-COMPLETO-2025-11-24.md

9. TRAZABILIDAD

9.1 Trazas Actualizadas

Traza ID Descripción
Backend BE-133 Exercise Responses Service
Frontend FE-104 Teacher Portal Complete Development

9.2 Referencias a Inventarios

Los nuevos componentes deben agregarse a:

  • docs/90-transversal/inventarios/BACKEND_INVENTORY.yml
  • docs/90-transversal/inventarios/FRONTEND_INVENTORY.yml

10. PRÓXIMOS PASOS

10.1 Testing y QA

  1. Tests E2E para nuevos endpoints
  2. Tests de componentes para nueva página
  3. Validación manual de flujos completos

10.2 Extensiones Futuras

  1. ML Predictions - Cuando MLPredictorService esté implementado:

    • Activar predicciones en TeacherReportsPage
    • Habilitar insights ML en TeacherAnalyticsPage
    • Desbloquear config rewards en TeacherGamificationPage
  2. TeacherResourcesPage - Fase 3:

    • Definir tablas BD para recursos
    • Implementar CRUD completo
  3. TeacherCommunicationPage - Opcional:

    • Sistema de mensajería
    • Notificaciones WebSocket

Documento generado: 2025-11-24 Versión: 1.0.0 Análisis ID: TEACHER-PORTAL-001