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:
parent
8f78cbf25d
commit
da94d44de1
|
|
@ -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])
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 }> }
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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")} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue