- CourseProgressTracker: Comprehensive progress visualization - LearningPathVisualizer: Visual roadmap for learning paths - VideoProgressPlayer: Enhanced video player with bookmarks and controls - AssessmentSummaryCard: Quiz results analysis and review Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
/**
|
|
* CourseProgressTracker Component
|
|
* Comprehensive progress visualization at the course level
|
|
* OQI-002: Modulo Educativo
|
|
*/
|
|
|
|
import React, { useMemo } from 'react';
|
|
import {
|
|
BookOpen,
|
|
CheckCircle,
|
|
Circle,
|
|
Clock,
|
|
Play,
|
|
Lock,
|
|
Trophy,
|
|
Target,
|
|
TrendingUp,
|
|
Download,
|
|
ChevronRight,
|
|
Zap,
|
|
Calendar,
|
|
} from 'lucide-react';
|
|
|
|
export interface ModuleProgress {
|
|
id: string;
|
|
title: string;
|
|
lessonsTotal: number;
|
|
lessonsCompleted: number;
|
|
quizScore?: number;
|
|
quizPassed?: boolean;
|
|
timeSpentMinutes: number;
|
|
estimatedMinutes: number;
|
|
status: 'locked' | 'available' | 'in_progress' | 'completed';
|
|
order: number;
|
|
}
|
|
|
|
export interface CourseProgressData {
|
|
courseId: string;
|
|
courseTitle: string;
|
|
totalLessons: number;
|
|
completedLessons: number;
|
|
totalQuizzes: number;
|
|
passedQuizzes: number;
|
|
overallProgress: number;
|
|
xpEarned: number;
|
|
xpTotal: number;
|
|
timeSpentMinutes: number;
|
|
estimatedTotalMinutes: number;
|
|
startedAt?: string;
|
|
lastAccessedAt?: string;
|
|
modules: ModuleProgress[];
|
|
}
|
|
|
|
interface CourseProgressTrackerProps {
|
|
progress: CourseProgressData;
|
|
onModuleClick?: (moduleId: string) => void;
|
|
onDownloadReport?: () => void;
|
|
compact?: boolean;
|
|
}
|
|
|
|
const CourseProgressTracker: React.FC<CourseProgressTrackerProps> = ({
|
|
progress,
|
|
onModuleClick,
|
|
onDownloadReport,
|
|
compact = false,
|
|
}) => {
|
|
const sortedModules = useMemo(() => {
|
|
return [...progress.modules].sort((a, b) => a.order - b.order);
|
|
}, [progress.modules]);
|
|
|
|
const completedModules = progress.modules.filter(m => m.status === 'completed').length;
|
|
const totalModules = progress.modules.length;
|
|
|
|
const formatTime = (minutes: number) => {
|
|
if (minutes < 60) return `${minutes}m`;
|
|
const hours = Math.floor(minutes / 60);
|
|
const mins = minutes % 60;
|
|
return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`;
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleDateString('es-ES', {
|
|
day: 'numeric',
|
|
month: 'short',
|
|
year: 'numeric',
|
|
});
|
|
};
|
|
|
|
const getStatusIcon = (status: ModuleProgress['status']) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return <CheckCircle className="w-5 h-5 text-green-400" />;
|
|
case 'in_progress':
|
|
return <Play className="w-5 h-5 text-blue-400" />;
|
|
case 'locked':
|
|
return <Lock className="w-5 h-5 text-gray-500" />;
|
|
default:
|
|
return <Circle className="w-5 h-5 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
const getStatusBg = (status: ModuleProgress['status']) => {
|
|
switch (status) {
|
|
case 'completed':
|
|
return 'bg-green-500/10 border-green-500/30';
|
|
case 'in_progress':
|
|
return 'bg-blue-500/10 border-blue-500/30';
|
|
case 'locked':
|
|
return 'bg-gray-800/50 border-gray-700 opacity-60';
|
|
default:
|
|
return 'bg-gray-800/50 border-gray-700';
|
|
}
|
|
};
|
|
|
|
const estimatedCompletion = useMemo(() => {
|
|
const remainingMinutes = progress.estimatedTotalMinutes - progress.timeSpentMinutes;
|
|
if (remainingMinutes <= 0) return 'Completable ahora';
|
|
const days = Math.ceil(remainingMinutes / 60 / 2); // Assuming 2 hours/day
|
|
return `~${days} ${days === 1 ? 'dia' : 'dias'} restantes`;
|
|
}, [progress]);
|
|
|
|
return (
|
|
<div className={`bg-gray-800/50 rounded-xl border border-gray-700 ${compact ? 'p-3' : 'p-4'}`}>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-blue-500/20 rounded-lg">
|
|
<Target className="w-5 h-5 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-white">Progreso del Curso</h3>
|
|
<p className="text-xs text-gray-500">{progress.courseTitle}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{onDownloadReport && (
|
|
<button
|
|
onClick={onDownloadReport}
|
|
className="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
|
title="Descargar reporte"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Overall Progress Bar */}
|
|
<div className="mb-6">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-sm text-gray-400">Progreso General</span>
|
|
<span className="text-lg font-bold text-white">{progress.overallProgress}%</span>
|
|
</div>
|
|
<div className="h-3 bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-gradient-to-r from-blue-600 to-green-500 rounded-full transition-all duration-500"
|
|
style={{ width: `${progress.overallProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats Grid */}
|
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
|
<div className="flex items-center justify-center gap-1 mb-1">
|
|
<BookOpen className="w-4 h-4 text-blue-400" />
|
|
</div>
|
|
<div className="text-lg font-bold text-white">
|
|
{progress.completedLessons}/{progress.totalLessons}
|
|
</div>
|
|
<div className="text-xs text-gray-500">Lecciones</div>
|
|
</div>
|
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
|
<div className="flex items-center justify-center gap-1 mb-1">
|
|
<Trophy className="w-4 h-4 text-yellow-400" />
|
|
</div>
|
|
<div className="text-lg font-bold text-white">
|
|
{progress.passedQuizzes}/{progress.totalQuizzes}
|
|
</div>
|
|
<div className="text-xs text-gray-500">Quizzes</div>
|
|
</div>
|
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
|
<div className="flex items-center justify-center gap-1 mb-1">
|
|
<Clock className="w-4 h-4 text-purple-400" />
|
|
</div>
|
|
<div className="text-lg font-bold text-white">{formatTime(progress.timeSpentMinutes)}</div>
|
|
<div className="text-xs text-gray-500">Tiempo</div>
|
|
</div>
|
|
<div className="p-3 bg-gray-900/50 rounded-lg text-center">
|
|
<div className="flex items-center justify-center gap-1 mb-1">
|
|
<Zap className="w-4 h-4 text-green-400" />
|
|
</div>
|
|
<div className="text-lg font-bold text-white">{progress.xpEarned}</div>
|
|
<div className="text-xs text-gray-500">XP</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Timeline Info */}
|
|
{(progress.startedAt || progress.lastAccessedAt) && (
|
|
<div className="flex items-center justify-between p-3 bg-gray-900/50 rounded-lg mb-4 text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<Calendar className="w-4 h-4 text-gray-500" />
|
|
{progress.startedAt && (
|
|
<span className="text-gray-400">
|
|
Iniciado: <span className="text-white">{formatDate(progress.startedAt)}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<TrendingUp className="w-4 h-4 text-gray-500" />
|
|
<span className="text-gray-400">{estimatedCompletion}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Module List */}
|
|
<div className="space-y-2">
|
|
<div className="text-sm text-gray-400 mb-2">
|
|
Modulos ({completedModules}/{totalModules})
|
|
</div>
|
|
|
|
{sortedModules.map((module, index) => {
|
|
const moduleProgress =
|
|
module.lessonsTotal > 0
|
|
? Math.round((module.lessonsCompleted / module.lessonsTotal) * 100)
|
|
: 0;
|
|
|
|
return (
|
|
<div
|
|
key={module.id}
|
|
onClick={() => module.status !== 'locked' && onModuleClick?.(module.id)}
|
|
className={`p-3 rounded-lg border transition-all ${getStatusBg(module.status)} ${
|
|
module.status !== 'locked' ? 'cursor-pointer hover:border-gray-600' : ''
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-3">
|
|
{/* Status Icon */}
|
|
<div className="flex-shrink-0">{getStatusIcon(module.status)}</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center justify-between">
|
|
<span
|
|
className={`font-medium truncate ${
|
|
module.status === 'locked' ? 'text-gray-500' : 'text-white'
|
|
}`}
|
|
>
|
|
{index + 1}. {module.title}
|
|
</span>
|
|
{module.status !== 'locked' && (
|
|
<ChevronRight className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
{module.status !== 'locked' && (
|
|
<div className="mt-2">
|
|
<div className="flex items-center justify-between text-xs mb-1">
|
|
<span className="text-gray-500">
|
|
{module.lessonsCompleted}/{module.lessonsTotal} lecciones
|
|
</span>
|
|
<span className="text-gray-400">{moduleProgress}%</span>
|
|
</div>
|
|
<div className="h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full rounded-full transition-all ${
|
|
module.status === 'completed' ? 'bg-green-500' : 'bg-blue-500'
|
|
}`}
|
|
style={{ width: `${moduleProgress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quiz Badge */}
|
|
{module.quizScore !== undefined && (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span
|
|
className={`px-2 py-0.5 rounded text-xs ${
|
|
module.quizPassed
|
|
? 'bg-green-500/20 text-green-400'
|
|
: 'bg-red-500/20 text-red-400'
|
|
}`}
|
|
>
|
|
Quiz: {module.quizScore}%
|
|
</span>
|
|
<span className="text-xs text-gray-500">
|
|
{formatTime(module.timeSpentMinutes)} / {formatTime(module.estimatedMinutes)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Completion Message */}
|
|
{progress.overallProgress === 100 && (
|
|
<div className="mt-4 p-4 bg-gradient-to-r from-green-500/10 to-emerald-500/10 border border-green-500/30 rounded-lg text-center">
|
|
<Trophy className="w-8 h-8 text-yellow-400 mx-auto mb-2" />
|
|
<p className="text-green-400 font-medium">Curso Completado!</p>
|
|
<p className="text-xs text-gray-400 mt-1">Has ganado {progress.xpEarned} XP</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default CourseProgressTracker;
|