feat: full LMS — learning paths, improved admin editor, student detail

Learning Paths (Parcours):
- New LearningPath, LearningPathCourse, LearningPathEnrollment Prisma models
- Admin CRUD: /admin/paths (list, create, edit, publish/draft, delete)
- Admin PathCourseManager: add/remove courses from a path with ordering
- Student pages: /paths (browse all paths), /paths/[slug] (detail + enroll)
- Enrolling in a path auto-enrolls the student in each individual course
- Enrollment API: POST /api/paths/enroll

Admin Module/Lesson/Quiz editor (ModuleManager rewrite):
- Replaced browser prompt()/confirm() with proper inline modals
- Modal dialogs for add module, add lesson, delete confirmation
- Module renaming (PUT /api/admin/modules/[id])
- Full multilingual editing for lessons (FR/EN/ES) with per-tab content
- Full multilingual quiz editing (FR/EN/ES) for questions and options
- Lesson type selection (TEXT / VIDEO) with clear button UI

Admin dashboard:
- 4-stat grid including Parcours count
- Quick actions for paths (new path, manage paths)

Students:
- Student list rows now link to /admin/students/[id] detail page
- Student detail: enrollment table with progress bars, learning paths, quiz attempts

Navigation:
- "Parcours" link added to main nav for all visitors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Romain bogdanovic 2026-03-29 21:55:28 +02:00
parent 8f78cbf25d
commit da94d44de1
23 changed files with 1955 additions and 400 deletions

View File

@ -46,12 +46,13 @@ model User {
role Role @default(LEARNER) role Role @default(LEARNER)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
accounts Account[] accounts Account[]
sessions Session[] sessions Session[]
enrollments Enrollment[] enrollments Enrollment[]
lessonProgress LessonProgress[] lessonProgress LessonProgress[]
quizAttempts QuizAttempt[] quizAttempts QuizAttempt[]
certificates Certificate[] certificates Certificate[]
learningPathEnrollments LearningPathEnrollment[]
} }
model Account { model Account {
@ -109,9 +110,10 @@ model Course {
descEn String @db.Text descEn String @db.Text
descEs String @db.Text descEs String @db.Text
modules Module[] modules Module[]
enrollments Enrollment[] enrollments Enrollment[]
certificates Certificate[] certificates Certificate[]
learningPaths LearningPathCourse[]
} }
model Module { model Module {
@ -225,3 +227,48 @@ model Certificate {
@@unique([userId, courseId]) @@unique([userId, courseId])
} }
// ─── Learning Paths (Parcours) ────────────────────────────────────────────────
model LearningPath {
id String @id @default(cuid())
slug String @unique
published Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
titleFr String
titleEn String
titleEs String
descFr String @db.Text
descEn String @db.Text
descEs String @db.Text
courses LearningPathCourse[]
enrollments LearningPathEnrollment[]
}
model LearningPathCourse {
id String @id @default(cuid())
learningPathId String
courseId String
order Int @default(0)
learningPath LearningPath @relation(fields: [learningPathId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([learningPathId, courseId])
}
model LearningPathEnrollment {
id String @id @default(cuid())
userId String
learningPathId String
enrolledAt DateTime @default(now())
completedAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
learningPath LearningPath @relation(fields: [learningPathId], references: [id], onDelete: Cascade)
@@unique([userId, learningPathId])
}

View File

@ -1,50 +1,69 @@
"use client"; "use client";
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
// ─── Types ───────────────────────────────────────────────────────────────────
interface Lesson { interface Lesson {
id: string; id: string;
titleFr: string; titleFr: string; titleEn: string; titleEs: string;
titleEn: string;
titleEs: string;
type: string; type: string;
videoUrl?: string | null; videoUrl?: string | null;
contentFr?: string | null; contentFr?: string | null; contentEn?: string | null; contentEs?: string | null;
contentEn?: string | null;
contentEs?: string | null;
duration?: number | null; duration?: number | null;
order: number; order: number;
} }
interface Question { interface Question {
id: string; id: string;
textFr: string; textFr: string; textEn: string; textEs: string;
textEn: string; optionsFr: string[]; optionsEn: string[]; optionsEs: string[];
textEs: string;
optionsFr: string[];
optionsEn: string[];
optionsEs: string[];
correctIndex: number; correctIndex: number;
order: number; order: number;
} }
interface Quiz { interface Quiz { id: string; passMark: number; questions: Question[] }
id: string;
passMark: number;
questions: Question[];
}
interface Module { interface Module {
id: string; id: string;
titleFr: string; titleFr: string; titleEn: string; titleEs: string;
titleEn: string;
titleEs: string;
order: number; order: number;
lessons: Lesson[]; lessons: Lesson[];
quiz?: Quiz | null; quiz?: Quiz | null;
} }
// ─── Modal component ──────────────────────────────────────────────────────────
function Modal({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
return (
<div style={{ position: "fixed", inset: 0, zIndex: 100, background: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#1a1f2e", border: "1px solid rgba(255,255,255,0.12)", borderRadius: 12, width: "100%", maxWidth: 560, maxHeight: "90vh", overflowY: "auto" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", padding: "16px 20px", borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: "#f1f5f9" }}>{title}</h3>
<button onClick={onClose} style={{ background: "none", border: "none", color: "#94a3b8", cursor: "pointer", fontSize: 20, lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: 20 }}>{children}</div>
</div>
</div>
);
}
function ConfirmDialog({ message, onConfirm, onCancel }: { message: string; onConfirm: () => void; onCancel: () => void }) {
return (
<div style={{ position: "fixed", inset: 0, zIndex: 110, background: "rgba(0,0,0,0.7)", display: "flex", alignItems: "center", justifyContent: "center", padding: 24 }}>
<div style={{ background: "#1a1f2e", border: "1px solid rgba(239,68,68,0.3)", borderRadius: 10, padding: 24, maxWidth: 380, width: "100%" }}>
<p style={{ color: "#f1f5f9", fontSize: 15, marginBottom: 20 }}>{message}</p>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end" }}>
<button onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
<button onClick={onConfirm} className="btn btn-danger" style={{ fontSize: 13 }}>Supprimer</button>
</div>
</div>
</div>
);
}
// ─── ModuleManager ────────────────────────────────────────────────────────────
interface ModuleManagerProps { interface ModuleManagerProps {
courseId: string; courseId: string;
modules: Module[]; modules: Module[];
@ -52,38 +71,32 @@ interface ModuleManagerProps {
t: Record<string, string>; t: Record<string, string>;
} }
export function ModuleManager({ courseId, modules: initialModules, locale, t }: ModuleManagerProps) { export function ModuleManager({ courseId, modules: initialModules, t }: ModuleManagerProps) {
const [modules, setModules] = useState(initialModules); const [modules, setModules] = useState(initialModules);
const [loading, setLoading] = useState(false); const [showAddModule, setShowAddModule] = useState(false);
const [expandedModule, setExpandedModule] = useState<string | null>(null); const [expandedModule, setExpandedModule] = useState<string | null>(null);
const router = useRouter(); const [confirmDelete, setConfirmDelete] = useState<{ message: string; onConfirm: () => void } | null>(null);
const addModule = async () => { const handleAddModule = async (data: { titleFr: string; titleEn: string; titleEs: string }) => {
const titleFr = prompt("Titre du module (FR) :"); const res = await fetch(`/api/admin/courses/${courseId}/modules`, {
if (!titleFr) return; method: "POST",
setLoading(true); headers: { "Content-Type": "application/json" },
try { body: JSON.stringify({ ...data, order: modules.length }),
const res = await fetch(`/api/admin/courses/${courseId}/modules`, { });
method: "POST", const newModule = await res.json();
headers: { "Content-Type": "application/json" }, setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]);
body: JSON.stringify({ titleFr, titleEn: titleFr, titleEs: titleFr, order: modules.length }), setShowAddModule(false);
});
const newModule = await res.json();
setModules((prev) => [...prev, { ...newModule, lessons: [], quiz: null }]);
} finally {
setLoading(false);
}
}; };
const deleteModule = async (moduleId: string) => { const deleteModule = (moduleId: string) => {
if (!confirm("Supprimer ce module ?")) return; setConfirmDelete({
setLoading(true); message: "Supprimer ce module et tout son contenu ?",
try { onConfirm: async () => {
await fetch(`/api/admin/modules/${moduleId}`, { method: "DELETE" }); await fetch(`/api/admin/modules/${moduleId}`, { method: "DELETE" });
setModules((prev) => prev.filter((m) => m.id !== moduleId)); setModules((prev) => prev.filter((m) => m.id !== moduleId));
} finally { setConfirmDelete(null);
setLoading(false); },
} });
}; };
return ( return (
@ -96,188 +109,194 @@ export function ModuleManager({ courseId, modules: initialModules, locale, t }:
expanded={expandedModule === module.id} expanded={expandedModule === module.id}
onToggle={() => setExpandedModule(expandedModule === module.id ? null : module.id)} onToggle={() => setExpandedModule(expandedModule === module.id ? null : module.id)}
onDelete={() => deleteModule(module.id)} onDelete={() => deleteModule(module.id)}
onUpdate={(updated) => onUpdate={(updated) => setModules((prev) => prev.map((m) => m.id === updated.id ? updated : m))}
setModules((prev) => prev.map((m) => m.id === updated.id ? updated : m)) onConfirmDelete={(msg, fn) => setConfirmDelete({ message: msg, onConfirm: fn })}
}
t={t} t={t}
/> />
))} ))}
<button <button onClick={() => setShowAddModule(true)} className="btn btn-secondary" style={{ alignSelf: "flex-start" }}>
onClick={addModule}
disabled={loading}
className="btn btn-secondary"
style={{ alignSelf: "flex-start" }}
>
+ {t.add_module} + {t.add_module}
</button> </button>
{showAddModule && (
<Modal title="Ajouter un module" onClose={() => setShowAddModule(false)}>
<ModuleForm onSave={handleAddModule} onCancel={() => setShowAddModule(false)} />
</Modal>
)}
{confirmDelete && (
<ConfirmDialog
message={confirmDelete.message}
onConfirm={confirmDelete.onConfirm}
onCancel={() => setConfirmDelete(null)}
/>
)}
</div> </div>
); );
} }
function ModuleItem({ // ─── ModuleForm ───────────────────────────────────────────────────────────────
module,
index, function ModuleForm({
expanded, initial,
onToggle, onSave,
onDelete, onCancel,
onUpdate,
t,
}: { }: {
module: Module; initial?: { titleFr: string; titleEn: string; titleEs: string };
index: number; onSave: (data: { titleFr: string; titleEn: string; titleEs: string }) => Promise<void>;
expanded: boolean; onCancel: () => void;
onToggle: () => void;
onDelete: () => void;
onUpdate: (m: Module) => void;
t: Record<string, string>;
}) { }) {
const [loading, setLoading] = useState(false); const [lang, setLang] = useState<"fr" | "en" | "es">("fr");
const [quizExpanded, setQuizExpanded] = useState(false); const [titleFr, setTitleFr] = useState(initial?.titleFr ?? "");
const [titleEn, setTitleEn] = useState(initial?.titleEn ?? "");
const [titleEs, setTitleEs] = useState(initial?.titleEs ?? "");
const [saving, setSaving] = useState(false);
const addLesson = async () => { const handleSubmit = async (e: React.FormEvent) => {
const titleFr = prompt("Titre de la leçon (FR) :"); e.preventDefault();
if (!titleFr) return; setSaving(true);
const type = (prompt("Type (VIDEO ou TEXT):") ?? "TEXT").toUpperCase(); await onSave({ titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr });
setLoading(true); setSaving(false);
try {
const res = await fetch(`/api/admin/modules/${module.id}/lessons`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
titleFr,
titleEn: titleFr,
titleEs: titleFr,
type: ["VIDEO", "TEXT"].includes(type) ? type : "TEXT",
order: module.lessons.length,
}),
});
const newLesson = await res.json();
onUpdate({ ...module, lessons: [...module.lessons, newLesson] });
} finally {
setLoading(false);
}
};
const deleteLesson = async (lessonId: string) => {
if (!confirm("Supprimer cette leçon ?")) return;
setLoading(true);
try {
await fetch(`/api/admin/lessons/${lessonId}`, { method: "DELETE" });
onUpdate({ ...module, lessons: module.lessons.filter((l) => l.id !== lessonId) });
} finally {
setLoading(false);
}
};
const saveQuiz = async (passMark: number, questions: Omit<Question, "id">[]) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/modules/${module.id}/quiz`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passMark, questions }),
});
const quiz = await res.json();
onUpdate({ ...module, quiz });
} finally {
setLoading(false);
}
}; };
return ( return (
<div <form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
style={{ <div style={{ display: "flex", gap: 6, marginBottom: 4 }}>
background: "#1a1f2e", {(["fr", "en", "es"] as const).map((l) => (
border: "1px solid rgba(255,255,255,0.08)", <button key={l} type="button" onClick={() => setLang(l)}
borderRadius: 10, style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
overflow: "hidden", {l}
}} </button>
> ))}
{/* Module header */} </div>
<div {lang === "fr" && <input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre du module (FR)" required autoFocus />}
style={{ {lang === "en" && <input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Module title (EN)" />}
display: "flex", {lang === "es" && <input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título del módulo (ES)" />}
alignItems: "center", <div style={{ display: "flex", gap: 8 }}>
gap: 12, <button type="submit" disabled={saving || !titleFr} className="btn btn-primary" style={{ fontSize: 13, opacity: !titleFr ? 0.6 : 1 }}>
padding: "14px 16px", {saving ? "…" : initial ? "Enregistrer" : "Créer"}
cursor: "pointer", </button>
}} <button type="button" onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
onClick={onToggle} </div>
> </form>
<span );
style={{ }
width: 26,
height: 26, // ─── ModuleItem ───────────────────────────────────────────────────────────────
background: "rgba(29,78,216,0.2)",
color: "#60a5fa", function ModuleItem({
borderRadius: 6, module, index, expanded, onToggle, onDelete, onUpdate, onConfirmDelete, t,
display: "flex", }: {
alignItems: "center", module: Module; index: number; expanded: boolean;
justifyContent: "center", onToggle: () => void; onDelete: () => void;
fontSize: 12, onUpdate: (m: Module) => void;
fontWeight: 700, onConfirmDelete: (msg: string, fn: () => void) => void;
}} t: Record<string, string>;
> }) {
const [editing, setEditing] = useState(false);
const [showAddLesson, setShowAddLesson] = useState(false);
const [quizExpanded, setQuizExpanded] = useState(false);
const handleRename = async (data: { titleFr: string; titleEn: string; titleEs: string }) => {
await fetch(`/api/admin/modules/${module.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
onUpdate({ ...module, ...data });
setEditing(false);
};
const handleAddLesson = async (data: { titleFr: string; titleEn: string; titleEs: string; type: string }) => {
const res = await fetch(`/api/admin/modules/${module.id}/lessons`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, order: module.lessons.length }),
});
const newLesson = await res.json();
onUpdate({ ...module, lessons: [...module.lessons, newLesson] });
setShowAddLesson(false);
};
const deleteLesson = (lessonId: string) => {
onConfirmDelete("Supprimer cette leçon ?", async () => {
await fetch(`/api/admin/lessons/${lessonId}`, { method: "DELETE" });
onUpdate({ ...module, lessons: module.lessons.filter((l) => l.id !== lessonId) });
});
};
const saveQuiz = async (passMark: number, questions: Omit<Question, "id">[]) => {
const res = await fetch(`/api/admin/modules/${module.id}/quiz`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ passMark, questions }),
});
const quiz = await res.json();
onUpdate({ ...module, quiz });
};
return (
<div style={{ background: "#1a1f2e", border: "1px solid rgba(255,255,255,0.08)", borderRadius: 10, overflow: "hidden" }}>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "14px 16px", cursor: "pointer" }} onClick={onToggle}>
<span style={{ width: 26, height: 26, background: "rgba(29,78,216,0.2)", color: "#60a5fa", borderRadius: 6, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 12, fontWeight: 700, flexShrink: 0 }}>
{index + 1} {index + 1}
</span> </span>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<p style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{module.titleFr}</p> <p style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{module.titleFr}</p>
<p style={{ fontSize: 12, color: "#94a3b8" }}> <p style={{ fontSize: 12, color: "#94a3b8" }}>
{module.lessons.length} leçon{module.lessons.length !== 1 ? "s" : ""} {module.lessons.length} leçon{module.lessons.length !== 1 ? "s" : ""}
{module.quiz ? " · Quiz" : ""} {module.quiz ? " · Quiz" : ""}
</p> </p>
</div> </div>
<button <div style={{ display: "flex", gap: 6 }} onClick={(e) => e.stopPropagation()}>
onClick={(e) => { e.stopPropagation(); onDelete(); }} <button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 12, padding: "4px 10px" }}>Renommer</button>
className="btn btn-danger" <button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 12, padding: "4px 10px" }}>{t.delete}</button>
style={{ fontSize: 12, padding: "4px 10px" }} </div>
> <span style={{ color: "#94a3b8", fontSize: 16, flexShrink: 0 }}>{expanded ? "▲" : "▼"}</span>
{t.delete}
</button>
<span style={{ color: "#94a3b8", fontSize: 16 }}>{expanded ? "▲" : "▼"}</span>
</div> </div>
{editing && (
<Modal title="Renommer le module" onClose={() => setEditing(false)}>
<ModuleForm
initial={{ titleFr: module.titleFr, titleEn: module.titleEn, titleEs: module.titleEs }}
onSave={handleRename}
onCancel={() => setEditing(false)}
/>
</Modal>
)}
{/* Expanded content */}
{expanded && ( {expanded && (
<div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", padding: "16px" }}> <div style={{ borderTop: "1px solid rgba(255,255,255,0.08)", padding: 16 }}>
{/* Lessons */} {/* Lessons */}
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8, marginBottom: 12 }}>
{module.lessons.map((lesson) => ( {module.lessons.map((lesson) => (
<LessonItem <LessonItem
key={lesson.id} key={lesson.id}
lesson={lesson} lesson={lesson}
moduleId={module.id} onUpdate={(updated) => onUpdate({ ...module, lessons: module.lessons.map((l) => l.id === updated.id ? updated : l) })}
onUpdate={(updated) =>
onUpdate({
...module,
lessons: module.lessons.map((l) => l.id === updated.id ? updated : l),
})
}
onDelete={() => deleteLesson(lesson.id)} onDelete={() => deleteLesson(lesson.id)}
t={t} t={t}
/> />
))} ))}
</div> </div>
<button <button onClick={() => setShowAddLesson(true)} className="btn btn-secondary" style={{ fontSize: 12, marginBottom: 16 }}>
onClick={addLesson}
disabled={loading}
className="btn btn-secondary"
style={{ fontSize: 12, marginBottom: 16 }}
>
+ {t.add_lesson} + {t.add_lesson}
</button> </button>
{/* Quiz section */} {showAddLesson && (
<Modal title="Ajouter une leçon" onClose={() => setShowAddLesson(false)}>
<LessonCreateForm onSave={handleAddLesson} onCancel={() => setShowAddLesson(false)} />
</Modal>
)}
{/* Quiz */}
<div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 12 }}> <div style={{ borderTop: "1px solid rgba(255,255,255,0.06)", paddingTop: 12 }}>
<div <div
style={{ style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 8, cursor: "pointer" }}
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 8,
cursor: "pointer",
}}
onClick={() => setQuizExpanded(!quizExpanded)} onClick={() => setQuizExpanded(!quizExpanded)}
> >
<span style={{ fontSize: 14, fontWeight: 600, color: "#fbbf24" }}> <span style={{ fontSize: 14, fontWeight: 600, color: "#fbbf24" }}>
@ -285,9 +304,7 @@ function ModuleItem({
</span> </span>
<span style={{ color: "#94a3b8" }}>{quizExpanded ? "▲" : "▼"}</span> <span style={{ color: "#94a3b8" }}>{quizExpanded ? "▲" : "▼"}</span>
</div> </div>
{quizExpanded && ( {quizExpanded && <QuizEditor quiz={module.quiz} onSave={saveQuiz} t={t} />}
<QuizEditor quiz={module.quiz} onSave={saveQuiz} t={t} />
)}
</div> </div>
</div> </div>
)} )}
@ -295,25 +312,81 @@ function ModuleItem({
); );
} }
function LessonItem({ // ─── LessonCreateForm ─────────────────────────────────────────────────────────
lesson,
moduleId, function LessonCreateForm({
onUpdate, onSave,
onDelete, onCancel,
t,
}: { }: {
lesson: Lesson; onSave: (data: { titleFr: string; titleEn: string; titleEs: string; type: string }) => Promise<void>;
moduleId: string; onCancel: () => void;
onUpdate: (l: Lesson) => void; }) {
onDelete: () => void; const [lang, setLang] = useState<"fr" | "en" | "es">("fr");
t: Record<string, string>; const [titleFr, setTitleFr] = useState("");
const [titleEn, setTitleEn] = useState("");
const [titleEs, setTitleEs] = useState("");
const [type, setType] = useState("TEXT");
const [saving, setSaving] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
await onSave({ titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, type });
setSaving(false);
};
return (
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Type de leçon</label>
<div style={{ display: "flex", gap: 8 }}>
{["TEXT", "VIDEO"].map((t) => (
<button key={t} type="button" onClick={() => setType(t)}
style={{ flex: 1, padding: "8px", borderRadius: 6, fontSize: 13, fontWeight: 600, border: "1px solid", cursor: "pointer", background: type === t ? (t === "VIDEO" ? "rgba(29,78,216,0.2)" : "rgba(34,197,94,0.1)") : "transparent", borderColor: type === t ? (t === "VIDEO" ? "#1d4ed8" : "#22c55e") : "rgba(255,255,255,0.1)", color: type === t ? (t === "VIDEO" ? "#60a5fa" : "#4ade80") : "#94a3b8" }}>
{t === "VIDEO" ? "▶ Vidéo" : "📄 Texte"}
</button>
))}
</div>
</div>
<div style={{ display: "flex", gap: 6 }}>
{(["fr", "en", "es"] as const).map((l) => (
<button key={l} type="button" onClick={() => setLang(l)}
style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
{l}
</button>
))}
</div>
{lang === "fr" && <input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre de la leçon (FR)" required autoFocus />}
{lang === "en" && <input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Lesson title (EN)" />}
{lang === "es" && <input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título de la lección (ES)" />}
<div style={{ display: "flex", gap: 8 }}>
<button type="submit" disabled={saving || !titleFr} className="btn btn-primary" style={{ fontSize: 13, opacity: !titleFr ? 0.6 : 1 }}>
{saving ? "…" : "Créer la leçon"}
</button>
<button type="button" onClick={onCancel} className="btn btn-secondary" style={{ fontSize: 13 }}>Annuler</button>
</div>
</form>
);
}
// ─── LessonItem ───────────────────────────────────────────────────────────────
function LessonItem({ lesson, onUpdate, onDelete, t }: {
lesson: Lesson; onUpdate: (l: Lesson) => void; onDelete: () => void; t: Record<string, string>;
}) { }) {
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [uploading, setUploading] = useState(false); const [lang, setLang] = useState<"fr" | "en" | "es">("fr");
const [titleFr, setTitleFr] = useState(lesson.titleFr); const [titleFr, setTitleFr] = useState(lesson.titleFr);
const [titleEn, setTitleEn] = useState(lesson.titleEn);
const [titleEs, setTitleEs] = useState(lesson.titleEs);
const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? ""); const [videoUrl, setVideoUrl] = useState(lesson.videoUrl ?? "");
const [contentFr, setContentFr] = useState(lesson.contentFr ?? ""); const [contentFr, setContentFr] = useState(lesson.contentFr ?? "");
const [contentEn, setContentEn] = useState(lesson.contentEn ?? "");
const [contentEs, setContentEs] = useState(lesson.contentEs ?? "");
const [duration, setDuration] = useState(lesson.duration?.toString() ?? ""); const [duration, setDuration] = useState(lesson.duration?.toString() ?? "");
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const handleSave = async () => { const handleSave = async () => {
@ -321,26 +394,20 @@ function LessonItem({
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
titleFr, titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr,
titleEn: lesson.titleEn,
titleEs: lesson.titleEs,
videoUrl: videoUrl || null, videoUrl: videoUrl || null,
contentFr: contentFr || null, contentFr: contentFr || null, contentEn: contentEn || null, contentEs: contentEs || null,
duration: duration ? parseInt(duration) : null, duration: duration ? parseInt(duration) : null,
}), }),
}); });
onUpdate({ ...lesson, titleFr, videoUrl: videoUrl || null, contentFr: contentFr || null, duration: duration ? parseInt(duration) : null }); onUpdate({ ...lesson, titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, videoUrl: videoUrl || null, contentFr: contentFr || null, contentEn: contentEn || null, contentEs: contentEs || null, duration: duration ? parseInt(duration) : null });
setEditing(false); setEditing(false);
}; };
const handleVideoUpload = async (file: File) => { const handleVideoUpload = async (file: File) => {
setUploading(true); setUploading(true);
try { try {
const res = await fetch("/api/upload", { const res = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filename: file.name, contentType: file.type, type: "video" }) });
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ filename: file.name, contentType: file.type, type: "video" }),
});
const { url, key } = await res.json(); const { url, key } = await res.json();
await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } }); await fetch(url, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
setVideoUrl(key); setVideoUrl(key);
@ -351,221 +418,156 @@ function LessonItem({
if (!editing) { if (!editing) {
return ( return (
<div <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 12px", background: "rgba(255,255,255,0.03)", borderRadius: 6, border: "1px solid rgba(255,255,255,0.06)" }}>
style={{ <span style={{ fontSize: 12, color: "#475569" }}>{lesson.type === "VIDEO" ? "▶" : "📄"}</span>
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "rgba(255,255,255,0.03)",
borderRadius: 6,
border: "1px solid rgba(255,255,255,0.06)",
}}
>
<span style={{ fontSize: 12, color: "#475569" }}>
{lesson.type === "VIDEO" ? "▶" : "📄"}
</span>
<span style={{ fontSize: 13, color: "#cbd5e1", flex: 1 }}>{lesson.titleFr}</span> <span style={{ fontSize: 13, color: "#cbd5e1", flex: 1 }}>{lesson.titleFr}</span>
{lesson.duration && ( {lesson.duration && <span style={{ fontSize: 11, color: "#475569" }}>{Math.floor(lesson.duration / 60)}min</span>}
<span style={{ fontSize: 11, color: "#475569" }}> <button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 11, padding: "3px 8px" }}>{t.edit}</button>
{Math.floor(lesson.duration / 60)}min <button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 11, padding: "3px 8px" }}>{t.delete}</button>
</span>
)}
<button onClick={() => setEditing(true)} className="btn btn-secondary" style={{ fontSize: 11, padding: "3px 8px" }}>
{t.edit}
</button>
<button onClick={onDelete} className="btn btn-danger" style={{ fontSize: 11, padding: "3px 8px" }}>
{t.delete}
</button>
</div> </div>
); );
} }
return ( return (
<div <div style={{ padding: 16, background: "rgba(29,78,216,0.05)", border: "1px solid rgba(29,78,216,0.2)", borderRadius: 8 }}>
style={{ <div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
padding: 14, {(["fr", "en", "es"] as const).map((l) => (
background: "rgba(29,78,216,0.05)", <button key={l} type="button" onClick={() => setLang(l)}
border: "1px solid rgba(29,78,216,0.2)", style={{ padding: "4px 12px", borderRadius: 6, fontSize: 12, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
borderRadius: 8, {l}
}} </button>
> ))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<input {lang === "fr" && (
type="text" <>
value={titleFr} <input type="text" value={titleFr} onChange={(e) => setTitleFr(e.target.value)} placeholder="Titre (FR)" />
onChange={(e) => setTitleFr(e.target.value)} {lesson.type === "TEXT" ? (
placeholder="Titre (FR)" <textarea value={contentFr} onChange={(e) => setContentFr(e.target.value)} placeholder="Contenu (FR)" rows={5} />
/> ) : null}
{lesson.type === "VIDEO" ? ( </>
)}
{lang === "en" && (
<>
<input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} placeholder="Title (EN)" />
{lesson.type === "TEXT" ? (
<textarea value={contentEn} onChange={(e) => setContentEn(e.target.value)} placeholder="Content (EN)" rows={5} />
) : null}
</>
)}
{lang === "es" && (
<>
<input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} placeholder="Título (ES)" />
{lesson.type === "TEXT" ? (
<textarea value={contentEs} onChange={(e) => setContentEs(e.target.value)} placeholder="Contenido (ES)" rows={5} />
) : null}
</>
)}
{lesson.type === "VIDEO" && (
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<input <input type="text" value={videoUrl} onChange={(e) => setVideoUrl(e.target.value)} placeholder="Clé S3 de la vidéo" style={{ flex: 1 }} />
type="text" <button type="button" onClick={() => fileRef.current?.click()} disabled={uploading} className="btn btn-secondary" style={{ fontSize: 12 }}>
value={videoUrl}
onChange={(e) => setVideoUrl(e.target.value)}
placeholder="Clé S3 de la vidéo"
style={{ flex: 1 }}
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="btn btn-secondary"
style={{ fontSize: 12 }}
>
{uploading ? "…" : t.upload_video} {uploading ? "…" : t.upload_video}
</button> </button>
<input <input ref={fileRef} type="file" accept="video/*" style={{ display: "none" }} onChange={(e) => { const f = e.target.files?.[0]; if (f) handleVideoUpload(f); }} />
ref={fileRef}
type="file"
accept="video/*"
style={{ display: "none" }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleVideoUpload(file);
}}
/>
</div> </div>
) : (
<textarea
value={contentFr}
onChange={(e) => setContentFr(e.target.value)}
placeholder="Contenu (FR)"
rows={4}
/>
)} )}
<input
type="number" <div>
value={duration} <label style={{ display: "block", fontSize: 12, color: "#94a3b8", marginBottom: 4 }}>Durée (secondes)</label>
onChange={(e) => setDuration(e.target.value)} <input type="number" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="ex: 360" style={{ width: 140 }} />
placeholder="Durée (secondes)" </div>
/>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button onClick={handleSave} className="btn btn-primary" style={{ fontSize: 12 }}> <button onClick={handleSave} className="btn btn-primary" style={{ fontSize: 12 }}>{t.save}</button>
{t.save} <button onClick={() => setEditing(false)} className="btn btn-secondary" style={{ fontSize: 12 }}>{t.cancel}</button>
</button>
<button onClick={() => setEditing(false)} className="btn btn-secondary" style={{ fontSize: 12 }}>
{t.cancel}
</button>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function QuizEditor({ // ─── QuizEditor ───────────────────────────────────────────────────────────────
quiz,
onSave, function QuizEditor({ quiz, onSave, t }: {
t,
}: {
quiz?: Quiz | null; quiz?: Quiz | null;
onSave: (passMark: number, questions: Omit<Question, "id">[]) => void; onSave: (passMark: number, questions: Omit<Question, "id">[]) => void;
t: Record<string, string>; t: Record<string, string>;
}) { }) {
const [passMark, setPassMark] = useState(quiz?.passMark ?? 80); const [passMark, setPassMark] = useState(quiz?.passMark ?? 80);
const [questions, setQuestions] = useState<Omit<Question, "id">[]>( const [questions, setQuestions] = useState<Omit<Question, "id">[]>(
quiz?.questions.map((q) => ({ quiz?.questions.map((q) => ({ textFr: q.textFr, textEn: q.textEn, textEs: q.textEs, optionsFr: q.optionsFr, optionsEn: q.optionsEn, optionsEs: q.optionsEs, correctIndex: q.correctIndex, order: q.order })) ?? []
textFr: q.textFr,
textEn: q.textEn,
textEs: q.textEs,
optionsFr: q.optionsFr,
optionsEn: q.optionsEn,
optionsEs: q.optionsEs,
correctIndex: q.correctIndex,
order: q.order,
})) ?? []
); );
const [lang, setLang] = useState<"fr" | "en" | "es">("fr");
const [saving, setSaving] = useState(false);
const addQuestion = () => { const addQuestion = () => {
setQuestions((prev) => [ setQuestions((prev) => [...prev, { textFr: "", textEn: "", textEs: "", optionsFr: ["", "", "", ""], optionsEn: ["", "", "", ""], optionsEs: ["", "", "", ""], correctIndex: 0, order: prev.length }]);
...prev,
{
textFr: "",
textEn: "",
textEs: "",
optionsFr: ["", "", "", ""],
optionsEn: ["", "", "", ""],
optionsEs: ["", "", "", ""],
correctIndex: 0,
order: prev.length,
},
]);
}; };
const removeQuestion = (i: number) => { const removeQuestion = (i: number) => setQuestions((prev) => prev.filter((_, idx) => idx !== i));
setQuestions((prev) => prev.filter((_, idx) => idx !== i));
const updateField = (i: number, field: string, value: any) =>
setQuestions((prev) => prev.map((q, idx) => idx === i ? { ...q, [field]: value } : q));
const updateOption = (qi: number, oi: number, field: "optionsFr" | "optionsEn" | "optionsEs", value: string) => {
setQuestions((prev) => prev.map((q, idx) => {
if (idx !== qi) return q;
const opts = [...q[field]];
opts[oi] = value;
return { ...q, [field]: opts };
}));
}; };
const updateQuestion = (i: number, field: string, value: any) => { const handleSave = async () => {
setQuestions((prev) => setSaving(true);
prev.map((q, idx) => (idx === i ? { ...q, [field]: value } : q)) await onSave(passMark, questions);
); setSaving(false);
}; };
const updateOption = (qi: number, oi: number, value: string) => { const textField = lang === "fr" ? "textFr" : lang === "en" ? "textEn" : "textEs";
setQuestions((prev) => const optsField = lang === "fr" ? "optionsFr" : lang === "en" ? "optionsEn" : "optionsEs";
prev.map((q, idx) => {
if (idx !== qi) return q;
const opts = [...q.optionsFr];
opts[oi] = value;
return { ...q, optionsFr: opts };
})
);
};
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<label style={{ fontSize: 13, color: "#94a3b8" }}>Note minimale (%)</label> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<input <label style={{ fontSize: 13, color: "#94a3b8" }}>Note minimale (%)</label>
type="number" <input type="number" value={passMark} onChange={(e) => setPassMark(parseInt(e.target.value))} style={{ width: 80 }} min={0} max={100} />
value={passMark} </div>
onChange={(e) => setPassMark(parseInt(e.target.value))} <div style={{ display: "flex", gap: 4 }}>
style={{ width: 80 }} {(["fr", "en", "es"] as const).map((l) => (
min={0} <button key={l} type="button" onClick={() => setLang(l)}
max={100} style={{ padding: "4px 10px", borderRadius: 6, fontSize: 11, fontWeight: 600, border: "1px solid", cursor: "pointer", background: lang === l ? "#1d4ed8" : "transparent", borderColor: lang === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: lang === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
/> {l}
</button>
))}
</div>
</div> </div>
{questions.map((q, qi) => ( {questions.map((q, qi) => (
<div <div key={qi} style={{ background: "rgba(245,158,11,0.05)", border: "1px solid rgba(245,158,11,0.2)", borderRadius: 8, padding: 14 }}>
key={qi}
style={{
background: "rgba(245,158,11,0.05)",
border: "1px solid rgba(245,158,11,0.2)",
borderRadius: 8,
padding: 14,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}> <div style={{ display: "flex", justifyContent: "space-between", marginBottom: 10 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: "#fbbf24" }}> <span style={{ fontSize: 13, fontWeight: 600, color: "#fbbf24" }}>Question {qi + 1}</span>
Question {qi + 1} <button onClick={() => removeQuestion(qi)} className="btn btn-danger" style={{ fontSize: 11, padding: "2px 8px" }}>{t.delete}</button>
</span>
<button onClick={() => removeQuestion(qi)} className="btn btn-danger" style={{ fontSize: 11, padding: "2px 8px" }}>
{t.delete}
</button>
</div> </div>
<input <input
type="text" type="text"
value={q.textFr} value={(q as any)[textField]}
onChange={(e) => updateQuestion(qi, "textFr", e.target.value)} onChange={(e) => updateField(qi, textField, e.target.value)}
placeholder="Question (FR)" placeholder={`Question (${lang.toUpperCase()})`}
style={{ marginBottom: 8 }} style={{ marginBottom: 8 }}
/> />
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{q.optionsFr.map((opt, oi) => ( {(q as any)[optsField].map((opt: string, oi: number) => (
<div key={oi} style={{ display: "flex", gap: 6, alignItems: "center" }}> <div key={oi} style={{ display: "flex", gap: 6, alignItems: "center" }}>
<input <input type="radio" name={`correct-${qi}`} checked={q.correctIndex === oi} onChange={() => updateField(qi, "correctIndex", oi)} style={{ accentColor: "#22c55e" }} />
type="radio"
name={`correct-${qi}`}
checked={q.correctIndex === oi}
onChange={() => updateQuestion(qi, "correctIndex", oi)}
style={{ accentColor: "#22c55e" }}
/>
<input <input
type="text" type="text"
value={opt} value={opt}
onChange={(e) => updateOption(qi, oi, e.target.value)} onChange={(e) => updateOption(qi, oi, optsField as any, e.target.value)}
placeholder={`Option ${oi + 1}`} placeholder={`Option ${oi + 1}`}
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
@ -576,15 +578,9 @@ function QuizEditor({
))} ))}
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<button onClick={addQuestion} className="btn btn-secondary" style={{ fontSize: 12 }}> <button onClick={addQuestion} className="btn btn-secondary" style={{ fontSize: 12 }}>+ {t.add_question}</button>
+ {t.add_question} <button onClick={handleSave} disabled={saving} className="btn btn-primary" style={{ fontSize: 12 }}>
</button> {saving ? "…" : `${t.save} Quiz`}
<button
onClick={() => onSave(passMark, questions)}
className="btn btn-primary"
style={{ fontSize: 12 }}
>
{t.save} Quiz
</button> </button>
</div> </div>
</div> </div>

View File

@ -20,18 +20,20 @@ export default async function AdminDashboardPage({
redirect(`/${locale}/dashboard`); redirect(`/${locale}/dashboard`);
} }
const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions] = const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions, totalPaths] =
await Promise.all([ await Promise.all([
db.course.count(), db.course.count(),
db.course.count({ where: { published: true } }), db.course.count({ where: { published: true } }),
db.user.count({ where: { role: "LEARNER" } }), db.user.count({ where: { role: "LEARNER" } }),
db.enrollment.count(), db.enrollment.count(),
db.enrollment.count({ where: { completedAt: { not: null } } }), db.enrollment.count({ where: { completedAt: { not: null } } }),
db.learningPath.count(),
]); ]);
const stats = [ const stats = [
{ label: "Formations totales", value: totalCourses, sub: `${publishedCourses} publiées`, icon: "📚", href: `/${locale}/admin/courses`, color: "#1d4ed8" }, { label: "Formations totales", value: totalCourses, sub: `${publishedCourses} publiées`, icon: "📚", href: `/${locale}/admin/courses`, color: "#1d4ed8" },
{ label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#8b5cf6" }, { label: "Parcours", value: totalPaths, sub: "programmes complets", icon: "🗺️", href: `/${locale}/admin/paths`, color: "#8b5cf6" },
{ label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#06b6d4" },
{ label: "Complétions", value: totalCompletions, sub: "formations terminées", icon: "🎓", href: null, color: "#22c55e" }, { label: "Complétions", value: totalCompletions, sub: "formations terminées", icon: "🎓", href: null, color: "#22c55e" },
]; ];
@ -50,7 +52,7 @@ export default async function AdminDashboardPage({
<div <div
style={{ style={{
display: "grid", display: "grid",
gridTemplateColumns: "repeat(3, 1fr)", gridTemplateColumns: "repeat(4, 1fr)",
gap: 16, gap: 16,
marginBottom: 40, marginBottom: 40,
}} }}
@ -126,6 +128,20 @@ export default async function AdminDashboardPage({
> >
{t("courses")} {t("courses")}
</Link> </Link>
<Link
href={`/${locale}/admin/paths/new`}
className="btn btn-secondary"
style={{ justifyContent: "center" }}
>
+ Nouveau parcours
</Link>
<Link
href={`/${locale}/admin/paths`}
className="btn btn-secondary"
style={{ justifyContent: "center" }}
>
🗺 Parcours
</Link>
<Link <Link
href={`/${locale}/admin/students`} href={`/${locale}/admin/students`}
className="btn btn-secondary" className="btn btn-secondary"

View File

@ -0,0 +1,57 @@
"use client";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
export function AdminPathActions({
pathId,
isPublished,
editHref,
}: {
pathId: string;
isPublished: boolean;
editHref: string;
}) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const togglePublish = async () => {
setLoading(true);
await fetch(`/api/admin/paths/${pathId}/publish`, { method: "PUT" });
router.refresh();
setLoading(false);
};
const handleDelete = async () => {
if (!confirm("Supprimer ce parcours définitivement ?")) return;
setLoading(true);
await fetch(`/api/admin/paths/${pathId}`, { method: "DELETE" });
router.refresh();
setLoading(false);
};
return (
<div style={{ display: "flex", gap: 6 }}>
<Link href={editHref} className="btn btn-secondary" style={{ fontSize: 12, padding: "4px 10px" }}>
Modifier
</Link>
<button
onClick={togglePublish}
disabled={loading}
className="btn btn-secondary"
style={{ fontSize: 12, padding: "4px 10px", color: isPublished ? "#fbbf24" : "#4ade80" }}
>
{isPublished ? "Dépublier" : "Publier"}
</button>
<button
onClick={handleDelete}
disabled={loading}
className="btn btn-danger"
style={{ fontSize: 12, padding: "4px 10px" }}
>
Supprimer
</button>
</div>
);
}

View File

@ -0,0 +1,154 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface PathFormProps {
locale: string;
path?: {
id: string;
titleFr: string;
titleEn: string;
titleEs: string;
descFr: string;
descEn: string;
descEs: string;
slug: string;
published: boolean;
};
}
const LANG_TABS = ["fr", "en", "es"] as const;
function slugify(text: string) {
return text
.toLowerCase()
.replace(/[éèêë]/g, "e")
.replace(/[àâä]/g, "a")
.replace(/[îï]/g, "i")
.replace(/[ôö]/g, "o")
.replace(/[ùûü]/g, "u")
.replace(/[ç]/g, "c")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export function PathForm({ locale, path }: PathFormProps) {
const [langTab, setLangTab] = useState<"fr" | "en" | "es">("fr");
const [titleFr, setTitleFr] = useState(path?.titleFr ?? "");
const [titleEn, setTitleEn] = useState(path?.titleEn ?? "");
const [titleEs, setTitleEs] = useState(path?.titleEs ?? "");
const [descFr, setDescFr] = useState(path?.descFr ?? "");
const [descEn, setDescEn] = useState(path?.descEn ?? "");
const [descEs, setDescEs] = useState(path?.descEs ?? "");
const [slug, setSlug] = useState(path?.slug ?? "");
const [published, setPublished] = useState(path?.published ?? false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const router = useRouter();
const handleTitleFrChange = (v: string) => {
setTitleFr(v);
if (!path) setSlug(slugify(v));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setError("");
const body = { titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, descFr, descEn: descEn || descFr, descEs: descEs || descFr, slug, published };
const res = path
? await fetch(`/api/admin/paths/${path.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
: await fetch("/api/admin/paths", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
setSaving(false);
if (!res.ok) {
const data = await res.json();
setError(data.error ?? "Erreur");
return;
}
const data = await res.json();
router.push(`/${locale}/admin/paths/${data.id}`);
};
return (
<form onSubmit={handleSubmit}>
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div className="card">
<div style={{ display: "flex", gap: 4, marginBottom: 20 }}>
{LANG_TABS.map((l) => (
<button key={l} type="button" onClick={() => setLangTab(l)}
style={{ padding: "6px 16px", borderRadius: 6, fontSize: 13, fontWeight: 600, border: "1px solid", cursor: "pointer", background: langTab === l ? "#1d4ed8" : "transparent", borderColor: langTab === l ? "#1d4ed8" : "rgba(255,255,255,0.1)", color: langTab === l ? "#fff" : "#94a3b8", textTransform: "uppercase" }}>
{l}
</button>
))}
</div>
{langTab === "fr" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Titre du parcours (FR) *</label>
<input type="text" value={titleFr} onChange={(e) => handleTitleFrChange(e.target.value)} required />
</div>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Description (FR) *</label>
<textarea value={descFr} onChange={(e) => setDescFr(e.target.value)} rows={5} required />
</div>
</div>
)}
{langTab === "en" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Path title (EN)</label>
<input type="text" value={titleEn} onChange={(e) => setTitleEn(e.target.value)} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Description (EN)</label>
<textarea value={descEn} onChange={(e) => setDescEn(e.target.value)} rows={5} />
</div>
</div>
)}
{langTab === "es" && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Título (ES)</label>
<input type="text" value={titleEs} onChange={(e) => setTitleEs(e.target.value)} />
</div>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Descripción (ES)</label>
<textarea value={descEs} onChange={(e) => setDescEs(e.target.value)} rows={5} />
</div>
</div>
)}
</div>
<div className="card">
<h3 style={{ fontSize: 15, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>Métadonnées</h3>
<div>
<label style={{ display: "block", fontSize: 13, color: "#94a3b8", marginBottom: 6 }}>Slug *</label>
<input type="text" value={slug} onChange={(e) => setSlug(e.target.value)} required />
</div>
<div style={{ marginTop: 14, display: "flex", alignItems: "center", gap: 10 }}>
<input type="checkbox" id="published" checked={published} onChange={(e) => setPublished(e.target.checked)} style={{ width: 16, height: 16, accentColor: "#1d4ed8" }} />
<label htmlFor="published" style={{ fontSize: 14, color: "#f1f5f9", cursor: "pointer" }}>Publier le parcours</label>
</div>
</div>
{error && (
<p style={{ color: "#f87171", fontSize: 13, background: "rgba(239,68,68,0.1)", padding: "10px 14px", borderRadius: 6, border: "1px solid rgba(239,68,68,0.3)" }}>{error}</p>
)}
<div style={{ display: "flex", gap: 10 }}>
<button type="submit" disabled={saving} className="btn btn-primary">
{saving ? "…" : path ? "Enregistrer" : "Créer le parcours"}
</button>
<button type="button" onClick={() => router.back()} className="btn btn-secondary">Annuler</button>
</div>
</div>
</form>
);
}

View File

@ -0,0 +1,159 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
interface Course {
id: string;
titleFr: string;
published: boolean;
}
interface PathCourse {
id: string;
courseId: string;
order: number;
course: Course;
}
export function PathCourseManager({
pathId,
pathCourses: initial,
allCourses,
}: {
pathId: string;
pathCourses: PathCourse[];
allCourses: Course[];
}) {
const [pathCourses, setPathCourses] = useState(initial);
const [selectedCourseId, setSelectedCourseId] = useState("");
const [loading, setLoading] = useState(false);
const router = useRouter();
const addedIds = new Set(pathCourses.map((pc) => pc.courseId));
const availableCourses = allCourses.filter((c) => !addedIds.has(c.id));
const addCourse = async () => {
if (!selectedCourseId) return;
setLoading(true);
try {
const res = await fetch(`/api/admin/paths/${pathId}/courses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId: selectedCourseId }),
});
const lpc = await res.json();
setPathCourses((prev) => [...prev, lpc]);
setSelectedCourseId("");
} finally {
setLoading(false);
}
};
const removeCourse = async (courseId: string) => {
if (!confirm("Retirer ce cours du parcours ?")) return;
setLoading(true);
try {
await fetch(`/api/admin/paths/${pathId}/courses`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId }),
});
setPathCourses((prev) => prev.filter((pc) => pc.courseId !== courseId));
} finally {
setLoading(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{pathCourses.length === 0 ? (
<div
style={{
padding: "24px",
textAlign: "center",
background: "#1a1f2e",
borderRadius: 8,
border: "1px dashed rgba(255,255,255,0.1)",
color: "#64748b",
fontSize: 14,
}}
>
Aucune formation dans ce parcours.
</div>
) : (
pathCourses.map((pc, i) => (
<div
key={pc.id}
style={{
display: "flex",
alignItems: "center",
gap: 14,
padding: "12px 16px",
background: "#1a1f2e",
borderRadius: 8,
border: "1px solid rgba(255,255,255,0.08)",
}}
>
<span
style={{
width: 28,
height: 28,
background: "rgba(29,78,216,0.2)",
color: "#60a5fa",
borderRadius: 6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
}}
>
{i + 1}
</span>
<div style={{ flex: 1 }}>
<p style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{pc.course.titleFr}</p>
{!pc.course.published && (
<span style={{ fontSize: 11, color: "#94a3b8" }}>Brouillon</span>
)}
</div>
<button
onClick={() => removeCourse(pc.courseId)}
disabled={loading}
className="btn btn-danger"
style={{ fontSize: 12, padding: "4px 10px" }}
>
Retirer
</button>
</div>
))
)}
{availableCourses.length > 0 && (
<div style={{ display: "flex", gap: 10, marginTop: 8 }}>
<select
value={selectedCourseId}
onChange={(e) => setSelectedCourseId(e.target.value)}
style={{ flex: 1, fontSize: 14 }}
>
<option value=""> Ajouter une formation </option>
{availableCourses.map((c) => (
<option key={c.id} value={c.id}>
{c.titleFr}{!c.published ? " (brouillon)" : ""}
</option>
))}
</select>
<button
onClick={addCourse}
disabled={loading || !selectedCourseId}
className="btn btn-primary"
style={{ opacity: !selectedCourseId ? 0.6 : 1 }}
>
Ajouter
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,66 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { PathForm } from "../PathForm";
import { PathCourseManager } from "./PathCourseManager";
export const dynamic = "force-dynamic";
export default async function EditPathPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const [path, allCourses] = await Promise.all([
db.learningPath.findUnique({
where: { id },
include: {
courses: {
include: { course: true },
orderBy: { order: "asc" },
},
},
}),
db.course.findMany({ orderBy: { titleFr: "asc" }, select: { id: true, titleFr: true, published: true } }),
]);
if (!path) notFound();
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 32 }}>
<Link href={`/${locale}/admin/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
Parcours
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
{path.titleFr}
</h1>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
<PathForm locale={locale} path={path} />
<div>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>
Formations du parcours
</h2>
<PathCourseManager
pathId={path.id}
pathCourses={path.courses.map((lpc) => ({ id: lpc.id, courseId: lpc.courseId, order: lpc.order, course: lpc.course }))}
allCourses={allCourses}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { PathForm } from "../PathForm";
export default async function NewPathPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 32 }}>
<Link href={`/${locale}/admin/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
Parcours
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
Nouveau parcours
</h1>
</div>
<PathForm locale={locale} />
</div>
);
}

View File

@ -0,0 +1,108 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { AdminPathActions } from "./AdminPathActions";
export const dynamic = "force-dynamic";
export default async function AdminPathsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const paths = await db.learningPath.findMany({
orderBy: { order: "asc" },
include: {
courses: true,
_count: { select: { enrollments: true } },
},
});
return (
<div style={{ maxWidth: 1200, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 32 }}>
<div>
<Link href={`/${locale}/admin`} style={{ fontSize: 12, color: "#60a5fa" }}>
Administration
</Link>
<h1 style={{ fontSize: 24, fontWeight: 800, color: "#f1f5f9", marginTop: 4 }}>
Parcours de formation
</h1>
</div>
<Link href={`/${locale}/admin/paths/new`} className="btn btn-primary">
+ Nouveau parcours
</Link>
</div>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<table>
<thead>
<tr>
<th>Titre (FR)</th>
<th>Formations</th>
<th>Inscrits</th>
<th>Statut</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{paths.length === 0 ? (
<tr>
<td colSpan={5} style={{ textAlign: "center", color: "#94a3b8", padding: "48px" }}>
Aucun parcours.{" "}
<Link href={`/${locale}/admin/paths/new`} style={{ color: "#60a5fa" }}>
Créer le premier.
</Link>
</td>
</tr>
) : (
paths.map((path) => (
<tr key={path.id}>
<td>
<div>
<p style={{ fontWeight: 600, color: "#f1f5f9", fontSize: 14 }}>{path.titleFr}</p>
<p style={{ fontSize: 11, color: "#475569" }}>{path.slug}</p>
</div>
</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{path.courses.length} cours</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{path._count.enrollments}</td>
<td>
<span
style={{
fontSize: 12,
fontWeight: 600,
padding: "3px 10px",
borderRadius: 999,
background: path.published ? "rgba(34,197,94,0.15)" : "rgba(148,163,184,0.1)",
color: path.published ? "#4ade80" : "#94a3b8",
}}
>
{path.published ? "Publié" : "Brouillon"}
</span>
</td>
<td>
<AdminPathActions
pathId={path.id}
isPublished={path.published}
editHref={`/${locale}/admin/paths/${path.id}`}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,217 @@
import { redirect, notFound } from "next/navigation";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { ProgressBar } from "@/components/ProgressBar";
export const dynamic = "force-dynamic";
export default async function StudentDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") {
redirect(`/${locale}/dashboard`);
}
const student = await db.user.findUnique({
where: { id },
include: {
enrollments: {
include: {
course: {
include: {
modules: { include: { lessons: { select: { id: true } } } },
},
},
},
orderBy: { enrolledAt: "desc" },
},
certificates: { include: { course: true }, orderBy: { issuedAt: "desc" } },
quizAttempts: { include: { quiz: { include: { module: { include: { course: true } } } } }, orderBy: { completedAt: "desc" } },
learningPathEnrollments: { include: { learningPath: true } },
},
});
if (!student) notFound();
// Compute progress for each enrollment
const enriched = await Promise.all(
student.enrollments.map(async (e) => {
const allLessons = e.course.modules.flatMap((m) => m.lessons);
let progress = 0;
if (allLessons.length > 0) {
const done = await db.lessonProgress.count({
where: { userId: student.id, lessonId: { in: allLessons.map((l) => l.id) } },
});
progress = Math.round((done / allLessons.length) * 100);
}
return { ...e, progress, totalLessons: allLessons.length };
})
);
return (
<div style={{ maxWidth: 1000, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 32 }}>
<Link href={`/${locale}/admin/students`} style={{ fontSize: 12, color: "#60a5fa" }}>
Étudiants
</Link>
<div style={{ display: "flex", alignItems: "center", gap: 16, marginTop: 12 }}>
<div
style={{
width: 52,
height: 52,
background: "linear-gradient(135deg, #1e3a8a, #1d4ed8)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 22,
fontWeight: 700,
color: "#fff",
flexShrink: 0,
}}
>
{(student.name || student.email)[0].toUpperCase()}
</div>
<div>
<h1 style={{ fontSize: 22, fontWeight: 800, color: "#f1f5f9" }}>
{student.name || "—"}
</h1>
<p style={{ fontSize: 14, color: "#64748b" }}>{student.email}</p>
</div>
</div>
</div>
{/* Stats */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: 12, marginBottom: 32 }}>
{[
{ label: "Formations inscrites", value: student.enrollments.length, color: "#1d4ed8" },
{ label: "Formations terminées", value: enriched.filter((e) => e.completedAt).length, color: "#22c55e" },
{ label: "Certificats", value: student.certificates.length, color: "#f59e0b" },
{ label: "Parcours", value: student.learningPathEnrollments.length, color: "#8b5cf6" },
].map((s) => (
<div key={s.label} className="card" style={{ textAlign: "center" }}>
<div style={{ fontSize: 28, fontWeight: 800, color: s.color, marginBottom: 4 }}>{s.value}</div>
<div style={{ fontSize: 12, color: "#94a3b8" }}>{s.label}</div>
</div>
))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 32 }}>
{/* Enrollments */}
<section>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Formations</h2>
{enriched.length === 0 ? (
<p style={{ color: "#64748b", fontSize: 14 }}>Aucune inscription.</p>
) : (
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<table>
<thead>
<tr>
<th>Formation</th>
<th>Progression</th>
<th>Leçons</th>
<th>Statut</th>
<th>Inscrit le</th>
</tr>
</thead>
<tbody>
{enriched.map((e) => (
<tr key={e.id}>
<td>
<Link href={`/${locale}/admin/courses/${e.course.id}`} style={{ color: "#f1f5f9", fontWeight: 600, fontSize: 14 }}>
{e.course.titleFr}
</Link>
</td>
<td style={{ width: 160 }}>
<ProgressBar value={e.progress} />
<span style={{ fontSize: 11, color: "#94a3b8" }}>{e.progress}%</span>
</td>
<td style={{ color: "#94a3b8", fontSize: 13 }}>{e.totalLessons}</td>
<td>
<span
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
borderRadius: 999,
background: e.completedAt ? "rgba(34,197,94,0.15)" : "rgba(148,163,184,0.1)",
color: e.completedAt ? "#4ade80" : "#94a3b8",
}}
>
{e.completedAt ? "Terminé" : "En cours"}
</span>
</td>
<td style={{ color: "#64748b", fontSize: 13 }}>
{e.enrolledAt.toLocaleDateString("fr-FR")}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Learning paths */}
{student.learningPathEnrollments.length > 0 && (
<section>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Parcours</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{student.learningPathEnrollments.map((lpe) => (
<div key={lpe.id} className="card" style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>{lpe.learningPath.titleFr}</span>
<span style={{ fontSize: 12, color: "#64748b" }}>Inscrit le {lpe.enrolledAt.toLocaleDateString("fr-FR")}</span>
</div>
))}
</div>
</section>
)}
{/* Recent quiz attempts */}
{student.quizAttempts.length > 0 && (
<section>
<h2 style={{ fontSize: 18, fontWeight: 700, color: "#f1f5f9", marginBottom: 14 }}>Tentatives de quiz</h2>
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
<table>
<thead>
<tr>
<th>Formation · Module</th>
<th>Score</th>
<th>Résultat</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{student.quizAttempts.slice(0, 20).map((qa) => (
<tr key={qa.id}>
<td style={{ fontSize: 13, color: "#cbd5e1" }}>
{qa.quiz.module.course.titleFr} · {qa.quiz.module.titleFr}
</td>
<td style={{ fontSize: 14, fontWeight: 700, color: qa.passed ? "#4ade80" : "#f87171" }}>
{qa.score}%
</td>
<td>
<span style={{ fontSize: 11, fontWeight: 600, padding: "2px 8px", borderRadius: 999, background: qa.passed ? "rgba(34,197,94,0.15)" : "rgba(239,68,68,0.15)", color: qa.passed ? "#4ade80" : "#f87171" }}>
{qa.passed ? "Réussi" : "Échoué"}
</span>
</td>
<td style={{ fontSize: 12, color: "#64748b" }}>{qa.completedAt.toLocaleDateString("fr-FR")}</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
</div>
);
}

View File

@ -95,7 +95,7 @@ export default async function AdminStudentsPage({
students.map((student) => ( students.map((student) => (
<tr key={student.id}> <tr key={student.id}>
<td> <td>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}> <Link href={`/${locale}/admin/students/${student.id}`} style={{ display: "flex", alignItems: "center", gap: 10, textDecoration: "none" }}>
{student.image ? ( {student.image ? (
<img <img
src={student.image} src={student.image}
@ -123,7 +123,7 @@ export default async function AdminStudentsPage({
<span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}> <span style={{ fontSize: 14, fontWeight: 600, color: "#f1f5f9" }}>
{student.name ?? "—"} {student.name ?? "—"}
</span> </span>
</div> </Link>
</td> </td>
<td style={{ color: "#94a3b8", fontSize: 13 }}>{student.email}</td> <td style={{ color: "#94a3b8", fontSize: 13 }}>{student.email}</td>
<td style={{ color: "#f1f5f9", fontSize: 14 }}>{student._count.enrollments}</td> <td style={{ color: "#f1f5f9", fontSize: 14 }}>{student._count.enrollments}</td>

View File

@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function EnrollPathButton({
pathId,
locale,
isLoggedIn,
}: {
pathId: string;
locale: string;
isLoggedIn: boolean;
}) {
const [loading, setLoading] = useState(false);
const router = useRouter();
const handleEnroll = async () => {
if (!isLoggedIn) {
router.push(`/${locale}/auth/login`);
return;
}
setLoading(true);
await fetch("/api/paths/enroll", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ learningPathId: pathId }),
});
router.refresh();
setLoading(false);
};
return (
<button
onClick={handleEnroll}
disabled={loading}
className="btn btn-primary"
style={{ width: "100%", justifyContent: "center", fontSize: 14, opacity: loading ? 0.7 : 1 }}
>
{loading ? "…" : isLoggedIn ? "S'inscrire gratuitement" : "Se connecter pour s'inscrire"}
</button>
);
}

View File

@ -0,0 +1,196 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { setRequestLocale } from "next-intl/server";
import { notFound } from "next/navigation";
import Link from "next/link";
import { EnrollPathButton } from "../EnrollPathButton";
import { ProgressBar } from "@/components/ProgressBar";
export const dynamic = "force-dynamic";
function getLocaleText(obj: any, locale: string, field: string) {
if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
return obj[`${field}Fr`];
}
const levelLabel: Record<string, string> = {
BEGINNER: "Débutant",
INTERMEDIATE: "Intermédiaire",
ADVANCED: "Avancé",
};
export default async function PathDetailPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
setRequestLocale(locale);
const session = await auth();
const userId = (session?.user as any)?.id as string | undefined;
const path = await db.learningPath.findUnique({
where: { slug, published: true },
include: {
courses: {
include: {
course: {
include: {
modules: { include: { lessons: { select: { id: true } } } },
},
},
},
orderBy: { order: "asc" },
},
_count: { select: { enrollments: true } },
},
});
if (!path) notFound();
const isEnrolled = userId
? !!(await db.learningPathEnrollment.findUnique({ where: { userId_learningPathId: { userId, learningPathId: path.id } } }))
: false;
// Get enrollment + progress for each course
const courseProgress: Record<string, { enrolled: boolean; progress: number; completedAt: Date | null }> = {};
if (userId) {
for (const lpc of path.courses) {
const enrollment = await db.enrollment.findUnique({ where: { userId_courseId: { userId, courseId: lpc.courseId } } });
const allLessons = lpc.course.modules.flatMap((m) => m.lessons);
let progress = 0;
if (allLessons.length > 0 && enrollment) {
const done = await db.lessonProgress.count({ where: { userId, lessonId: { in: allLessons.map((l) => l.id) } } });
progress = Math.round((done / allLessons.length) * 100);
}
courseProgress[lpc.courseId] = { enrolled: !!enrollment, progress, completedAt: enrollment?.completedAt ?? null };
}
}
const title = getLocaleText(path, locale, "title");
const desc = getLocaleText(path, locale, "desc");
const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
return (
<div style={{ maxWidth: 900, margin: "0 auto", padding: "40px 24px" }}>
<Link href={`/${locale}/paths`} style={{ fontSize: 12, color: "#60a5fa" }}>
Parcours
</Link>
{/* Header */}
<div style={{ marginTop: 16, marginBottom: 40, display: "grid", gridTemplateColumns: "1fr auto", gap: 32, alignItems: "start" }}>
<div>
<h1 style={{ fontSize: 30, fontWeight: 800, color: "#f1f5f9", marginBottom: 12 }}>{title}</h1>
<p style={{ color: "#94a3b8", fontSize: 15, lineHeight: 1.7, marginBottom: 20 }}>{desc}</p>
<div style={{ display: "flex", gap: 20, fontSize: 14, color: "#64748b" }}>
<span>📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}</span>
<span>🎬 {totalLessons} leçons</span>
<span>👥 {path._count.enrollments} inscrits</span>
</div>
</div>
<div style={{ minWidth: 180 }}>
{isEnrolled ? (
<div
style={{
background: "rgba(34,197,94,0.1)",
border: "1px solid rgba(34,197,94,0.3)",
borderRadius: 8,
padding: "12px 20px",
textAlign: "center",
fontSize: 14,
color: "#4ade80",
fontWeight: 600,
}}
>
Inscrit à ce parcours
</div>
) : (
<EnrollPathButton pathId={path.id} locale={locale} isLoggedIn={!!session} />
)}
</div>
</div>
{/* Course list */}
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9", marginBottom: 16 }}>Formations incluses</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{path.courses.map((lpc, i) => {
const courseTitle = getLocaleText(lpc.course, locale, "title");
const courseDesc = getLocaleText(lpc.course, locale, "desc");
const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
const cp = courseProgress[lpc.courseId];
return (
<div
key={lpc.id}
className="card"
style={{ display: "grid", gridTemplateColumns: "auto 1fr auto", gap: 16, alignItems: "center" }}
>
{/* Step number */}
<div
style={{
width: 40,
height: 40,
background: cp?.completedAt ? "rgba(34,197,94,0.2)" : "rgba(29,78,216,0.2)",
color: cp?.completedAt ? "#4ade80" : "#60a5fa",
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
fontWeight: 800,
flexShrink: 0,
}}
>
{cp?.completedAt ? "✓" : i + 1}
</div>
{/* Info */}
<div>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 4 }}>
<p style={{ fontSize: 16, fontWeight: 600, color: "#f1f5f9" }}>{courseTitle}</p>
<span style={{ fontSize: 11, color: "#94a3b8", background: "rgba(255,255,255,0.05)", borderRadius: 4, padding: "2px 6px" }}>
{levelLabel[lpc.course.level] ?? lpc.course.level}
</span>
</div>
<p style={{ fontSize: 13, color: "#64748b", marginBottom: cp ? 8 : 0, lineHeight: 1.5 }}>
{courseDesc?.slice(0, 120)}{courseDesc?.length > 120 ? "…" : ""}
</p>
{cp?.enrolled && (
<>
<ProgressBar value={cp.progress} />
<span style={{ fontSize: 12, color: "#94a3b8", display: "block", marginTop: 4 }}>{cp.progress}% complété</span>
</>
)}
<p style={{ fontSize: 12, color: "#475569", marginTop: 4 }}>
{lpc.course.modules.length} module{lpc.course.modules.length !== 1 ? "s" : ""} · {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}
</p>
</div>
{/* Action */}
<div>
{cp?.enrolled ? (
<Link
href={`/${locale}/courses/${lpc.course.slug}/learn`}
className="btn btn-primary"
style={{ fontSize: 13, whiteSpace: "nowrap" }}
>
{cp.completedAt ? "Revoir" : cp.progress > 0 ? "Continuer →" : "Commencer →"}
</Link>
) : (
<Link
href={`/${locale}/courses/${lpc.course.slug}`}
className="btn btn-secondary"
style={{ fontSize: 13, whiteSpace: "nowrap" }}
>
Voir la formation
</Link>
)}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@ -0,0 +1,159 @@
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { setRequestLocale } from "next-intl/server";
import Link from "next/link";
import { EnrollPathButton } from "./EnrollPathButton";
export const dynamic = "force-dynamic";
function getLocaleText(obj: any, locale: string, field: string) {
if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
return obj[`${field}Fr`];
}
const levelColors: Record<string, string> = {
BEGINNER: "#4ade80",
INTERMEDIATE: "#fbbf24",
ADVANCED: "#f87171",
};
export default async function PathsPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const session = await auth();
const userId = (session?.user as any)?.id as string | undefined;
const paths = await db.learningPath.findMany({
where: { published: true },
orderBy: { order: "asc" },
include: {
courses: {
include: {
course: {
include: { modules: { include: { lessons: { select: { id: true } } } } },
},
},
orderBy: { order: "asc" },
},
_count: { select: { enrollments: true } },
},
});
// Check which paths the user is enrolled in
const enrolledPathIds = userId
? new Set(
(await db.learningPathEnrollment.findMany({ where: { userId }, select: { learningPathId: true } })).map(
(e) => e.learningPathId
)
)
: new Set<string>();
return (
<div style={{ maxWidth: 1100, margin: "0 auto", padding: "40px 24px" }}>
<div style={{ marginBottom: 40 }}>
<h1 style={{ fontSize: 28, fontWeight: 800, color: "#f1f5f9", marginBottom: 8 }}>
Parcours de formation
</h1>
<p style={{ color: "#94a3b8", fontSize: 15 }}>
Des programmes complets pour maîtriser la GRC et l'application OwlCub.
</p>
</div>
{paths.length === 0 ? (
<div style={{ textAlign: "center", padding: "60px 24px", background: "#1a1f2e", borderRadius: 12, border: "1px solid rgba(255,255,255,0.08)" }}>
<p style={{ color: "#64748b", fontSize: 16 }}>Aucun parcours disponible pour l'instant.</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
{paths.map((path) => {
const title = getLocaleText(path, locale, "title");
const desc = getLocaleText(path, locale, "desc");
const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
const isEnrolled = enrolledPathIds.has(path.id);
return (
<div
key={path.id}
className="card"
style={{
display: "grid",
gridTemplateColumns: "1fr auto",
gap: 24,
background: "linear-gradient(135deg, #1a1f2e, #1e2a4a)",
border: "1px solid rgba(29,78,216,0.25)",
}}
>
<div>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 10 }}>
<h2 style={{ fontSize: 20, fontWeight: 700, color: "#f1f5f9" }}>{title}</h2>
{isEnrolled && (
<span style={{ fontSize: 11, fontWeight: 600, background: "rgba(34,197,94,0.15)", color: "#4ade80", border: "1px solid rgba(34,197,94,0.3)", borderRadius: 4, padding: "2px 8px" }}>
Inscrit
</span>
)}
</div>
<p style={{ color: "#94a3b8", fontSize: 14, marginBottom: 16, lineHeight: 1.6 }}>{desc}</p>
{/* Course list */}
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginBottom: 16 }}>
{path.courses.map((lpc, i) => {
const courseTitle = getLocaleText(lpc.course, locale, "title");
const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
return (
<div key={lpc.id} style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ width: 22, height: 22, background: "rgba(29,78,216,0.3)", color: "#60a5fa", borderRadius: 4, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
{i + 1}
</span>
<span style={{ fontSize: 13, color: "#cbd5e1" }}>{courseTitle}</span>
<span style={{ fontSize: 11, color: "#475569" }}>· {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}</span>
<span style={{ fontSize: 11, fontWeight: 600, color: levelColors[lpc.course.level] ?? "#94a3b8" }}>
{lpc.course.level}
</span>
</div>
);
})}
</div>
<div style={{ display: "flex", gap: 16, fontSize: 13, color: "#64748b" }}>
<span>📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}</span>
<span>🎬 {totalLessons} leçons</span>
<span>👥 {path._count.enrollments} inscrits</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10, alignItems: "flex-end", justifyContent: "center", minWidth: 160 }}>
{isEnrolled ? (
<Link
href={`/${locale}/courses/${path.courses[0]?.course.slug ?? ""}/learn`}
className="btn btn-primary"
style={{ width: "100%", justifyContent: "center", fontSize: 14 }}
>
Continuer
</Link>
) : (
<EnrollPathButton
pathId={path.id}
locale={locale}
isLoggedIn={!!session}
/>
)}
<Link
href={`/${locale}/paths/${path.slug}`}
style={{ fontSize: 13, color: "#60a5fa", textAlign: "center", width: "100%" }}
>
Voir le détail
</Link>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@ -8,6 +8,29 @@ async function requireAdmin() {
return session; return session;
} }
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await requireAdmin())) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const body = await req.json();
const updated = await db.module.update({
where: { id },
data: {
titleFr: body.titleFr,
titleEn: body.titleEn ?? body.titleFr,
titleEs: body.titleEs ?? body.titleFr,
},
});
return NextResponse.json(updated);
}
export async function DELETE( export async function DELETE(
req: NextRequest, req: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }

View File

@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function requireAdmin() {
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
return session;
}
// Add a course to the learning path
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id: learningPathId } = await params;
const { courseId } = await req.json();
// Get current max order
const maxOrder = await db.learningPathCourse.count({ where: { learningPathId } });
const lpc = await db.learningPathCourse.create({
data: { learningPathId, courseId, order: maxOrder },
include: { course: true },
});
return NextResponse.json(lpc);
}
// Remove a course from the learning path
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id: learningPathId } = await params;
const { courseId } = await req.json();
await db.learningPathCourse.deleteMany({ where: { learningPathId, courseId } });
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function requireAdmin() {
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
return session;
}
export async function PUT(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const current = await db.learningPath.findUnique({ where: { id }, select: { published: true } });
if (!current) return NextResponse.json({ error: "Not found" }, { status: 404 });
const updated = await db.learningPath.update({
where: { id },
data: { published: !current.published },
});
return NextResponse.json(updated);
}

View File

@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function requireAdmin() {
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
return session;
}
export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const path = await db.learningPath.findUnique({
where: { id },
include: {
courses: {
include: { course: true },
orderBy: { order: "asc" },
},
},
});
if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(path);
}
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
const body = await req.json();
const path = await db.learningPath.update({
where: { id },
data: {
titleFr: body.titleFr,
titleEn: body.titleEn,
titleEs: body.titleEs,
descFr: body.descFr,
descEn: body.descEn,
descEs: body.descEs,
slug: body.slug,
},
});
return NextResponse.json(path);
}
export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const { id } = await params;
await db.learningPath.delete({ where: { id } });
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
async function requireAdmin() {
const session = await auth();
if (!session?.user || (session.user as any).role !== "ADMIN") return null;
return session;
}
function slugify(text: string) {
return text
.toLowerCase()
.replace(/[éèêë]/g, "e")
.replace(/[àâä]/g, "a")
.replace(/[îï]/g, "i")
.replace(/[ôö]/g, "o")
.replace(/[ùûü]/g, "u")
.replace(/[ç]/g, "c")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function GET() {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const paths = await db.learningPath.findMany({
orderBy: { order: "asc" },
include: {
courses: { include: { course: { select: { id: true, titleFr: true } } }, orderBy: { order: "asc" } },
_count: { select: { enrollments: true } },
},
});
return NextResponse.json(paths);
}
export async function POST(req: NextRequest) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = await req.json();
const { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug } = body;
if (!titleFr || !descFr) {
return NextResponse.json({ error: "Titre et description FR requis" }, { status: 400 });
}
const finalSlug = slug || slugify(titleFr);
const path = await db.learningPath.create({
data: {
titleFr,
titleEn: titleEn || titleFr,
titleEs: titleEs || titleFr,
descFr,
descEn: descEn || descFr,
descEs: descEs || descFr,
slug: finalSlug,
},
});
return NextResponse.json(path);
}

View File

@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET(_: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const path = await db.learningPath.findUnique({
where: { slug, published: true },
include: {
courses: {
include: {
course: {
include: {
modules: { include: { lessons: { select: { id: true } } } },
},
},
},
orderBy: { order: "asc" },
},
_count: { select: { enrollments: true } },
},
});
if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(path);
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { db } from "@/lib/db";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
const userId = (session.user as any).id as string;
const { learningPathId } = await req.json();
// Check path exists and is published
const path = await db.learningPath.findUnique({
where: { id: learningPathId, published: true },
include: {
courses: {
include: { course: true },
orderBy: { order: "asc" },
},
},
});
if (!path) return NextResponse.json({ error: "Parcours introuvable" }, { status: 404 });
// Enroll in the path
await db.learningPathEnrollment.upsert({
where: { userId_learningPathId: { userId, learningPathId } },
create: { userId, learningPathId },
update: {},
});
// Also auto-enroll in each course of the path
for (const lpc of path.courses) {
await db.enrollment.upsert({
where: { userId_courseId: { userId, courseId: lpc.courseId } },
create: { userId, courseId: lpc.courseId },
update: {},
});
}
return NextResponse.json({ ok: true });
}

View File

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
export async function GET() {
const paths = await db.learningPath.findMany({
where: { published: true },
orderBy: { order: "asc" },
include: {
courses: {
include: {
course: {
select: { id: true, titleFr: true, titleEn: true, titleEs: true, level: true, thumbnailUrl: true },
},
},
orderBy: { order: "asc" },
},
_count: { select: { enrollments: true } },
},
});
return NextResponse.json(paths);
}

View File

@ -78,6 +78,7 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
{/* Nav links */} {/* Nav links */}
<div style={{ display: "flex", alignItems: "center", gap: 8 }}> <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<NavLink href={localePath("/courses")} label={t("courses")} /> <NavLink href={localePath("/courses")} label={t("courses")} />
<NavLink href={localePath("/paths")} label="Parcours" />
{isLoggedIn && ( {isLoggedIn && (
<NavLink href={localePath("/dashboard")} label={t("dashboard")} /> <NavLink href={localePath("/dashboard")} label={t("dashboard")} />
)} )}