feat: Implement advanced grading (rubrics) and lesson dependencies across CMS service, API, and Studio UI.

This commit is contained in:
2026-02-17 22:43:19 -03:00
parent 12df920f60
commit f9e78a265a
17 changed files with 2181 additions and 124 deletions
+68 -30
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { lmsApi, Course, Module, Recommendation, UserGrade } from "@/lib/api";
import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } from "lucide-react";
import Link from "next/link";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import DiscussionBoard from "@/components/DiscussionBoard";
import { AnnouncementsList } from "@/components/AnnouncementsList";
@@ -17,6 +17,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
const [loadingAI, setLoadingAI] = useState(false);
const [userGrades, setUserGrades] = useState<UserGrade[]>([]);
const [isEnrolled, setIsEnrolled] = useState(false);
const [lessonDependencies, setLessonDependencies] = useState<any[]>([]);
useEffect(() => {
const fetchData = async () => {
@@ -24,6 +25,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
setLoading(true);
const data = await lmsApi.getCourseOutline(params.id);
setCourseData({ ...data.course, modules: data.modules });
setLessonDependencies(data.dependencies || []);
if (user) {
const grades = await lmsApi.getUserGrades(user.id, params.id);
@@ -88,7 +90,23 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
if (!courseData) return <div className="text-center py-20 text-gray-500">Curso no encontrado.</div>;
const isLessonLocked = (lessonId: string) => {
if (!isEnrolled) return false;
const deps = lessonDependencies.filter(d => d.lesson_id === lessonId);
if (deps.length === 0) return false;
return deps.some(dep => {
const prereqGrade = userGrades.find(g => g.lesson_id === dep.prerequisite_lesson_id);
if (!prereqGrade) return true; // Not completed at all
if (dep.min_score_percentage && (prereqGrade.score * 100) < dep.min_score_percentage) return true;
return false;
});
};
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
if (isLessonLocked(lessonId)) {
return <Lock size={18} className="text-gray-600" />;
}
const grade = userGrades.find((g: UserGrade) => g.lesson_id === lessonId);
if (!grade) {
return <Circle size={18} className="text-white/20" />;
@@ -263,43 +281,63 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
<div className="grid gap-3 pl-14">
{module.lessons.map((lesson: any) => (
isEnrolled ? (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
{module.lessons.map((lesson: any) => {
const locked = isLessonLocked(lesson.id);
return isEnrolled ? (
locked ? (
<div key={lesson.id} className="glass-card !p-4 border-white/5 opacity-60 cursor-not-allowed">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center">
<Lock size={18} className="text-gray-600" />
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
<h3 className="text-sm font-bold text-gray-400">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-600">Bloqueado por Prerrequisitos</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
<Lock size={18} className="text-gray-600" />
</div>
</div>
</div>
</Link>
) : (
<Link key={lesson.id} href={`/courses/${params.id}/lessons/${lesson.id}`}>
<div className="glass-card !p-4 group hover:bg-white/10 border-white/5 active:scale-[0.99] transition-all">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-white/5 flex items-center justify-center group-hover:bg-blue-500/20 transition-colors">
{lesson.content_type === 'video' ? (
<PlayCircle size={18} className="text-gray-400 group-hover:text-blue-400" />
) : (
<BookOpen size={18} className="text-gray-400 group-hover:text-blue-400" />
)}
</div>
<div>
<h3 className="text-sm font-bold text-gray-200 group-hover:text-white transition-colors">{lesson.title}</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-gray-500">
{lesson.content_type === 'activity' ? 'Actividad Interactiva' : 'Lección en Video'}
</span>
</div>
</div>
<div className="flex items-center gap-6">
{getStatusIcon(lesson.id, lesson.is_graded, lesson.allow_retry)}
{lesson.due_date && (
<div className="text-right hidden sm:block">
<div className="text-[9px] font-black uppercase tracking-widest text-gray-600">Vencimiento</div>
<div className={`text-[10px] font-bold ${new Date(lesson.due_date) < new Date() ? 'text-red-400' : 'text-blue-400'}`}>
{new Date(lesson.due_date).toLocaleDateString()}
</div>
</div>
)}
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronRight size={18} className="text-blue-500" />
</div>
</div>
</div>
</div>
</Link>
)
) : (
<div key={lesson.id} onClick={handleEnrollOrBuy} className="glass-card !p-4 group border-white/5 opacity-60 cursor-pointer hover:bg-white/5 transition-all">
<div className="flex items-center justify-between">
@@ -319,8 +357,8 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
</div>
</div>
)
))}
);
})}
</div>
</div>
))}
+16 -1
View File
@@ -138,6 +138,15 @@ export interface GradingCategory {
drop_count: number;
}
export interface LessonDependency {
id: string;
organization_id: string;
lesson_id: string;
prerequisite_lesson_id: string;
min_score_percentage: number | null;
created_at: string;
}
export interface UserGrade {
id: string;
user_id: string;
@@ -366,7 +375,13 @@ export const lmsApi = {
return apiFetch(`/catalog${query}`);
},
async getCourseOutline(courseId: string): Promise<{ course: Course, modules: Module[], grading_categories: GradingCategory[], organization: Organization }> {
async getCourseOutline(courseId: string): Promise<{
course: Course,
modules: Module[],
grading_categories: GradingCategory[],
organization: Organization,
dependencies?: LessonDependency[]
}> {
return apiFetch(`/courses/${courseId}/outline`);
},