From c2a07a6e5ce24dcd1f032658d20871052c810ad4 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sat, 25 Apr 2026 17:13:01 +0400 Subject: [PATCH] feat(course-plan): RAG sources + multi-modal media + assignments + student view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the §24 product on top of the LangGraph runtime from §22: Phase A (Sources / RAG) - encoach.course.plan.source model (file | url | text) - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain text and embeds chunks via the existing pgvector pipeline scoped to plan_id, so resources.search only returns the plan's own corpus - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval Phase B (Deliverables) - services.deliverables.compute_deliverables walks the plan, derives {planned, generated, ready} per week from material + media state - GET /api/ai/course-plan//deliverables drives the new wizard preview step and the live progress strip on the detail page Phase C (Multi-modal media) - encoach.course.plan.media model + MediaService: audio: AWS Polly (default) or ElevenLabs image: OpenAI DALL-E 3, capped per plan via system parameter video: local ffmpeg subprocess (image + audio -> MP4 1280x720) - Three new agent tools (media.synthesize_audio / generate_image / compose_video), wired into course_week_materials and a new course_media_director agent - Endpoints per material + week-level batch generator Phase D (Assignments) - encoach.course.plan.assignment supports mode='batch' (op.batch) or mode='students' (res.users), with due_date + message + state - REST endpoints to list / create / delete assignments Phase E (Student view) - /api/student/course-plans + /api/student/course-plans/ enforce visibility via assignment.expand_user_ids() - New /student/course-plans list + read-only drilldown rendering audio/image/video tiles from /web/content/ Cross-cutting - encoach.ai.tool.category: + media (so the new tools register) - encoach.embedding gains a plan_id filter for plan-scoped RAG - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard + per-material MediaDrawer - ~280 new EN + AR i18n keys (full RTL coverage) - smoke_course_plan.py exercises every phase via odoo-bin shell; last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio fails cleanly when AWS creds aren't configured (expected) Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase artefact list, endpoints, smoke test, ops notes, and gotchas. Made-with: Cursor --- src/App.tsx | 4 + src/components/StudentLayout.tsx | 3 +- src/i18n/locales/ar.ts | 155 +++ src/i18n/locales/en.ts | 156 +++ src/pages/admin/AdminCoursePlanDetail.tsx | 1135 ++++++++++++++++- src/pages/admin/wizards/CoursePlanWizard.tsx | 362 +++++- src/pages/student/StudentCoursePlanDetail.tsx | 311 +++++ src/pages/student/StudentCoursePlans.tsx | 147 +++ src/services/coursePlan.service.ts | 170 +++ src/types/coursePlan.ts | 144 +++ 10 files changed, 2522 insertions(+), 65 deletions(-) create mode 100644 src/pages/student/StudentCoursePlanDetail.tsx create mode 100644 src/pages/student/StudentCoursePlans.tsx diff --git a/src/App.tsx b/src/App.tsx index 01d5f7b..6977187 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -133,6 +133,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements")); const StudentMessages = lazy(() => import("@/pages/student/StudentMessages")); const StudentJourney = lazy(() => import("@/pages/student/StudentJourney")); +const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans")); +const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail")); const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse")); const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse")); const ExamSession = lazy(() => import("@/pages/student/ExamSession")); @@ -269,6 +271,8 @@ const App = () => ( } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/StudentLayout.tsx b/src/components/StudentLayout.tsx index 201c060..1488677 100644 --- a/src/components/StudentLayout.tsx +++ b/src/components/StudentLayout.tsx @@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup"; import { LayoutDashboard, BookOpen, ClipboardList, BarChart3, CalendarCheck, Calendar, User, Target, ListChecks, - MessageSquare, Megaphone, Mail, TrendingUp, + MessageSquare, Megaphone, Mail, TrendingUp, Sparkles, } from "lucide-react"; /** @@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [ items: [ { titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard }, { titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen }, + { titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles }, { titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks }, { titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList }, ], diff --git a/src/i18n/locales/ar.ts b/src/i18n/locales/ar.ts index 04c9e34..36f8139 100644 --- a/src/i18n/locales/ar.ts +++ b/src/i18n/locales/ar.ts @@ -60,6 +60,7 @@ const ar: Translations = { dashboard: "لوحة التحكم", courses: "الدورات", myCourses: "دوراتي", + myCoursePlans: "خططي الدراسية", students: "الطلاب", teachers: "المعلمون", batches: "الدفعات", @@ -739,8 +740,14 @@ const ar: Translations = { basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.", coverage: "التغطية", coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.", + sources: "المصادر المرجعية", + sourcesDesc: + "ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.", scope: "النطاق", scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.", + media: "الوسائط", + mediaDesc: + "اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.", review: "المراجعة", reviewDesc: "راجع البيانات قبل بدء التوليد.", }, @@ -767,7 +774,155 @@ const ar: Translations = { cefrRequired: "يرجى اختيار مستوى CEFR.", weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.", }, + review: { + sourcesCount_one: "{{count}} مصدر", + sourcesCount_other: "{{count}} مصادر", + noSources: "لا توجد مصادر", + mediaNone: "لا يوجد توليد وسائط", + }, }, + sections_extras: { + sources: "المصادر المرجعية", + progress: "تقدّم التوليد", + assignments: "الإسنادات", + media: "الوسائط المُولَّدة", + }, + sources: { + sectionTitle: "المصادر المرجعية (RAG)", + sectionDesc: + "ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.", + collected_one: "{{count}} مصدر جاهز", + collected_other: "{{count}} مصادر جاهزة", + empty: "لا توجد مصادر مرجعية بعد.", + dropTitle: "أضف مواد مرجعية", + dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.", + uploadFiles: "رفع ملفات", + urlLabel: "إضافة رابط", + textLabel: "أو ألصق نصاً مباشرة", + textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…", + uploadFailed: "تعذّر رفع \"{{name}}\".", + reindex: "إعادة فهرسة", + reindexed: "أُعيدت فهرسة المصدر.", + reindexFailed: "فشلت إعادة الفهرسة.", + deleted: "تم حذف المصدر.", + deleteFailed: "تعذّر حذف المصدر.", + status: { + pending: "في الانتظار", + indexing: "يُفهرس…", + indexed: "مُفهرس", + failed: "فشل", + }, + kindLabel: { + file: "ملف", + url: "رابط", + text: "نص", + }, + chunks_one: "{{count}} جزء", + chunks_other: "{{count}} أجزاء", + }, + sourceKind: { + file: "ملف", + url: "رابط", + text: "نص", + }, + deliverables: { + title: "تقدّم التوليد", + subtitle: + "ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).", + summary: { + planned_one: "{{count}} مخطّط", + planned_other: "{{count}} مخطّط", + generated_one: "{{count}} مُولَّد", + generated_other: "{{count}} مُولَّد", + ready_one: "{{count}} جاهز", + ready_other: "{{count}} جاهز", + }, + mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}", + status: { + planned: "مخطّط", + generated: "مُولَّد", + ready: "جاهز", + }, + perWeek: "تفصيل لكل أسبوع", + noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.", + }, + media: { + audio: "صوت", + image: "صورة", + video: "فيديو", + audioTitle: "تعليق صوتي", + audioDesc: + "توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.", + imageTitle: "صور تغطية", + imageDesc: + "توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.", + videoTitle: "فيديو شرائح", + videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.", + hint: + "الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.", + drawerTitle: "وسائط \"{{title}}\"", + drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.", + generateAudio: "توليد صوت", + generateImage: "توليد صورة", + generateVideo: "توليد فيديو", + generating: "جاري التوليد…", + audioReady: "الصوت جاهز", + imageReady: "الصورة جاهزة", + videoReady: "الفيديو جاهز", + audioFailed: "فشل توليد الصوت.", + imageFailed: "فشل توليد الصورة.", + videoFailed: "فشل توليد الفيديو.", + deleted: "تم حذف الوسيط.", + deleteFailed: "تعذّر حذف الوسيط.", + open: "فتح", + download: "تنزيل", + noMedia: "لا توجد وسائط لهذه المادة بعد.", + mediaForMaterial: "وسائط", + bulk: "توليد وسائط الأسبوع", + bulkSuccess: "اكتمل توليد وسائط الأسبوع.", + bulkFailed: "تعذّر توليد وسائط الأسبوع.", + provider: "المزوّد", + }, + assignments: { + title: "مُسندة إلى", + empty: "لم تُسند إلى أحد بعد.", + assign: "إسناد", + assignTitle: "إسناد خطة المقرر", + assignSubtitle: + "اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.", + modeBatch: "دفعة كاملة", + modeStudents: "طلاب محدّدون", + pickBatch: "اختر دفعة", + pickStudents: "اختر طلاباً", + noBatches: "لا توجد دفعات.", + noStudents: "لا يوجد طلاب.", + dueDate: "تاريخ التسليم (اختياري)", + message: "رسالة للمتعلّمين (اختياري)", + messagePlaceholder: "ترحيب، توقّعات، مواعيد…", + created: "تم إسناد الخطة.", + createFailed: "تعذّر إسناد الخطة.", + removed: "تمت إزالة الإسناد.", + removeFailed: "تعذّر إزالة الإسناد.", + remove: "إزالة", + confirmRemove: "إزالة هذا الإسناد؟", + assignedBy: "أسندها {{name}}", + students_one: "{{count}} طالب", + students_other: "{{count}} طلاب", + cancel: "إلغاء", + save: "إسناد", + }, + student: { + listTitle: "خططي الدراسية", + listSubtitle: + "خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.", + empty: "لم تُسند إليك خطط بعد.", + open: "فتح المقرر", + assignedBy: "أسندها {{name}}", + due: "موعد التسليم {{date}}", + noDue: "بدون موعد", + backToList: "العودة إلى خططي", + }, + wizardSourcesStep: "المصادر", }, aiAdmin: { title: "وكلاء الذكاء الاصطناعي والأدوات", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 3e8e006..be4bc71 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -105,6 +105,7 @@ const en: Translations = { dashboard: "Dashboard", courses: "Courses", myCourses: "My Courses", + myCoursePlans: "My Course Plans", students: "Students", teachers: "Teachers", batches: "Batches", @@ -796,8 +797,14 @@ const en: Translations = { basicsDesc: "Name the course and set level, duration, and weekly hours.", coverage: "Coverage", coverageDesc: "Tell the AI how hours split across skills and who the learners are.", + sources: "Reference sources", + sourcesDesc: + "Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.", scope: "Scope", scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.", + media: "Multimedia", + mediaDesc: + "Pick which media the AI should auto-produce alongside the text materials.", review: "Review", reviewDesc: "Double-check your brief before kicking off the generation.", }, @@ -825,7 +832,156 @@ const en: Translations = { cefrRequired: "Please pick a CEFR level.", weeksRange: "Total weeks must be at least 1.", }, + review: { + sourcesCount_one: "{{count}} reference source", + sourcesCount_other: "{{count}} reference sources", + noSources: "No reference sources", + mediaNone: "No media generation", + }, }, + sections_extras: { + sources: "Reference sources", + progress: "Generation progress", + assignments: "Assignments", + media: "Generated media", + }, + sources: { + sectionTitle: "Reference sources (RAG)", + sectionDesc: + "Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.", + collected_one: "{{count}} source ready", + collected_other: "{{count}} sources ready", + empty: "No reference sources yet.", + dropTitle: "Add reference materials", + dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.", + uploadFiles: "Upload files", + urlLabel: "Add a URL", + textLabel: "Or paste text directly", + textPlaceholder: "Paste a passage to ground the AI on…", + uploadFailed: "Couldn't upload \"{{name}}\".", + reindex: "Reindex", + reindexed: "Source reindexed.", + reindexFailed: "Reindex failed.", + deleted: "Source deleted.", + deleteFailed: "Couldn't delete source.", + status: { + pending: "Pending", + indexing: "Indexing…", + indexed: "Indexed", + failed: "Failed", + }, + kindLabel: { + file: "File", + url: "URL", + text: "Text", + }, + chunks_one: "{{count}} chunk", + chunks_other: "{{count}} chunks", + }, + sourceKind: { + file: "File", + url: "URL", + text: "Inline text", + }, + deliverables: { + title: "Generation progress", + subtitle: + "What the AI is going to produce, what it has produced, and what is fully ready (materials + media).", + summary: { + planned_one: "{{count}} planned", + planned_other: "{{count}} planned", + generated_one: "{{count}} generated", + generated_other: "{{count}} generated", + ready_one: "{{count}} ready", + ready_other: "{{count}} ready", + }, + mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}", + status: { + planned: "Planned", + generated: "Generated", + ready: "Ready", + }, + perWeek: "Per-week breakdown", + noWeeks: "No weeks yet — generate the plan first.", + }, + media: { + audio: "Audio", + image: "Image", + video: "Video", + audioTitle: "Narration audio", + audioDesc: + "Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.", + imageTitle: "Cover images", + imageDesc: + "Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.", + videoTitle: "Slideshow video", + videoDesc: + "Combine audio + image into a short MP4 with ffmpeg. Slowest option.", + hint: + "Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.", + drawerTitle: "Media for \"{{title}}\"", + drawerSubtitle: "Generate or remove audio, images, and slideshow video.", + generateAudio: "Generate audio", + generateImage: "Generate image", + generateVideo: "Generate video", + generating: "Generating…", + audioReady: "Audio ready", + imageReady: "Image ready", + videoReady: "Video ready", + audioFailed: "Audio generation failed.", + imageFailed: "Image generation failed.", + videoFailed: "Video generation failed.", + deleted: "Media deleted.", + deleteFailed: "Couldn't delete media.", + open: "Open", + download: "Download", + noMedia: "No media yet for this material.", + mediaForMaterial: "Media", + bulk: "Generate week media", + bulkSuccess: "Week media generation done.", + bulkFailed: "Couldn't generate week media.", + provider: "Provider", + }, + assignments: { + title: "Assigned to", + empty: "Not assigned to anyone yet.", + assign: "Assign", + assignTitle: "Assign this course plan", + assignSubtitle: + "Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.", + modeBatch: "Whole class (batch)", + modeStudents: "Individual students", + pickBatch: "Select a batch", + pickStudents: "Pick students", + noBatches: "No batches available.", + noStudents: "No students available.", + dueDate: "Due date (optional)", + message: "Message to learners (optional)", + messagePlaceholder: "Welcome note, expectations, deadlines…", + created: "Plan assigned.", + createFailed: "Couldn't assign plan.", + removed: "Assignment removed.", + removeFailed: "Couldn't remove assignment.", + remove: "Remove", + confirmRemove: "Remove this assignment?", + assignedBy: "Assigned by {{name}}", + students_one: "{{count}} student", + students_other: "{{count}} students", + cancel: "Cancel", + save: "Assign", + }, + student: { + listTitle: "My course plans", + listSubtitle: + "Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.", + empty: "No plans assigned to you yet.", + open: "Open course", + assignedBy: "Assigned by {{name}}", + due: "Due {{date}}", + noDue: "No deadline", + backToList: "Back to my plans", + }, + wizardSourcesStep: "Sources", }, aiAdmin: { title: "AI Agents & Tools", diff --git a/src/pages/admin/AdminCoursePlanDetail.tsx b/src/pages/admin/AdminCoursePlanDetail.tsx index 8f8b3ae..3710358 100644 --- a/src/pages/admin/AdminCoursePlanDetail.tsx +++ b/src/pages/admin/AdminCoursePlanDetail.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -7,19 +7,42 @@ import { ArrowLeft, BookOpen, Calendar, + CheckCircle2, ClipboardList, + Database, + Download, + ExternalLink, + FileText, Headphones, + Image as ImageIcon, Library, + Link2, + Loader2, MessageSquare, Mic, + Music, PenSquare, + Play, + Plus, + RefreshCw, Sparkles, + Trash2, Type, + Upload, + UserPlus, + Users, + Video, Wand2, type LucideIcon, } from "lucide-react"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; @@ -29,31 +52,62 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; import { coursePlanService } from "@/services/coursePlan.service"; +import { lmsService } from "@/services/lms.service"; import { describeApiError } from "@/lib/api-client"; import type { CoursePlan, + CoursePlanAssignment, + CoursePlanDeliverables, CoursePlanMaterial, + CoursePlanMedia, CoursePlanSkill, + CoursePlanSource, CoursePlanWeek, } from "@/types"; /** * Detail page for a single AI-generated course plan. * - * Sections: - * - Header with course metadata + CEFR badge - * - Course description - * - Objectives list - * - Learning outcomes grouped by skill - * - Grammar scope - * - Assessment breakdown - * - Resources - * - Weekly delivery plan (accordion). Each week shows the items table - * from the AI plus a "Generate materials" button. When the user - * clicks it, we POST to `/weeks/:n/materials` and re-render the - * week content with the returned reading/listening/grammar/… - * materials. + * Sections (top → bottom): + * 1. Header with course metadata + CEFR badge. + * 2. **Generation progress strip** (Phase B) — planned / generated / + * ready counters with a per-week breakdown. + * 3. **Reference sources card** (Phase A) — upload PDFs, URLs, text; + * reindex/delete; status badges. + * 4. **Assignments card** (Phase D) — assign to a batch or specific + * students with a due date / message. + * 5. Course description, objectives, outcomes, grammar. + * 6. Weekly delivery plan (accordion) with per-material media drawers + * (Phase C: audio / image / video) and a per-week bulk-generate + * button. */ const SKILL_ICONS: Record = { @@ -72,25 +126,69 @@ export default function AdminCoursePlanDetail() { const planId = Number(params.planId); const qc = useQueryClient(); - const { data, isLoading, isError, error } = useQuery({ + const planQ = useQuery({ queryKey: ["course-plan", planId], queryFn: () => coursePlanService.get(planId), enabled: Number.isFinite(planId) && planId > 0, }); - const plan = data?.data as CoursePlan | undefined; + const sourcesQ = useQuery({ + queryKey: ["course-plan", planId, "sources"], + queryFn: () => coursePlanService.listSources(planId), + enabled: Number.isFinite(planId) && planId > 0, + refetchInterval: (q) => { + // Poll while any source is still indexing. + const data = q.state.data as { items?: CoursePlanSource[] } | undefined; + const hasPending = data?.items?.some( + (s) => s.status === "pending" || s.status === "indexing", + ); + return hasPending ? 4000 : false; + }, + }); + + const deliverablesQ = useQuery({ + queryKey: ["course-plan", planId, "deliverables"], + queryFn: () => coursePlanService.deliverables(planId), + enabled: Number.isFinite(planId) && planId > 0, + }); + + const assignmentsQ = useQuery({ + queryKey: ["course-plan", planId, "assignments"], + queryFn: () => coursePlanService.listAssignments(planId), + enabled: Number.isFinite(planId) && planId > 0, + }); + + const plan = planQ.data?.data as CoursePlan | undefined; const generateMut = useMutation({ mutationFn: (weekNumber: number) => coursePlanService.generateWeekMaterials(planId, weekNumber), onSuccess: () => { qc.invalidateQueries({ queryKey: ["course-plan", planId] }); + qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] }); toast.success(t("coursePlan.weekMaterialsGenerated")); }, onError: (err) => toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))), }); + const bulkMediaMut = useMutation({ + mutationFn: (vars: { + week: number; + kinds: Array<"audio" | "image" | "video">; + }) => + coursePlanService.generateWeekMedia(planId, vars.week, { + kinds: vars.kinds, + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["course-plan", planId] }); + qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] }); + toast.success(t("coursePlan.media.bulkSuccess")); + }, + onError: (err) => + toast.error(describeApiError(err, t("coursePlan.media.bulkFailed"))), + }); + const materialsByWeek = useMemo(() => { const map = new Map(); if (!plan?.materials) return map; @@ -111,7 +209,7 @@ export default function AdminCoursePlanDetail() { - {isLoading && ( + {planQ.isLoading && (
@@ -119,15 +217,14 @@ export default function AdminCoursePlanDetail() {
)} - {isError && ( + {planQ.isError && (
- {describeApiError(error, t("coursePlan.loadFailed"))} + {describeApiError(planQ.error, t("coursePlan.loadFailed"))}
)} {plan && ( <> - {/* Header */}
@@ -161,7 +258,19 @@ export default function AdminCoursePlanDetail() { - {/* Objectives */} + + + + + + {plan.objectives.length > 0 && ( @@ -180,7 +289,6 @@ export default function AdminCoursePlanDetail() { )} - {/* Outcomes per skill */} {Object.keys(plan.outcomes).length > 0 && ( @@ -190,34 +298,40 @@ export default function AdminCoursePlanDetail() { - {(Object.keys(plan.outcomes) as CoursePlanSkill[]).map((skill) => { - const list = plan.outcomes[skill]; - if (!list?.length) return null; - const Icon = SKILL_ICONS[skill] ?? ClipboardList; - return ( -
-
- -
{t(`coursePlan.skill.${skill}`, skill)}
+ {(Object.keys(plan.outcomes) as CoursePlanSkill[]).map( + (skill) => { + const list = plan.outcomes[skill]; + if (!list?.length) return null; + const Icon = SKILL_ICONS[skill] ?? ClipboardList; + return ( +
+
+ +
+ {t(`coursePlan.skill.${skill}`, skill)} +
+
+
    + {list.map((o) => ( +
  • + + {o.code} + + {o.description} +
  • + ))} +
-
    - {list.map((o) => ( -
  • - - {o.code} - - {o.description} -
  • - ))} -
-
- ); - })} + ); + }, + )} )} - {/* Grammar scope */} {plan.grammar.length > 0 && ( @@ -231,7 +345,10 @@ export default function AdminCoursePlanDetail() { {plan.grammar.map((g) => (
  • - + {g.code} {g.label} @@ -250,7 +367,6 @@ export default function AdminCoursePlanDetail() { )} - {/* Weekly delivery plan */} {plan.weeks && plan.weeks.length > 0 && ( @@ -258,9 +374,7 @@ export default function AdminCoursePlanDetail() { {t("coursePlan.sections.delivery")} - - {t("coursePlan.deliveryHint")} - + {t("coursePlan.deliveryHint")} @@ -269,8 +383,18 @@ export default function AdminCoursePlanDetail() { key={week.id} week={week} materials={materialsByWeek.get(week.week_number) ?? []} - generating={generateMut.isPending && generateMut.variables === week.week_number} + generating={ + generateMut.isPending && + generateMut.variables === week.week_number + } onGenerate={() => generateMut.mutate(week.week_number)} + onBulkMedia={(kinds) => + bulkMediaMut.mutate({ week: week.week_number, kinds }) + } + bulkBusy={ + bulkMediaMut.isPending && + bulkMediaMut.variables?.week === week.week_number + } /> ))} @@ -283,16 +407,656 @@ export default function AdminCoursePlanDetail() { ); } +// --------------------------------------------------------------------------- +// Phase B — Deliverables / progress strip +// --------------------------------------------------------------------------- + +function DeliverablesStrip({ data }: { data: CoursePlanDeliverables | undefined }) { + const { t } = useTranslation(); + if (!data) return null; + const s = data.summary; + return ( + + + + + {t("coursePlan.deliverables.title")} + + {t("coursePlan.deliverables.subtitle")} + + +
    + + + +
    + +

    + {t("coursePlan.deliverables.mediaCounts", { + audio: s.media.audio, + image: s.media.image, + video: s.media.video, + })} +

    +
    +
    + ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
    +
    {value}
    +
    {label}
    +
    + ); +} + +// --------------------------------------------------------------------------- +// Phase A — Sources card +// --------------------------------------------------------------------------- + +function SourcesCard({ + planId, + sources, + isLoading, +}: { + planId: number; + sources: CoursePlanSource[]; + isLoading: boolean; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const fileRef = useRef(null); + const [urlDraft, setUrlDraft] = useState(""); + const [textDraft, setTextDraft] = useState(""); + + const invalidate = () => { + qc.invalidateQueries({ queryKey: ["course-plan", planId, "sources"] }); + qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] }); + qc.invalidateQueries({ queryKey: ["course-plan", planId] }); + }; + + const uploadFiles = async (files: FileList | null) => { + if (!files || !files.length) return; + for (const f of Array.from(files)) { + try { + await coursePlanService.uploadSource(planId, f, { name: f.name }); + } catch (err) { + toast.error( + describeApiError(err, t("coursePlan.sources.uploadFailed", { name: f.name })), + ); + } + } + invalidate(); + }; + + const addUrl = async () => { + const url = urlDraft.trim(); + if (!url) return; + try { + await coursePlanService.createSource(planId, { kind: "url", url, name: url }); + setUrlDraft(""); + invalidate(); + } catch (err) { + toast.error(describeApiError(err, t("coursePlan.sources.uploadFailed", { name: url }))); + } + }; + + const addText = async () => { + const text = textDraft.trim(); + if (!text) return; + try { + await coursePlanService.createSource(planId, { + kind: "text", + inline_text: text, + name: text.slice(0, 60).replace(/\s+/g, " "), + }); + setTextDraft(""); + invalidate(); + } catch (err) { + toast.error( + describeApiError(err, t("coursePlan.sources.uploadFailed", { name: "text" })), + ); + } + }; + + const reindexMut = useMutation({ + mutationFn: (sourceId: number) => + coursePlanService.reindexSource(planId, sourceId), + onSuccess: () => { + toast.success(t("coursePlan.sources.reindexed")); + invalidate(); + }, + onError: (err) => + toast.error(describeApiError(err, t("coursePlan.sources.reindexFailed"))), + }); + + const deleteMut = useMutation({ + mutationFn: (sourceId: number) => + coursePlanService.deleteSource(planId, sourceId), + onSuccess: () => { + toast.success(t("coursePlan.sources.deleted")); + invalidate(); + }, + onError: (err) => + toast.error(describeApiError(err, t("coursePlan.sources.deleteFailed"))), + }); + + return ( + + + + + {t("coursePlan.sources.sectionTitle")} + + {t("coursePlan.sources.sectionDesc")} + + +
    +
    + +

    + {t("coursePlan.sources.dropHint")} +

    + { + uploadFiles(e.currentTarget.files); + if (fileRef.current) fileRef.current.value = ""; + }} + /> + +
    +
    + +
    + setUrlDraft(e.target.value)} + placeholder="https://example.com/article" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void addUrl(); + } + }} + /> + +
    +
    +
    + +
    +