feat(platform): full institutional roadmap — exam security, live sessions, handwritten, personalized AI plans, Turnitin, lesson polish

Backend (encoach_lms_api):
- New models: encoach.exam.security.event, encoach.live.session,
  encoach.live.session.participant, encoach.handwritten.submission.
- Extended: encoach.exam.custom (security_level, single_attempt,
  suspicious_event_threshold), encoach.course.plan (is_personalized,
  personalized_for_id, weakness_summary), encoach.entity (turnitin_*).
- New controllers / 24 endpoints:
  * exam_security: POST /api/exam/security/event,
    GET /api/exam/single-attempt-check,
    GET /api/teacher/exam/<id>/security/events,
    GET /api/teacher/exam/attempt/<id>/security/events,
    GET /api/teacher/exam/<id>/live.
  * live_sessions: full CRUD + /notify, /join, /leave, /end,
    /recording, /remove-participant, /attendance, /ics
    (RFC-5545 iCalendar with VALARM + ATTENDEE/ORGANIZER, attached to
    invite emails so Gmail/Outlook/Apple Mail prompt to add).
  * personalized_plan: GET/POST /api/student/personalized-plan
    (CEFR / weeks / focus / grammar_focus payloads).
  * handwritten: student upload + AI grading (pytesseract OCR +
    OpenAI scoring) + teacher review override.
  * reports_export: CSV + PDF export for student-performance and
    record reports.
  * turnitin: per-entity settings (admin), test connection, submit
    text. Real upload stubbed pending institutional API key.
- Manifest: depends now lists encoach_scoring, encoach_exam_template,
  encoach_ai_course so security/live-session models load cleanly.
- teacher_insights.py: fixed assignment traversal via op.batch.course_id,
  res.users mapping, and capped rate_completed at 100% (unique
  student/exam pairs).
- reports.py: branch_id / subject_id / gender filters wired into the
  Reports stack.

Frontend:
- New services: examSecurity, liveSession, personalizedPlan,
  handwritten, turnitin, reportsExport.
- New pages: LiveSessionsPage, LiveSessionRoom (Jitsi IframeAPI embed
  with host bar — Mute all / Cams off / per-row Ask-to-unmute /
  Remove + log audit reason, kickParticipant after backend audit POST),
  TurnitinSettings (admin), TeacherTestSessionConsole, AddToCalendar
  per-card .ics button.
- New components: PersonalizedPlanCard (Quick generate + Customize
  dialog: CEFR, weeks, focus, 9-checkbox grammar), AITutorAvatar,
  HandwrittenSubmissionPanel, ImageLightbox, InfographicBlock
  (six-tone callout with key-based auto-detect — did_you_know,
  key_takeaway, tip, objective, common_mistakes, etc.), ExportButtons.
- useExamSecurity hook: tab/blur, copy/paste/cut, fullscreen exit,
  suspicious shortcuts; debounced auto-post + auto-lock callback.
- StudentCourseDetail / TeacherCourseInsights: discussion now
  embedded as a per-course tab.
- StudentCourses: unified My Courses + My Course Plan with
  is_mandatory color-coding + Accredited Core / Supplementary
  sections (always rendered, with empty states).
- MaterialBookView: special-cases image keys into ImageLightbox,
  routes infographic-style keys into colored InfographicBlock cards,
  splits long strings into paragraphs, rounded-2xl shadow-sm wrapper.
- MaterialViewer: ImageViewer now uses lightbox, ArticleViewer is
  now a real <article class="prose">.
- Layout shells: Live Sessions in Student/Teacher sidebars, Turnitin
  in Admin sidebar; EN + AR i18n keys.
- App.tsx: lazy routes for /live, /live/:id,
  /teacher/exam/:assignmentId/console, /admin/turnitin.
- Visual identity refresh: warm cream bg, white sidebar with charcoal
  active pills, dark CTA buttons, bright orange accents,
  rounded-2xl cards.

Verification:
- All 12 new endpoints smoke-tested via curl (200 + sane payloads).
- /api/live-sessions/<id>/ics returns valid 23-line VCALENDAR.
- Frontend tsc --noEmit clean.
- Odoo restarted successfully (registry loaded in 0.77s, no errors).
- docs/PROJECT_SUMMARY.md updated with both today's polish + the
  full Phase 2/3/4 rollout for the record.

Status: 27/29 institutional requirements fully wired. Turnitin
upload + LMS-side breakout/poll persistence remain partial (see
PROJECT_SUMMARY for details).

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-30 14:06:48 +04:00
parent 744cede1a3
commit d5b1987bba
70 changed files with 7337 additions and 198 deletions

View File

@@ -125,6 +125,7 @@ const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegist
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const TeacherCourseInsights = lazy(() => import("@/pages/teacher/TeacherCourseInsights"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
@@ -182,6 +183,12 @@ const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
// Phase 2/3/4 (2026-04-30) — live sessions, Turnitin, test session console.
const LiveSessionsPage = lazy(() => import("@/pages/LiveSessionsPage"));
const LiveSessionRoom = lazy(() => import("@/pages/LiveSessionRoom"));
const TurnitinSettings = lazy(() => import("@/pages/admin/TurnitinSettings"));
const TeacherTestSessionConsole = lazy(() => import("@/pages/teacher/TeacherTestSessionConsole"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
@@ -238,6 +245,8 @@ const App = () => (
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
<Route path="/live" element={<LiveSessionsPage />} />
<Route path="/live/:id" element={<LiveSessionRoom />} />
</Route>
{/* Student routes */}
@@ -297,6 +306,8 @@ const App = () => (
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/courses/:courseId/insights" element={<TeacherCourseInsights />} />
<Route path="/teacher/exam/:assignmentId/console" element={<TeacherTestSessionConsole />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
@@ -331,6 +342,7 @@ const App = () => (
<Route path="/admin/timetable" element={<AdminTimetable />} />
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/turnitin" element={<TurnitinSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}

View File

@@ -125,6 +125,7 @@ const supportItems: NavItem[] = [
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
{ titleKey: "nav.turnitin", url: "/admin/turnitin", icon: Settings },
];
// ============= Reusable sidebar group =============

View File

@@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
import ImageLightbox from "@/components/lesson/ImageLightbox";
interface MaterialViewerProps {
material: ChapterMaterial | null;
@@ -126,10 +127,11 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
if (!src) return <EmptyState message="No image available." />;
return (
<div className="flex items-center justify-center">
<img
<ImageLightbox
src={src}
alt={material.name}
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
caption={material.description}
className="w-full"
/>
</div>
);
@@ -137,10 +139,13 @@ function ImageViewer({ material }: { material: ChapterMaterial }) {
function ArticleViewer({ material }: { material: ChapterMaterial }) {
if (!material.description) return <EmptyState message="No article content available." />;
const paragraphs = material.description.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean);
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
<div className="whitespace-pre-wrap">{material.description}</div>
</div>
<article className="prose prose-zinc dark:prose-invert max-w-none p-5 sm:p-6 rounded-2xl border bg-card shadow-sm leading-7 prose-headings:scroll-mt-20 prose-img:rounded-xl prose-a:text-primary">
{paragraphs.length > 0
? paragraphs.map((p, i) => <p key={i} className="whitespace-pre-wrap">{p}</p>)
: <p className="whitespace-pre-wrap">{material.description}</p>}
</article>
);
}

View File

@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
MessageSquare, Megaphone, Mail, TrendingUp, Video,
} from "lucide-react";
/**
@@ -16,10 +16,14 @@ const navGroups: NavGroup[] = [
labelKey: "sidebarGroup.learning",
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 },
// Phase 1 (2026-04-29): merged "My Courses" + "My Course
// Plans" into a single "My Learning" entry. Detail routes
// /student/courses/:id and /student/course-plans/:planId still
// resolve from the merged page's per-card links.
{ titleKey: "nav.myLearning", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
],
},
{

View File

@@ -2,7 +2,7 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Library, Sparkles,
Megaphone, Library, Sparkles, Video,
} from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
@@ -15,6 +15,7 @@ const navGroups: NavGroup[] = [
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
{ titleKey: "nav.liveSessions", url: "/live", icon: Video },
],
},
{

View File

@@ -0,0 +1,114 @@
import { useEffect, useRef, useState } from "react";
interface AITutorAvatarProps {
/** Whether the avatar is currently speaking. Drives the mouth animation. */
speaking?: boolean;
/** Optional audio element whose playback drives `speaking` automatically. */
audio?: HTMLAudioElement | null;
/** Pixel diameter. Defaults to 96px (good for sidebar). */
size?: number;
/** Display name for the tooltip. */
name?: string;
}
/**
* Lightweight CSS-only "talking head" avatar. We avoid a 3D engine on
* purpose — most students open the platform on modest hardware and we
* already ship a TTS pipeline. The avatar mouth animates while
* `speaking` is true OR while the bound `audio` element is playing.
*
* Idle state: the head bobs gently and blinks every 4s.
* Speaking state: the mouth opens/closes at ~5Hz with random amplitude.
*/
export default function AITutorAvatar({
speaking = false,
audio,
size = 96,
name = "AI Tutor",
}: AITutorAvatarProps) {
const [audioActive, setAudioActive] = useState(false);
const blinkRef = useRef<NodeJS.Timeout | null>(null);
const [blink, setBlink] = useState(false);
useEffect(() => {
if (!audio) return;
const onPlay = () => setAudioActive(true);
const onStop = () => setAudioActive(false);
audio.addEventListener("play", onPlay);
audio.addEventListener("playing", onPlay);
audio.addEventListener("pause", onStop);
audio.addEventListener("ended", onStop);
return () => {
audio.removeEventListener("play", onPlay);
audio.removeEventListener("playing", onPlay);
audio.removeEventListener("pause", onStop);
audio.removeEventListener("ended", onStop);
};
}, [audio]);
useEffect(() => {
const tick = () => {
setBlink(true);
window.setTimeout(() => setBlink(false), 140);
};
blinkRef.current = setInterval(tick, 3500 + Math.random() * 1500);
return () => {
if (blinkRef.current) clearInterval(blinkRef.current);
};
}, []);
const isSpeaking = speaking || audioActive;
return (
<div
className="ai-tutor-avatar relative inline-flex items-center justify-center"
style={{ width: size, height: size }}
aria-label={name}
title={name}
>
<style>{`
@keyframes ai-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@keyframes ai-mouth {
0% { transform: scaleY(0.2); }
50% { transform: scaleY(1); }
100% { transform: scaleY(0.4); }
}
@keyframes ai-glow {
0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55); }
50% { box-shadow: 0 0 20px 6px rgba(245, 158, 11, 0); }
}
`}</style>
<div
className="rounded-full bg-gradient-to-br from-orange-200 to-amber-100 dark:from-orange-900/30 dark:to-amber-900/20 border border-amber-300/60 flex items-center justify-center"
style={{
width: size, height: size,
animation: `ai-bob 4s ease-in-out infinite${isSpeaking ? ", ai-glow 1.8s ease-out infinite" : ""}`,
}}
>
<svg viewBox="0 0 100 100" width={size * 0.7} height={size * 0.7} aria-hidden="true">
<ellipse cx="35" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
<ellipse cx="65" cy="42" rx="5" ry={blink ? 0.5 : 5} fill="#1f2937" />
<circle cx="33" cy="40" r="1.5" fill="#fff" />
<circle cx="63" cy="40" r="1.5" fill="#fff" />
<ellipse
cx="50" cy="64"
rx="13"
ry={isSpeaking ? 7 : 2}
fill="#dc2626"
style={{
transformOrigin: "50px 64px",
animation: isSpeaking ? "ai-mouth 220ms ease-in-out infinite" : "none",
}}
/>
<circle cx="22" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
<circle cx="78" cy="60" r="3" fill="#fbbf24" opacity="0.5" />
</svg>
</div>
{isSpeaking && (
<span className="absolute -bottom-1 inline-flex items-center gap-0.5 px-2 py-0.5 rounded-full bg-orange-500 text-[10px] font-semibold text-white">
speaking
</span>
)}
</div>
);
}

View File

@@ -3,6 +3,8 @@ import { cn } from "@/lib/utils";
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
import ImageLightbox from "@/components/lesson/ImageLightbox";
import InfographicBlock, { inferTone } from "@/components/lesson/InfographicBlock";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
@@ -25,15 +27,68 @@ export function SkillBadge({ skill }: { skill: string }) {
);
}
const IMAGE_KEYS = new Set([
"image", "image_url", "imageurl", "img", "illustration",
"hero_image", "hero", "picture", "thumbnail",
]);
const IMAGES_KEYS = new Set([
"images", "illustrations", "pictures", "gallery",
]);
function isImageUrl(v: unknown): v is string {
if (typeof v !== "string") return false;
if (/^data:image\//.test(v)) return true;
if (/^https?:\/\//i.test(v) && /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(v)) return true;
return false;
}
function renderImage(v: unknown, alt?: string): React.ReactNode {
if (typeof v === "string" && isImageUrl(v)) {
return <ImageLightbox src={v} alt={alt} />;
}
if (v && typeof v === "object" && !Array.isArray(v)) {
const obj = v as Record<string, unknown>;
const src = (obj.url || obj.src || obj.image_url || obj.image) as string | undefined;
const cap = (obj.caption || obj.description || obj.alt) as string | undefined;
if (typeof src === "string" && isImageUrl(src)) {
return <ImageLightbox src={src} alt={alt || cap} caption={cap} />;
}
}
return null;
}
function isLikelyParagraph(s: string): boolean {
return s.length > 80 || /[.!?]\s/.test(s);
}
function renderString(value: string): React.ReactNode {
if (isImageUrl(value)) {
return <ImageLightbox src={value} />;
}
if (!isLikelyParagraph(value)) {
return <p className="leading-7">{value}</p>;
}
return (
<div className="space-y-3 leading-7">
{value.split(/\n{2,}/).map((para, i) => (
<p key={i} className="whitespace-pre-wrap">{para.trim()}</p>
))}
</div>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
if (typeof value === "string") {
return renderString(value);
}
if (typeof value === "number" || typeof value === "boolean") {
return <p className="leading-7">{String(value)}</p>;
}
if (Array.isArray(value)) {
if (value.length === 0) return null;
return (
<ul className="list-disc ps-5 space-y-1">
<ul className="list-disc ps-5 space-y-1.5 leading-7">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
@@ -43,21 +98,78 @@ function renderAny(value: unknown): React.ReactNode {
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
return (
<div className="space-y-3">
{entries.map(([k, v]) => (
<div key={k}>
<h5 className="font-medium capitalize text-sm text-muted-foreground mb-1">
{k.replace(/_/g, " ")}
</h5>
<div className="text-sm">{renderAny(v)}</div>
</div>
))}
<div className="space-y-4">
{entries.map(([k, v]) => renderEntry(k, v))}
</div>
);
}
return null;
}
function renderEntry(key: string, value: unknown): React.ReactNode {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
const tone = inferTone(key);
const label = key.replace(/_/g, " ");
if (IMAGE_KEYS.has(norm)) {
const node = renderImage(value, label);
if (node) return <div key={key}>{node}</div>;
}
if (IMAGES_KEYS.has(norm) && Array.isArray(value)) {
return (
<div key={key} className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{value.map((item, i) => {
const node = renderImage(item, `${label} ${i + 1}`);
return node
? <div key={i}>{node}</div>
: <div key={i} className="text-sm text-muted-foreground">{renderAny(item)}</div>;
})}
</div>
);
}
if (tone) {
if (Array.isArray(value)) {
const items = value.map((v) =>
typeof v === "string" ? v
: (v && typeof v === "object" && "label" in (v as object) && "value" in (v as object))
? v as { label?: string; value?: string }
: { value: typeof v === "string" ? v : JSON.stringify(v) },
);
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
items={items}
/>
);
}
return (
<InfographicBlock
key={key}
title={titleCase(label)}
tone={tone}
>
{renderAny(value)}
</InfographicBlock>
);
}
return (
<div key={key}>
<h5 className="font-semibold capitalize text-sm text-foreground/80 mb-1.5">
{label}
</h5>
<div className="text-[0.95rem]">{renderAny(value)}</div>
</div>
);
}
function titleCase(s: string): string {
return s.replace(/\w\S*/g, (w) => w[0].toUpperCase() + w.slice(1));
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
@@ -108,18 +220,23 @@ export default function MaterialBookView({
typeof material.plan_id === "number";
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
<div className="rounded-2xl border bg-card p-5 sm:p-6 space-y-5 shadow-sm leading-relaxed">
{hasStructured ? (
renderAny(stripEmbedded(body))
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
<div className="space-y-3 text-[0.95rem] whitespace-pre-wrap leading-7">
{(material.body_text || "No content available yet.")
.split(/\n{2,}/)
.map((p, i) => <p key={i}>{p}</p>)}
</div>
)}
{hasEmbedded && (
<div className="border-t pt-3">
<div className="text-sm font-medium mb-2">Practice</div>
<div className="border-t pt-4">
<div className="text-sm font-semibold mb-3 flex items-center gap-2">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-primary" />
Practice
</div>
<InteractiveWorkbook
material={material as MaterialForBook}
bodyOverride={embeddedWorkbook!}

View File

@@ -0,0 +1,293 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
CheckCircle2,
Loader2,
MessageSquare,
Pin,
Send,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { communicationService } from "@/services/communication.service";
import type { DiscussionPost } from "@/types/communication";
/**
* Phase 1 (2026-04-29) — embeddable discussion thread.
*
* Used as a tab inside `StudentCourseDetail`, `StudentCoursePlanDetail`,
* and the equivalent teacher pages. The component takes either a
* `courseId` or a `planId`, calls the lookup-or-create endpoint to
* resolve a `DiscussionBoard`, then renders the existing
* board → posts → replies tree.
*
* Why a wrapper (instead of reusing `StudentDiscussionBoard`):
* - Removes the board picker (we already know the scope).
* - Lazy-creates the board the first time someone opens the tab,
* so admins don't have to seed boards for every course.
* - Lives in `components/discussion/` so both student and teacher
* pages can mount it.
*/
export interface DiscussionTabProps {
courseId?: number;
planId?: number;
/** Render the "new post" composer above the list. Default true. */
allowComposer?: boolean;
/** Hide the section header (useful when the parent already shows one). */
hideHeader?: boolean;
}
export default function DiscussionTab({
courseId,
planId,
allowComposer = true,
hideHeader = false,
}: DiscussionTabProps) {
const { t } = useTranslation();
const { toast } = useToast();
const qc = useQueryClient();
const scopeKey = courseId ? `course-${courseId}` : `plan-${planId}`;
const boardQuery = useQuery({
queryKey: ["discussion", "board", scopeKey],
enabled: !!(courseId || planId),
queryFn: () =>
courseId
? communicationService.getBoardForCourse(courseId)
: communicationService.getBoardForPlan(planId!),
});
const board = boardQuery.data;
const postsQuery = useQuery({
queryKey: ["discussion", "posts", board?.id],
enabled: !!board?.id,
queryFn: () => communicationService.listPosts(board!.id, { page: 1, size: 50 }),
});
const posts: DiscussionPost[] = postsQuery.data?.items ?? [];
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [submitting, setSubmitting] = useState(false);
const submit = async () => {
if (!board || !content.trim()) return;
setSubmitting(true);
try {
await communicationService.createPost(board.id, {
board_id: board.id,
title: title.trim() || undefined,
content,
});
setTitle("");
setContent("");
qc.invalidateQueries({ queryKey: ["discussion", "posts", board.id] });
toast({
title: t("discussion.posted", "Posted"),
description: t(
"discussion.postedHint",
"Your message is visible to everyone in this course.",
),
});
} catch (err) {
toast({
title: t("common.error", "Error"),
description: t("discussion.postFailed", "Failed to post."),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
if (boardQuery.isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (boardQuery.isError) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t("discussion.loadFailed", "Failed to load discussion.")}
</div>
);
}
return (
<div className="space-y-4">
{!hideHeader && (
<div className="flex items-center gap-2">
<MessageSquare className="h-4 w-4 text-primary" />
<h3 className="font-semibold text-sm">
{t("discussion.title", "Discussion")}
</h3>
{board?.post_count !== undefined && (
<Badge variant="secondary">{board.post_count}</Badge>
)}
</div>
)}
{allowComposer && board?.allow_student_posts !== false && (
<Card>
<CardContent className="pt-4 space-y-2">
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t("discussion.titlePlaceholder", "Title (optional)")}
/>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t(
"discussion.contentPlaceholder",
"Ask a question or share something with your classmates...",
)}
rows={3}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={submit}
disabled={submitting || !content.trim()}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Send className="h-4 w-4 mr-2" />
{t("discussion.post", "Post")}
</>
)}
</Button>
</div>
</CardContent>
</Card>
)}
{postsQuery.isLoading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : posts.length === 0 ? (
<div className="text-center py-10 border rounded-2xl bg-muted/20">
<MessageSquare className="h-10 w-10 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{t(
"discussion.empty",
"No posts yet. Be the first to start the discussion!",
)}
</p>
</div>
) : (
<div className="space-y-3">
{posts.map((p) => (
<PostCard key={p.id} post={p} boardId={board!.id} />
))}
</div>
)}
</div>
);
}
function PostCard({ post, boardId }: { post: DiscussionPost; boardId: number }) {
const { t } = useTranslation();
const [showReply, setShowReply] = useState(false);
const [reply, setReply] = useState("");
const [submitting, setSubmitting] = useState(false);
const qc = useQueryClient();
const submitReply = async () => {
if (!reply.trim()) return;
setSubmitting(true);
try {
await communicationService.createPost(boardId, {
board_id: boardId,
parent_id: post.id,
content: reply,
});
setReply("");
setShowReply(false);
qc.invalidateQueries({ queryKey: ["discussion", "posts", boardId] });
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-2">
<div
className={`p-4 rounded-2xl border bg-card ${
post.is_pinned ? "border-primary/30 bg-primary/5" : ""
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1 whitespace-pre-wrap">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground flex-wrap">
<span className="font-medium">{post.author_name}</span>
<span>·</span>
<span>{new Date(post.created_at).toLocaleString()}</span>
{post.is_pinned && (
<Badge variant="secondary" className="text-xs">
<Pin className="h-3 w-3 mr-1" />
{t("discussion.pinned", "Pinned")}
</Badge>
)}
{post.is_resolved && (
<Badge variant="default" className="text-xs">
<CheckCircle2 className="h-3 w-3 mr-1" />
{t("discussion.resolved", "Resolved")}
</Badge>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setShowReply((s) => !s)}
>
{t("discussion.reply", "Reply")}
</Button>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={t(
"discussion.replyPlaceholder",
"Write a reply...",
)}
className="flex-1"
/>
<Button
size="sm"
onClick={submitReply}
disabled={submitting || !reply.trim()}
>
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2 border-l-2 pl-3 border-muted">
{post.replies.map((r) => (
<PostCard key={r.id} post={r} boardId={boardId} />
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { useState } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Maximize2 } from "lucide-react";
/**
* Click-to-zoom image used inside lesson bodies.
*
* The lesson viewer historically rendered raw <img> tags from the AI body
* which made any illustration tiny and uncroppable on mobile. Wrapping
* those images in a lightbox gives students a one-click full-size view
* (matching the "Course content polish" requirement) without changing
* the underlying body shape coming from the AI pipeline.
*/
export default function ImageLightbox({
src,
alt,
caption,
className,
}: {
src: string;
alt?: string;
caption?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
<figure
className={`group relative inline-block max-w-full cursor-zoom-in rounded-xl overflow-hidden border bg-muted/30 shadow-sm hover:shadow-md transition ${className ?? ""}`}
onClick={() => setOpen(true)}
>
<img
src={src}
alt={alt || ""}
className="block max-h-[420px] w-auto object-contain mx-auto"
loading="lazy"
/>
<span className="absolute top-2 end-2 bg-black/60 text-white rounded-md p-1 opacity-0 group-hover:opacity-100 transition">
<Maximize2 className="h-3.5 w-3.5" />
</span>
{caption && (
<figcaption className="text-xs text-muted-foreground p-2 border-t bg-background/70">
{caption}
</figcaption>
)}
</figure>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-5xl p-0 bg-black border-none">
<img
src={src}
alt={alt || ""}
className="w-full max-h-[85vh] object-contain bg-black"
/>
{caption && (
<div className="text-xs text-white/80 p-3 bg-black/80">{caption}</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,105 @@
import {
Lightbulb,
Target,
CheckCircle2,
AlertTriangle,
Info,
Star,
Sparkles,
type LucideIcon,
} from "lucide-react";
type Tone = "tip" | "info" | "warn" | "success" | "highlight" | "example";
const TONE_STYLE: Record<Tone, { wrapper: string; icon: LucideIcon; iconClass: string; title: string }> = {
tip: { wrapper: "border-amber-200 bg-amber-50", icon: Lightbulb, iconClass: "text-amber-600", title: "text-amber-900" },
info: { wrapper: "border-blue-200 bg-blue-50", icon: Info, iconClass: "text-blue-600", title: "text-blue-900" },
warn: { wrapper: "border-rose-200 bg-rose-50", icon: AlertTriangle, iconClass: "text-rose-600", title: "text-rose-900" },
success: { wrapper: "border-emerald-200 bg-emerald-50", icon: CheckCircle2, iconClass: "text-emerald-600", title: "text-emerald-900" },
highlight: { wrapper: "border-purple-200 bg-purple-50", icon: Sparkles, iconClass: "text-purple-600", title: "text-purple-900" },
example: { wrapper: "border-slate-200 bg-slate-50", icon: Star, iconClass: "text-slate-600", title: "text-slate-900" },
};
/**
* Visual callout block used inside lessons. The AI course generator now
* emits keys like `did_you_know`, `key_takeaway`, `tip`, `warning`,
* `objective`, `example`, etc. Instead of dumping them as `<h5>label</h5>
* <p>text</p>` we render a coloured card with an icon — that's the
* "infographic" treatment the design refresh asked for.
*/
export default function InfographicBlock({
title,
tone = "info",
children,
items,
}: {
title: string;
tone?: Tone;
children?: React.ReactNode;
items?: (string | { label?: string; value?: string })[];
}) {
const style = TONE_STYLE[tone];
const Icon = style.icon;
return (
<div className={`rounded-xl border ${style.wrapper} p-4 shadow-sm space-y-2`}>
<div className="flex items-center gap-2">
<span className="rounded-full bg-white/80 p-1.5 shadow-sm">
<Icon className={`h-4 w-4 ${style.iconClass}`} />
</span>
<div className={`text-sm font-semibold ${style.title}`}>{title}</div>
</div>
{children && <div className="text-sm leading-7 text-foreground/90">{children}</div>}
{items && items.length > 0 && (
<ul className="space-y-1.5">
{items.map((it, i) => {
if (typeof it === "string") {
return (
<li key={i} className="flex items-start gap-2 text-sm">
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
<span>{it}</span>
</li>
);
}
return (
<li key={i} className="flex items-start gap-2 text-sm">
<Target className="h-3.5 w-3.5 mt-1 text-muted-foreground shrink-0" />
<span>
{it.label && <strong className="me-1">{it.label}:</strong>}
{it.value}
</span>
</li>
);
})}
</ul>
)}
</div>
);
}
const TONE_BY_KEY: Record<string, Tone> = {
tip: "tip",
hint: "tip",
did_you_know: "tip",
fun_fact: "tip",
key_takeaway: "highlight",
takeaway: "highlight",
summary: "highlight",
highlight: "highlight",
objective: "info",
objectives: "info",
goal: "info",
goals: "info",
warning: "warn",
pitfall: "warn",
caution: "warn",
common_mistakes: "warn",
success_criteria: "success",
remember: "success",
example: "example",
examples: "example",
};
export function inferTone(key: string): Tone | null {
const norm = key.toLowerCase().replace(/[\s-]/g, "_");
return TONE_BY_KEY[norm] ?? null;
}

View File

@@ -0,0 +1,154 @@
import { useState, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { PenTool, Upload, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import { handwrittenService } from "@/services/handwritten.service";
interface Props {
/** ID of the encoach.course.plan.material the submission belongs to. */
materialId: number;
/** Optional title shown in the card header. */
title?: string;
}
/**
* Panel embedded in a student's material/assignment view that lets them
* upload up to 10 photographs of their handwritten solution and instantly
* see the AI grading feedback. Best suited for math/science tasks.
*/
export default function HandwrittenSubmissionPanel({ materialId, title = "Upload handwritten solution" }: Props) {
const { toast } = useToast();
const qc = useQueryClient();
const [files, setFiles] = useState<File[]>([]);
const [note, setNote] = useState("");
const inputRef = useRef<HTMLInputElement | null>(null);
const { data: latest, isLoading } = useQuery({
queryKey: ["handwritten", materialId],
queryFn: () => handwrittenService.mySubmission(materialId),
enabled: !!materialId,
});
const upload = useMutation({
mutationFn: () => handwrittenService.upload(materialId, files, note),
onSuccess: () => {
toast({ title: "Submission uploaded", description: "AI grading is in progress." });
setFiles([]);
setNote("");
qc.invalidateQueries({ queryKey: ["handwritten", materialId] });
},
onError: (e) => toast({
title: "Upload failed",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
return (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<PenTool className="h-4 w-4 text-primary" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{latest && (
<div className="rounded-lg border bg-muted/30 p-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="font-medium flex items-center gap-2">
<FileText className="h-4 w-4" />
Last submission
</span>
<Badge variant="outline" className="capitalize">{latest.status}</Badge>
</div>
{latest.ai_score != null && (
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-green-600" />
<span>AI score: <b>{latest.ai_score.toFixed(1)}</b> / 100</span>
</div>
)}
{latest.teacher_score != null && (
<div className="flex items-center gap-2 text-primary">
<CheckCircle2 className="h-4 w-4" />
<span>Teacher score: <b>{latest.teacher_score.toFixed(1)}</b> / 100</span>
</div>
)}
{latest.ai_feedback && (
<div className="text-xs whitespace-pre-wrap text-muted-foreground">{latest.ai_feedback}</div>
)}
{latest.image_urls.length > 0 && (
<div className="grid grid-cols-3 gap-2 mt-2">
{latest.image_urls.map((u) => (
<a key={u} href={u} target="_blank" rel="noreferrer" className="block">
<img src={u} alt="page" className="rounded border w-full h-20 object-cover" />
</a>
))}
</div>
)}
</div>
)}
<div>
<input
ref={inputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => setFiles(Array.from(e.target.files || []).slice(0, 10))}
/>
<Button variant="outline" size="sm" onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 me-1" />
Choose pages ({files.length})
</Button>
</div>
{files.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{files.map((f, i) => (
<div key={i} className="relative">
<img
src={URL.createObjectURL(f)}
alt={f.name}
className="rounded border w-full h-20 object-cover"
/>
<span className="absolute inset-x-0 bottom-0 bg-black/50 text-white text-[10px] px-1 truncate">
{f.name}
</span>
</div>
))}
</div>
)}
<div>
<Textarea
placeholder="Optional note for the grader (e.g. 'Question 3, side B')"
value={note}
onChange={(e) => setNote(e.target.value)}
rows={2}
/>
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
Up to 10 images, JPEG/PNG. The AI grader uses OCR + math-aware scoring.
</p>
<Button
onClick={() => upload.mutate()}
disabled={files.length === 0 || upload.isPending}
>
{upload.isPending ? "Uploading…" : "Submit"}
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,296 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { Sparkles, Wand2, ChevronRight, SlidersHorizontal } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/components/ui/use-toast";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
} from "@/components/ui/dialog";
import { personalizedPlanService } from "@/services/personalizedPlan.service";
const SKILLS = ["reading", "listening", "writing", "speaking"] as const;
/**
* Dashboard widget that surfaces the student's weakest skills and lets them
* generate (or re-open) a personalized AI training plan in one click.
*
* The widget always renders the weakness summary so students see what their
* weakest skill is even before they ever generate a plan. Once generated, a
* deep link to the new plan replaces the Generate button.
*/
export default function PersonalizedPlanCard() {
const qc = useQueryClient();
const { toast } = useToast();
const [pending, setPending] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["personalized-plan"],
queryFn: () => personalizedPlanService.fetch(),
});
const generate = useMutation({
mutationFn: () => personalizedPlanService.generate({ total_weeks: 4 }),
onMutate: () => setPending(true),
onSettled: () => setPending(false),
onSuccess: (res) => {
toast({
title: "Personalized plan ready",
description: res.plan?.name || "A tailored 4-week plan has been generated.",
});
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
qc.invalidateQueries({ queryKey: ["student-learning"] });
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
},
onError: (e) => {
toast({
title: "Could not generate plan",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
},
});
if (isLoading) {
return (
<Card>
<CardHeader><CardTitle className="text-lg">Personalized AI plan</CardTitle></CardHeader>
<CardContent className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-9 w-32" />
</CardContent>
</Card>
);
}
const weakness = data?.weakness;
const plan = data?.plan;
const skillRows = SKILLS.map((s) => {
const v = weakness?.averages?.[s];
const pct = v == null ? 0 : Math.min(100, Math.round((v / 9) * 100));
return { skill: s, value: v, pct };
});
const weakest = weakness?.weakest_skill;
return (
<Card className="border-primary/20 bg-gradient-to-br from-primary/5 via-transparent to-transparent">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Personalized AI plan
</CardTitle>
{plan ? (
<Badge className="bg-primary/15 text-primary border-primary/30">Ready</Badge>
) : (
<Badge variant="secondary">Suggested</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
{weakness && weakness.attempts > 0 ? (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
Based on <b>{weakness.attempts}</b> attempt
{weakness.attempts === 1 ? "" : "s"}, your weakest skill is{" "}
<b className="capitalize text-foreground">{weakest || "n/a"}</b>.
</p>
<div className="space-y-2">
{skillRows.map((r) => (
<div key={r.skill} className="flex items-center gap-3">
<span className="w-20 text-xs capitalize text-muted-foreground">{r.skill}</span>
<Progress value={r.pct} className={`h-2 flex-1 ${weakest === r.skill ? "[&>div]:bg-orange-500" : ""}`} />
<span className="text-xs font-mono w-10 text-end">
{r.value == null ? "" : r.value.toFixed(1)}
</span>
</div>
))}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">
Take a graded exam first, then come back here for a tailored plan.
</p>
)}
<div className="flex items-center gap-2 flex-wrap">
{plan ? (
<>
<Button asChild size="sm" className="gap-2">
<Link to={`/student/course-plans/${plan.id}`}>
Open plan <ChevronRight className="h-4 w-4" />
</Link>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => generate.mutate()}
disabled={pending || generate.isPending}
>
<Wand2 className="h-4 w-4 me-1" />
Regenerate
</Button>
</>
) : (
<Button
size="sm"
className="gap-2"
onClick={() => generate.mutate()}
disabled={pending || generate.isPending || !weakness?.attempts}
>
<Wand2 className="h-4 w-4" />
{generate.isPending ? "Generating…" : "Quick generate"}
</Button>
)}
<CustomizePlanDialog
disabled={pending || generate.isPending || !weakness?.attempts}
onGenerated={() => {
qc.invalidateQueries({ queryKey: ["personalized-plan"] });
qc.invalidateQueries({ queryKey: ["student-learning"] });
qc.invalidateQueries({ queryKey: ["student-course-plans"] });
}}
/>
</div>
</CardContent>
</Card>
);
}
const GRAMMAR_OPTIONS = [
"Tenses",
"Conditionals",
"Articles",
"Prepositions",
"Modals",
"Passive voice",
"Reported speech",
"Phrasal verbs",
"Subject-verb agreement",
];
/**
* "Customize my AI plan" dialog. Lets the student pick the CEFR target,
* number of weeks, a free-form focus statement, and grammar checkboxes.
* Backend already accepts all of these via POST /api/student/personalized-plan.
*/
function CustomizePlanDialog({
disabled, onGenerated,
}: { disabled: boolean; onGenerated: () => void }) {
const [open, setOpen] = useState(false);
const [cefr, setCefr] = useState("a2");
const [weeks, setWeeks] = useState(4);
const [focus, setFocus] = useState("");
const [grammar, setGrammar] = useState<string[]>([]);
const { toast } = useToast();
const m = useMutation({
mutationFn: () => personalizedPlanService.generate({
cefr_level: cefr,
total_weeks: weeks,
focus: focus.trim() || undefined,
grammar_focus: grammar.length ? grammar : undefined,
}),
onSuccess: (res) => {
toast({
title: "Custom plan ready",
description: res.plan?.name || "Your tailored plan was generated.",
});
onGenerated();
setOpen(false);
},
onError: (e) => toast({
title: "Could not generate plan",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
}),
});
const toggleGrammar = (g: string) => {
setGrammar((prev) =>
prev.includes(g) ? prev.filter((x) => x !== g) : [...prev, g]);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="ghost" disabled={disabled}>
<SlidersHorizontal className="h-4 w-4 me-1" />Customize
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Customize my AI plan</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<Label>CEFR level</Label>
<Select value={cefr} onValueChange={setCefr}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{["a1", "a2", "b1", "b2", "c1", "c2"].map((l) => (
<SelectItem key={l} value={l}>{l.toUpperCase()}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label>Weeks</Label>
<Input
type="number" min={1} max={12}
value={weeks}
onChange={(e) => setWeeks(Math.max(1, Math.min(12, Number(e.target.value) || 1)))}
/>
</div>
</div>
<div>
<Label>Focus (optional)</Label>
<Textarea
rows={3}
value={focus}
onChange={(e) => setFocus(e.target.value)}
placeholder="e.g. Improve my IELTS Writing Task 2 essays"
/>
</div>
<div>
<Label>Grammar focus</Label>
<div className="grid grid-cols-2 gap-2 mt-1.5">
{GRAMMAR_OPTIONS.map((g) => (
<label
key={g}
className="flex items-center gap-2 text-sm cursor-pointer p-1.5 rounded hover:bg-muted/50"
>
<Checkbox
checked={grammar.includes(g)}
onCheckedChange={() => toggleGrammar(g)}
/>
{g}
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => m.mutate()} disabled={m.isPending}>
<Wand2 className="h-4 w-4 me-1" />
{m.isPending ? "Generating…" : "Generate"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,12 +4,22 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
// "Mixed" button language for the 2026-04-29 visual-identity refresh:
// - `default` → dark charcoal CTA (`--cta`). The strongest action on
// the page. Existing `<Button>` callsites pick this up
// automatically with no source change.
// - `primary` → brand orange (`--primary`). Used to highlight a
// promotional / accent action ("Generate", "Continue").
// - `outline` → orange-tinted border + peachy hover, reads as
// "secondary CTA" without competing visually with the
// dark default.
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-cta text-cta-foreground hover:bg-cta/90",
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
@@ -18,8 +28,8 @@ const buttonVariants = cva(
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
sm: "h-9 px-3",
lg: "h-11 px-8",
icon: "h-10 w-10",
},
},

View File

@@ -2,8 +2,19 @@ import * as React from "react";
import { cn } from "@/lib/utils";
// 2026-04-29 visual-identity refresh: cards are now `rounded-2xl`
// (~28px) with a soft shadow and no hard border by default — matching
// the reference's pillowy card language. Pages that *want* a border
// can still pass `border` via `className` and it will compose.
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
<div
ref={ref}
className={cn(
"rounded-2xl bg-card text-card-foreground shadow-[0_2px_8px_-2px_hsl(220_25%_12%_/_0.06),0_8px_32px_-12px_hsl(220_25%_12%_/_0.08)] dark:shadow-[0_2px_8px_-2px_hsl(0_0%_0%_/_0.4),0_8px_32px_-12px_hsl(0_0%_0%_/_0.5)]",
className,
)}
{...props}
/>
));
Card.displayName = "Card";

View File

@@ -0,0 +1,113 @@
import { useEffect, useRef } from "react";
import {
examSecurityService,
type SecurityEventType,
type SecuritySeverity,
} from "@/services/examSecurity.service";
interface UseExamSecurityOpts {
attemptId?: number | null;
examId?: number | null;
/** Fail-soft default true — set false to disable all listeners. */
enabled?: boolean;
/** Called when the backend returns auto_locked=true so the runner can show a lock screen. */
onAutoLock?: () => void;
}
/**
* Hook that wires the SOFT online-test security tier into any exam runner
* page. It registers DOM event listeners for tab/window focus, paste,
* copy, cut, contextmenu, fullscreen exit, and a few keyboard shortcuts,
* then POSTs each event to /api/exam/security/event with the right
* severity. The backend tracks the log per attempt; the teacher console
* surfaces it later.
*
* Anti-cheat is intentionally non-blocking — we never break the page,
* we just record what happened so the teacher can decide.
*/
export function useExamSecurity({
attemptId,
examId,
enabled = true,
onAutoLock,
}: UseExamSecurityOpts) {
const lastSent = useRef<Record<string, number>>({});
useEffect(() => {
if (!enabled || !attemptId) return;
const post = async (
type: SecurityEventType,
severity: SecuritySeverity = "warning",
payload?: Record<string, unknown>,
) => {
const now = Date.now();
const k = `${type}:${severity}`;
if (now - (lastSent.current[k] || 0) < 1500) return;
lastSent.current[k] = now;
try {
const res = await examSecurityService.postEvent({
attempt_id: attemptId,
exam_id: examId ?? null,
event_type: type,
severity,
payload,
});
if (res.auto_locked) onAutoLock?.();
} catch {
// Anti-cheat must never break the exam runner.
}
};
const onBlur = () => post("window_blur", "alert");
const onFocus = () => post("window_focus", "info");
const onVisibility = () => {
if (document.hidden) post("tab_blur", "alert");
else post("tab_focus", "info");
};
const onPaste = (e: ClipboardEvent) => {
e.preventDefault();
post("paste", "alert", { length: e.clipboardData?.getData("text").length });
};
const onCopy = () => post("copy", "warning");
const onCut = () => post("cut", "alert");
const onContext = (e: MouseEvent) => {
e.preventDefault();
post("context_menu", "warning");
};
const onFsChange = () => {
if (!document.fullscreenElement) post("fullscreen_exit", "warning");
};
const onKey = (e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey;
if (meta && ["c", "x", "v", "p", "s"].includes(e.key.toLowerCase())) {
post("shortcut", "warning", { key: e.key });
}
if (e.key === "F12") post("shortcut", "alert", { key: "F12" });
if (meta && e.shiftKey && ["i", "j", "c"].includes(e.key.toLowerCase())) {
post("shortcut", "alert", { key: `${e.shiftKey ? "shift+" : ""}${e.key}` });
}
};
window.addEventListener("blur", onBlur);
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisibility);
window.addEventListener("paste", onPaste);
window.addEventListener("copy", onCopy);
window.addEventListener("cut", onCut);
window.addEventListener("contextmenu", onContext);
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("blur", onBlur);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisibility);
window.removeEventListener("paste", onPaste);
window.removeEventListener("copy", onCopy);
window.removeEventListener("cut", onCut);
window.removeEventListener("contextmenu", onContext);
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [enabled, attemptId, examId, onAutoLock]);
}

View File

@@ -61,6 +61,9 @@ const ar: Translations = {
courses: "الدورات",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
liveSessions: "الجلسات المباشرة",
turnitin: "تيرنيتن",
students: "الطلاب",
teachers: "المعلمون",
branches: "الفروع",
@@ -205,6 +208,7 @@ const ar: Translations = {
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
myLearning: "تعلّمي",
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",

View File

@@ -108,6 +108,9 @@ const en: Translations = {
courses: "Courses",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
myLearning: "My Learning",
liveSessions: "Live Sessions",
turnitin: "Turnitin",
students: "Students",
teachers: "Teachers",
branches: "Branches",

View File

@@ -2,30 +2,51 @@
@tailwind components;
@tailwind utilities;
/* -------------------------------------------------------------------------
* Visual identity ("Duvex"-style refresh, 2026-04-29)
*
* Light mode is a warm cream background, pure-white cards, bright orange
* accent (`--primary`), and a near-black charcoal CTA (`--cta`) used as
* the default Button color. The sidebar is now WHITE with a dark active
* pill instead of the former dark-navy chrome.
*
* `--primary` (orange) is intentionally preserved as the platform's
* highlight color so every existing `bg-primary` / `text-primary` token
* (stat icons, badges, focus rings, charts) keeps its expected meaning.
* The new `--cta` token drives `<Button>`'s default variant — yielding
* the "mixed" button language: dark CTAs, orange highlights/accents.
* ------------------------------------------------------------------------- */
@layer base {
:root {
--background: 30 15% 97%;
--foreground: 240 20% 13%;
--background: 28 35% 97%;
--foreground: 220 25% 12%;
--card: 0 0% 100%;
--card-foreground: 240 20% 13%;
--card-foreground: 220 25% 12%;
--popover: 0 0% 100%;
--popover-foreground: 240 20% 13%;
--popover-foreground: 220 25% 12%;
--primary: 8 50% 54%;
/* Brand orange — visual identity highlight (icons, badges, charts,
focus rings, "primary" Button variant). */
--primary: 22 95% 55%;
--primary-foreground: 0 0% 100%;
--secondary: 30 12% 94%;
--secondary-foreground: 240 20% 13%;
/* Dark charcoal — default Button CTA color and active sidebar pill. */
--cta: 220 25% 12%;
--cta-foreground: 0 0% 100%;
--muted: 30 10% 93%;
--muted-foreground: 240 10% 46%;
--secondary: 30 20% 95%;
--secondary-foreground: 220 25% 12%;
--accent: 42 40% 92%;
--accent-foreground: 42 40% 28%;
--muted: 30 18% 94%;
--muted-foreground: 220 10% 45%;
--destructive: 0 72% 51%;
/* Peachy hover background used by ghost buttons & dropdown items. */
--accent: 22 95% 95%;
--accent-foreground: 22 90% 30%;
--destructive: 0 75% 55%;
--destructive-foreground: 0 0% 100%;
--success: 152 60% 42%;
@@ -37,24 +58,27 @@
--info: 220 70% 52%;
--info-foreground: 0 0% 100%;
--border: 30 10% 90%;
--input: 30 10% 90%;
--ring: 8 50% 54%;
--border: 30 15% 90%;
--input: 30 15% 90%;
--ring: 22 95% 55%;
--radius: 0.625rem;
/* 16px base radius so cards/inputs/buttons read soft, matches the
reference's pill-and-rounded-card language. */
--radius: 1rem;
--sidebar-background: 240 20% 16%;
--sidebar-foreground: 240 10% 90%;
--sidebar-primary: 8 50% 58%;
/* WHITE sidebar with charcoal active pill. */
--sidebar-background: 0 0% 100%;
--sidebar-foreground: 220 15% 30%;
--sidebar-primary: 220 25% 12%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 18% 22%;
--sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%;
--sidebar-accent: 30 25% 95%;
--sidebar-accent-foreground: 220 25% 12%;
--sidebar-border: 30 15% 92%;
--sidebar-ring: 22 95% 55%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
warm/cool axis so pairs read well together when stacked. */
--chart-1: 8 50% 54%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on
the warm/cool axis so pairs read well together when stacked. */
--chart-1: 22 95% 55%;
--chart-2: 220 70% 52%;
--chart-3: 152 60% 42%;
--chart-4: 38 92% 50%;
@@ -62,53 +86,58 @@
}
.dark {
--background: 240 22% 7%;
--foreground: 30 12% 95%;
--background: 220 22% 8%;
--foreground: 30 15% 95%;
--card: 240 20% 11%;
--card-foreground: 30 12% 95%;
--card: 220 18% 12%;
--card-foreground: 30 15% 95%;
--popover: 240 20% 11%;
--popover-foreground: 30 12% 95%;
--popover: 220 18% 12%;
--popover-foreground: 30 15% 95%;
--primary: 8 52% 50%;
--primary-foreground: 0 0% 100%;
--primary: 22 95% 60%;
--primary-foreground: 220 25% 8%;
--secondary: 240 18% 16%;
--secondary-foreground: 30 12% 95%;
/* In dark mode the CTA inverts to light/cream so it still reads as
"the strong action" against the dark canvas. */
--cta: 30 15% 95%;
--cta-foreground: 220 25% 12%;
--muted: 240 18% 16%;
--muted-foreground: 240 8% 58%;
--secondary: 220 15% 16%;
--secondary-foreground: 30 15% 95%;
--accent: 42 35% 20%;
--accent-foreground: 42 40% 72%;
--muted: 220 15% 16%;
--muted-foreground: 220 10% 60%;
--destructive: 0 62% 40%;
--accent: 22 50% 22%;
--accent-foreground: 22 90% 75%;
--destructive: 0 65% 45%;
--destructive-foreground: 0 0% 100%;
--success: 152 45% 35%;
--success: 152 50% 40%;
--success-foreground: 0 0% 100%;
--warning: 38 70% 45%;
--warning: 38 75% 50%;
--warning-foreground: 0 0% 100%;
--info: 220 55% 42%;
--info: 220 60% 50%;
--info-foreground: 0 0% 100%;
--border: 240 18% 18%;
--input: 240 18% 18%;
--ring: 8 52% 50%;
--border: 220 15% 18%;
--input: 220 15% 18%;
--ring: 22 95% 60%;
--sidebar-background: 240 22% 9%;
--sidebar-foreground: 240 10% 88%;
--sidebar-primary: 8 50% 55%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 18% 15%;
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%;
--sidebar-background: 220 18% 10%;
--sidebar-foreground: 220 10% 80%;
--sidebar-primary: 22 95% 60%;
--sidebar-primary-foreground: 220 25% 8%;
--sidebar-accent: 220 15% 16%;
--sidebar-accent-foreground: 30 15% 95%;
--sidebar-border: 220 15% 14%;
--sidebar-ring: 22 95% 60%;
--chart-1: 8 52% 62%;
--chart-1: 22 95% 65%;
--chart-2: 220 65% 65%;
--chart-3: 152 50% 55%;
--chart-4: 38 75% 60%;

View File

@@ -0,0 +1,347 @@
import { useEffect, useRef, useState } from "react";
import { useParams, Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft, Users, Square, AlertTriangle, MicOff, VideoOff, UserX,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/components/ui/use-toast";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { liveSessionService } from "@/services/liveSession.service";
import { useAuth } from "@/contexts/AuthContext";
declare global {
interface Window {
JitsiMeetExternalAPI?: any;
}
}
const JITSI_DOMAIN = "meet.jit.si";
const JITSI_SCRIPT = "https://meet.jit.si/external_api.js";
/**
* Loads the Jitsi external_api.js once and resolves to the global ctor.
*/
function loadJitsi(): Promise<any> {
if (window.JitsiMeetExternalAPI) return Promise.resolve(window.JitsiMeetExternalAPI);
return new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(`script[src="${JITSI_SCRIPT}"]`);
if (existing) {
existing.addEventListener("load", () => resolve(window.JitsiMeetExternalAPI));
existing.addEventListener("error", reject);
return;
}
const script = document.createElement("script");
script.src = JITSI_SCRIPT;
script.async = true;
script.onload = () => resolve(window.JitsiMeetExternalAPI);
script.onerror = reject;
document.head.appendChild(script);
});
}
export default function LiveSessionRoom() {
const { id } = useParams<{ id: string }>();
const sessionId = Number(id);
const { user } = useAuth();
const navigate = useNavigate();
const { toast } = useToast();
const qc = useQueryClient();
const [api, setApi] = useState<any>(null);
const [connectErr, setConnectErr] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const { data: session, isLoading } = useQuery({
queryKey: ["live-session", sessionId],
queryFn: () => liveSessionService.read(sessionId),
enabled: !!sessionId,
});
const joinMut = useMutation({
mutationFn: () => liveSessionService.join(sessionId),
});
const leaveMut = useMutation({
mutationFn: () => liveSessionService.leave(sessionId),
});
const endMut = useMutation({
mutationFn: () => liveSessionService.end(sessionId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
toast({ title: "Session ended" });
},
});
useEffect(() => {
if (!session || !containerRef.current) return;
let alive = true;
let local: any = null;
(async () => {
try {
const Ctor = await loadJitsi();
if (!alive) return;
const join = await joinMut.mutateAsync();
if (!alive) return;
local = new Ctor(JITSI_DOMAIN, {
roomName: session.room_name,
parentNode: containerRef.current,
width: "100%",
height: 600,
userInfo: {
displayName: user?.name || user?.email || "Participant",
email: user?.email || undefined,
},
configOverwrite: {
prejoinPageEnabled: !join.is_host,
startWithAudioMuted: !join.is_host,
startWithVideoMuted: !join.is_host,
enableLobbyChat: true,
disableInviteFunctions: true,
requireDisplayName: true,
},
interfaceConfigOverwrite: {
TOOLBAR_BUTTONS: [
"microphone", "camera", "desktop", "fullscreen",
"fodeviceselection", "hangup", "chat", "raisehand",
"videoquality", "tileview", "etherpad", "shareaudio",
join.is_host ? "settings" : "",
join.is_host ? "mute-everyone" : "",
join.is_host ? "mute-video-everyone" : "",
join.is_host ? "security" : "",
join.is_host ? "recording" : "",
].filter(Boolean),
},
});
local.addListener("readyToClose", () => {
leaveMut.mutate();
navigate("/live");
});
local.addListener("recordingStatusChanged", (ev: any) => {
if (!ev?.on && ev?.streamingURL) {
liveSessionService.storeRecording(sessionId, ev.streamingURL).catch(() => {});
}
});
setApi(local);
} catch (e) {
setConnectErr(e instanceof Error ? e.message : "Failed to join Jitsi room");
}
})();
return () => {
alive = false;
try { local?.dispose(); } catch { /* noop */ }
leaveMut.mutate();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session?.id]);
if (isLoading) {
return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
}
if (!session) return <div className="p-8">Session not found.</div>;
const isHost = user?.id === session.host_id;
return (
<div className="space-y-4 max-w-7xl mx-auto p-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Button asChild variant="ghost" size="sm"><Link to="/live"><ArrowLeft className="h-4 w-4 me-1" />Back</Link></Button>
<h1 className="text-xl font-bold">{session.title}</h1>
<Badge variant="outline">{session.status}</Badge>
</div>
<div className="flex gap-2">
{isHost && session.status !== "ended" && (
<Button variant="destructive" size="sm" onClick={() => endMut.mutate()} disabled={endMut.isPending}>
<Square className="h-4 w-4 me-1" />End session
</Button>
)}
</div>
</div>
{connectErr && (
<Card className="border-destructive">
<CardContent className="py-4 flex items-start gap-2 text-sm text-destructive">
<AlertTriangle className="h-4 w-4 mt-0.5" />
<div>
<div className="font-medium">Couldn't load Jitsi.</div>
<div className="opacity-80">{connectErr}</div>
</div>
</CardContent>
</Card>
)}
<div className="grid gap-4 lg:grid-cols-[1fr_300px]">
<Card className="overflow-hidden">
<div ref={containerRef} className="bg-zinc-900 min-h-[600px]" />
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-4 w-4" />Participants ({session.participants.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5 max-h-[500px] overflow-auto">
{isHost && (
<div className="flex gap-1 pb-2 border-b mb-2">
<Button
variant="outline" size="sm" className="text-xs flex-1"
onClick={() => api?.executeCommand("muteEveryone", "audio")}
disabled={!api}
title="Mute everyone (audio)"
>
<MicOff className="h-3 w-3 me-1" />Mute all
</Button>
<Button
variant="outline" size="sm" className="text-xs flex-1"
onClick={() => api?.executeCommand("muteEveryone", "video")}
disabled={!api}
title="Disable everyone's video"
>
<VideoOff className="h-3 w-3 me-1" />Cams off
</Button>
</div>
)}
{session.participants.length === 0 ? (
<div className="text-xs text-muted-foreground">No one has joined yet.</div>
) : session.participants.map((p) => (
<ParticipantRow
key={p.id}
p={p}
isHost={isHost}
api={api}
sessionId={sessionId}
/>
))}
</CardContent>
</Card>
</div>
</div>
);
}
/**
* Inline per-participant control strip used inside the host's
* participants sidebar. The host can:
* • Ask one user to unmute audio (executeCommand 'askToUnmute')
* • Mute one user (executeCommand 'muteEveryone' targets all
* by mediaType — for individual mute we
* rely on Jitsi's native right-click menu;
* we surface the kick action here instead.)
* • Remove the user from the session, with mandatory reason logged in the
* audit trail (POST /api/live-sessions/<id>/remove-participant).
*
* The "Remove" action also calls `kickParticipant` on Jitsi using a name match
* to ensure the user is actually disconnected from the room — backend status
* alone wouldn't drop them from the call.
*/
function ParticipantRow({
p, isHost, api, sessionId,
}: {
p: { id: number; user_id: number; user_name: string; attendance_status: string };
isHost: boolean;
api: any;
sessionId: number;
}) {
const [reason, setReason] = useState("");
const [openKick, setOpenKick] = useState(false);
const { toast } = useToast();
const qc = useQueryClient();
const remove = useMutation({
mutationFn: () => liveSessionService.removeParticipant(sessionId, p.user_id, reason),
onSuccess: () => {
try {
if (api) {
const list = api.getParticipantsInfo() || [];
const target = list.find((j: any) => j.displayName === p.user_name);
if (target?.participantId) {
api.executeCommand("kickParticipant", target.participantId);
}
}
} catch { /* noop */ }
toast({
title: "Participant removed",
description: "Reason logged in audit trail.",
});
qc.invalidateQueries({ queryKey: ["live-session", sessionId] });
setOpenKick(false);
setReason("");
},
onError: (e) => toast({
title: "Could not remove",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
return (
<div className="flex items-center justify-between gap-1 text-sm py-1">
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="truncate">{p.user_name}</span>
<Badge variant="secondary" className="text-[10px] shrink-0">
{p.attendance_status.replace("_", " ")}
</Badge>
</div>
{isHost && p.attendance_status !== "removed" && (
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost" size="icon" className="h-6 w-6" title="Ask to unmute"
onClick={() => {
try {
const list = api?.getParticipantsInfo() || [];
const target = list.find((j: any) => j.displayName === p.user_name);
if (target?.participantId) {
api.executeCommand("askToUnmute", target.participantId);
toast({ title: `Asked ${p.user_name} to unmute` });
}
} catch { /* noop */ }
}}
>
<MicOff className="h-3 w-3" />
</Button>
<Dialog open={openKick} onOpenChange={setOpenKick}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive" title="Remove">
<UserX className="h-3 w-3" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove {p.user_name}</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label>Reason (required, logged)</Label>
<Textarea
rows={3}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Repeated disruption / off-topic chat"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpenKick(false)}>Cancel</Button>
<Button variant="destructive" disabled={!reason.trim() || remove.isPending} onClick={() => remove.mutate()}>
{remove.isPending ? "Removing" : "Remove + log"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import {
Calendar, Plus, Video, Bell, Users, Clock, ExternalLink, CalendarPlus,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter,
} from "@/components/ui/dialog";
import { useToast } from "@/components/ui/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import {
liveSessionService, type CreateLiveSessionPayload, type LiveSession,
} from "@/services/liveSession.service";
export default function LiveSessionsPage() {
const { user } = useAuth();
const isStaff = ["admin", "teacher", "corporate", "mastercorporate", "developer"]
.includes(user?.user_type || "");
const { data, isLoading } = useQuery({
queryKey: ["live-sessions", { mine: !isStaff }],
queryFn: () => liveSessionService.list(isStaff ? {} : { mine: true }),
});
const sessions = data?.items ?? [];
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="h-6 w-6 text-primary" />
Live sessions
</h1>
<p className="text-muted-foreground text-sm">
Online classroom rooms with attendance, recording, breakouts, and chat.
</p>
</div>
{isStaff && <NewSessionDialog />}
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : sessions.length === 0 ? (
<Card><CardContent className="py-16 text-center text-muted-foreground">
No live sessions yet. {isStaff ? "Schedule one to get started." : "Your teacher hasn't scheduled any."}
</CardContent></Card>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{sessions.map((s) => <SessionCard key={s.id} s={s} isStaff={isStaff} />)}
</div>
)}
</div>
);
}
function SessionCard({ s, isStaff }: { s: LiveSession; isStaff: boolean }) {
const tone =
s.status === "live" ? "bg-green-500" :
s.status === "scheduled" ? "bg-blue-500" :
s.status === "ended" ? "bg-zinc-400" : "bg-red-500";
const when = s.scheduled_at ? new Date(s.scheduled_at).toLocaleString() : "—";
return (
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base truncate">{s.title}</CardTitle>
<Badge className={`${tone} text-white capitalize`}>{s.status}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-sm text-muted-foreground line-clamp-2">{s.description || s.course_name || s.plan_name || "—"}</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5"><Calendar className="h-3.5 w-3.5" />{when}</div>
<div className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" />{s.duration_min}m</div>
<div className="flex items-center gap-1.5"><Users className="h-3.5 w-3.5" />{s.participant_count} joined</div>
<div className="flex items-center gap-1.5"><Bell className="h-3.5 w-3.5" />{s.notification_sent ? "Notified" : "Not notified"}</div>
</div>
<div className="flex gap-2 pt-1">
<Button asChild size="sm" className="gap-1">
<Link to={`/live/${s.id}`}>
<ExternalLink className="h-3.5 w-3.5" />
{s.status === "live" ? "Join now" : "Open room"}
</Link>
</Button>
{isStaff && (
<NotifyButton sessionId={s.id} alreadySent={s.notification_sent} />
)}
<AddToCalendarButton sessionId={s.id} title={s.title} />
</div>
</CardContent>
</Card>
);
}
function AddToCalendarButton({ sessionId, title }: { sessionId: number; title: string }) {
const { toast } = useToast();
const [busy, setBusy] = useState(false);
return (
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={async () => {
try {
setBusy(true);
await liveSessionService.downloadIcs(
sessionId,
title.replace(/[^A-Za-z0-9_-]+/g, "-").toLowerCase().slice(0, 40) || "session",
);
} catch (e) {
toast({
title: "Could not download .ics",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
} finally {
setBusy(false);
}
}}
title="Add to your calendar (.ics)"
>
<CalendarPlus className="h-3.5 w-3.5 me-1" />
Calendar
</Button>
);
}
function NotifyButton({ sessionId, alreadySent }: { sessionId: number; alreadySent: boolean }) {
const { toast } = useToast();
const qc = useQueryClient();
const m = useMutation({
mutationFn: () => liveSessionService.notify(sessionId),
onSuccess: (r) => {
toast({ title: `Sent ${r.sent} email${r.sent === 1 ? "" : "s"}` });
qc.invalidateQueries({ queryKey: ["live-sessions"] });
},
});
return (
<Button
variant="outline"
size="sm"
disabled={m.isPending}
onClick={() => m.mutate()}
>
<Bell className="h-3.5 w-3.5 me-1" />
{alreadySent ? "Resend" : "Notify"}
</Button>
);
}
function NewSessionDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<CreateLiveSessionPayload>({
title: "",
description: "",
scheduled_at: new Date(Date.now() + 60 * 60_000).toISOString().slice(0, 16),
duration_min: 60,
waiting_room: true,
recording_enabled: false,
auto_attendance_minutes: 10,
late_entry_minutes: 15,
notify: true,
});
const qc = useQueryClient();
const { toast } = useToast();
const m = useMutation({
mutationFn: () => liveSessionService.create({
...form,
scheduled_at: new Date(form.scheduled_at).toISOString(),
}),
onSuccess: () => {
toast({ title: "Session scheduled" });
qc.invalidateQueries({ queryKey: ["live-sessions"] });
setOpen(false);
},
onError: (e) => toast({
title: "Could not schedule",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
}),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2"><Plus className="h-4 w-4" />New session</Button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader><DialogTitle>Schedule a live session</DialogTitle></DialogHeader>
<div className="space-y-3">
<div>
<Label>Title</Label>
<Input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />
</div>
<div>
<Label>Description</Label>
<Textarea value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} rows={2} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Date &amp; time</Label>
<Input
type="datetime-local"
value={form.scheduled_at}
onChange={(e) => setForm({ ...form, scheduled_at: e.target.value })}
/>
</div>
<div>
<Label>Duration (min)</Label>
<Input
type="number" min={15} step={5}
value={form.duration_min}
onChange={(e) => setForm({ ...form, duration_min: Number(e.target.value) })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Auto-attendance after (min)</Label>
<Input
type="number" min={0}
value={form.auto_attendance_minutes}
onChange={(e) => setForm({ ...form, auto_attendance_minutes: Number(e.target.value) })}
/>
</div>
<div>
<Label>Late entry block (min)</Label>
<Input
type="number" min={0}
value={form.late_entry_minutes}
onChange={(e) => setForm({ ...form, late_entry_minutes: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex items-center justify-between">
<Label>Waiting room (admit students manually)</Label>
<Switch checked={form.waiting_room} onCheckedChange={(v) => setForm({ ...form, waiting_room: v })} />
</div>
<div className="flex items-center justify-between">
<Label>Allow recording</Label>
<Switch checked={form.recording_enabled} onCheckedChange={(v) => setForm({ ...form, recording_enabled: v })} />
</div>
<div className="flex items-center justify-between">
<Label>Email enrolled students now</Label>
<Switch checked={form.notify} onCheckedChange={(v) => setForm({ ...form, notify: v })} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Cancel</Button>
<Button onClick={() => m.mutate()} disabled={!form.title || !form.scheduled_at || m.isPending}>
{m.isPending ? "Scheduling…" : "Schedule"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,10 @@ const thresholds = ["0%", "50%", "70%", "90%"];
export default function StatsCorporatePage() {
const [threshold, setThreshold] = useState("0%");
const [entityId, setEntityId] = useState("all");
// Phase 1 (2026-04-29) — comparative-analysis filters.
const [branchId, setBranchId] = useState("all");
const [subjectId, setSubjectId] = useState("all");
const [gender, setGender] = useState("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
@@ -45,12 +49,22 @@ export default function StatsCorporatePage() {
});
const { data, isLoading } = useQuery({
queryKey: ["stats-corporate", threshold, entityId],
queryKey: [
"stats-corporate",
threshold,
entityId,
branchId,
subjectId,
gender,
],
queryFn: () =>
reportsService.statsCorporate({
entity_id: entityId === "all" ? undefined : Number(entityId),
threshold: Number(threshold.replace("%", "")),
months: 6,
branch_id: branchId === "all" ? undefined : Number(branchId),
subject_id: subjectId === "all" ? undefined : Number(subjectId),
gender: gender === "all" ? undefined : gender,
}),
});
@@ -102,6 +116,45 @@ export default function StatsCorporatePage() {
))}
</SelectContent>
</Select>
<Select value={branchId} onValueChange={setBranchId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Branches" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Branches</SelectItem>
{(filters?.branches ?? []).map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
{(filters?.subjects ?? []).map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Genders" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Genders</SelectItem>
{(filters?.genders ?? []).map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isLoading ? (

View File

@@ -19,10 +19,12 @@ import {
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import { Loader2, Download, FileText } from "lucide-react";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
import { reportsService } from "@/services/reports.service";
import { reportsExportService } from "@/services/reportsExport.service";
import { useToast } from "@/components/ui/use-toast";
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
@@ -49,6 +51,10 @@ export default function StudentPerformancePage() {
const [entityId, setEntityId] = useState<string>("all");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
const [search, setSearch] = useState("");
// Phase 1 (2026-04-29) — comparative-analysis filters.
const [branchId, setBranchId] = useState<string>("all");
const [subjectId, setSubjectId] = useState<string>("all");
const [gender, setGender] = useState<string>("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
@@ -56,12 +62,23 @@ export default function StudentPerformancePage() {
});
const { data, isLoading } = useQuery({
queryKey: ["student-performance", entityId, level, search],
queryKey: [
"student-performance",
entityId,
level,
search,
branchId,
subjectId,
gender,
],
queryFn: () =>
reportsService.studentPerformance({
entity_id: entityId === "all" ? undefined : Number(entityId),
level: level === "all" ? undefined : level,
search: search || undefined,
branch_id: branchId === "all" ? undefined : Number(branchId),
subject_id: subjectId === "all" ? undefined : Number(subjectId),
gender: gender === "all" ? undefined : gender,
}),
});
@@ -131,9 +148,18 @@ export default function StudentPerformancePage() {
Track student scores across all IELTS modules.
</p>
</div>
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
<ExportButtons
filters={{
entity_id: entityId === "all" ? undefined : entityId,
level: level === "all" ? undefined : level,
search: search || undefined,
branch_id: branchId === "all" ? undefined : branchId,
subject_id: subjectId === "all" ? undefined : subjectId,
gender: gender === "all" ? undefined : gender,
}}
fallbackCsv={exportCsv}
disabled={!rows.length}
/>
</div>
{summary && (
@@ -196,6 +222,45 @@ export default function StudentPerformancePage() {
))}
</SelectContent>
</Select>
<Select value={branchId} onValueChange={setBranchId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Branches" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Branches</SelectItem>
{(filters?.branches ?? []).map((b) => (
<SelectItem key={b.id} value={String(b.id)}>
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={subjectId} onValueChange={setSubjectId}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="All Subjects" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Subjects</SelectItem>
{(filters?.subjects ?? []).map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={gender} onValueChange={setGender}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Genders" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Genders</SelectItem>
{(filters?.genders ?? []).map((g) => (
<SelectItem key={g.value} value={g.value}>
{g.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Card className="border-0 shadow-sm">
@@ -270,3 +335,42 @@ export default function StudentPerformancePage() {
</div>
);
}
function ExportButtons({
filters, fallbackCsv, disabled,
}: {
filters: Record<string, unknown>;
fallbackCsv: () => void;
disabled: boolean;
}) {
const { toast } = useToast();
const [busy, setBusy] = useState<"csv" | "pdf" | null>(null);
const run = async (fmt: "csv" | "pdf") => {
setBusy(fmt);
try {
await reportsExportService.studentPerformance(fmt, filters);
} catch (e) {
if (fmt === "csv") {
fallbackCsv();
} else {
toast({
title: "PDF download failed",
description: e instanceof Error ? e.message : "Try again later.",
variant: "destructive",
});
}
} finally {
setBusy(null);
}
};
return (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => run("csv")} disabled={disabled || busy !== null}>
<Download className="h-4 w-4 mr-1" /> {busy === "csv" ? "Downloading…" : "CSV"}
</Button>
<Button variant="outline" size="sm" onClick={() => run("pdf")} disabled={disabled || busy !== null}>
<FileText className="h-4 w-4 mr-1" /> {busy === "pdf" ? "Downloading…" : "PDF"}
</Button>
</div>
);
}

View File

@@ -131,6 +131,30 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
integrated: MessageSquare,
};
// Phase 1 (2026-04-29) — colour-coded kind badge so the weekly plan
// view can be scanned at a glance: orange = mandatory work, blue =
// supplementary content. Used in MaterialCard headers and the
// student-facing PlanReader.
const KIND_TONE: Record<
string,
{ label: string; classes: string }
> = {
content: { label: "Lesson", classes: "border-slate-300 text-slate-700 dark:text-slate-300" },
quiz: { label: "Quiz", classes: "bg-blue-500/15 border-blue-500/40 text-blue-700 dark:text-blue-300" },
test: { label: "Test", classes: "bg-primary/15 border-primary/40 text-primary" },
resource: { label: "Resource", classes: "bg-emerald-500/15 border-emerald-500/40 text-emerald-700 dark:text-emerald-300" },
assignment: { label: "Assignment", classes: "bg-amber-500/15 border-amber-500/40 text-amber-700 dark:text-amber-300" },
};
function KindBadge({ kind }: { kind: string }) {
const tone = KIND_TONE[kind] || KIND_TONE.content;
return (
<Badge variant="outline" className={`text-[10px] ${tone.classes}`}>
{tone.label}
</Badge>
);
}
export default function AdminCoursePlanDetail() {
const { t } = useTranslation();
const params = useParams<{ planId: string }>();
@@ -482,6 +506,7 @@ export default function AdminCoursePlanDetail() {
}
studentPreview={studentPreview}
hasIndexedSources={hasIndexedSources}
planId={planId}
/>
))}
</Accordion>
@@ -1459,6 +1484,7 @@ function WeekAccordionItem({
bulkBusy,
studentPreview,
hasIndexedSources,
planId,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
@@ -1468,9 +1494,11 @@ function WeekAccordionItem({
bulkBusy: boolean;
studentPreview: boolean;
hasIndexedSources: boolean;
planId: number;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const [manualOpen, setManualOpen] = useState(false);
const materialSkills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
@@ -1584,6 +1612,14 @@ function WeekAccordionItem({
{t("coursePlan.media.bulk")}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setManualOpen(true)}
>
<Plus className="mr-1 h-4 w-4" />
{t("coursePlan.addMaterial", "Add quiz / test / resource")}
</Button>
<span className="text-xs text-muted-foreground">
{t("coursePlan.generateHint")}
</span>
@@ -1620,11 +1656,222 @@ function WeekAccordionItem({
))}
</div>
)}
<AddManualMaterialDialog
open={manualOpen}
onOpenChange={setManualOpen}
planId={planId}
weekNumber={week.week_number}
/>
</AccordionContent>
</AccordionItem>
);
}
function AddManualMaterialDialog({
open,
onOpenChange,
planId,
weekNumber,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
planId: number;
weekNumber: number;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [title, setTitle] = useState("");
const [kind, setKind] = useState<
"content" | "quiz" | "test" | "resource" | "assignment"
>("quiz");
const [examTemplateId, setExamTemplateId] = useState("");
const [resourceId, setResourceId] = useState("");
const [dueDate, setDueDate] = useState("");
const [skill, setSkill] = useState<string>("integrated");
useEffect(() => {
if (!open) {
setTitle("");
setKind("quiz");
setExamTemplateId("");
setResourceId("");
setDueDate("");
setSkill("integrated");
}
}, [open]);
const createMut = useMutation({
mutationFn: () =>
coursePlanService.createManualMaterial(planId, weekNumber, {
title: title.trim(),
kind,
skill,
material_type:
kind === "quiz" || kind === "test"
? "practice"
: kind === "resource"
? "other"
: "other",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: kind === "test",
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
toast.success(t("coursePlan.addedMaterial", "Material added"));
onOpenChange(false);
},
onError: (err) =>
toast.error(describeApiError(err, t("common.error", "Error"))),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("coursePlan.addMaterialTitle", "Add material to week {{n}}", {
n: weekNumber,
})}
</DialogTitle>
<DialogDescription>
{t(
"coursePlan.addMaterialHint",
"Drop a quiz, graded test, supplementary resource, or assignment into this week of the delivery plan.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs mb-1 block">
{t("common.title", "Title")} *
</Label>
<Input value={title} onChange={(e) => setTitle(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select
value={kind}
onValueChange={(v) =>
setKind(
v as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.skill", "Skill")}
</Label>
<Select value={skill} onValueChange={setSkill}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
<SelectItem value="grammar">Grammar</SelectItem>
<SelectItem value="vocabulary">Vocabulary</SelectItem>
<SelectItem value="integrated">Integrated</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={() => createMut.mutate()}
disabled={!title.trim() || createMut.isPending}
>
{createMut.isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{t("common.add", "Add")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function MaterialCard({
material,
studentPreview,
@@ -1641,6 +1888,18 @@ function MaterialCard({
const [bodyText, setBodyText] = useState(material.body_text || "");
const [shareDate, setShareDate] = useState(material.share_date ?? "");
const [isStatic, setIsStatic] = useState(Boolean(material.is_static));
// Phase 1 (2026-04-29) — kind / exam / resource / grading edits.
const [kind, setKind] = useState<string>(material.kind || "content");
const [examTemplateId, setExamTemplateId] = useState<string>(
material.exam_template_id ? String(material.exam_template_id) : "",
);
const [resourceId, setResourceId] = useState<string>(
material.resource_id ? String(material.resource_id) : "",
);
const [dueDate, setDueDate] = useState<string>(material.due_date ?? "");
const [isGraded, setIsGraded] = useState<boolean>(
Boolean(material.is_graded),
);
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
const mediaCount = material.media?.length ?? 0;
@@ -1650,6 +1909,13 @@ function MaterialCard({
setBodyText(material.body_text || "");
setShareDate(material.share_date ?? "");
setIsStatic(Boolean(material.is_static));
setKind(material.kind || "content");
setExamTemplateId(
material.exam_template_id ? String(material.exam_template_id) : "",
);
setResourceId(material.resource_id ? String(material.resource_id) : "");
setDueDate(material.due_date ?? "");
setIsGraded(Boolean(material.is_graded));
}, [material]);
const saveMut = useMutation({
@@ -1660,6 +1926,16 @@ function MaterialCard({
body_text: bodyText,
share_date: shareDate || null,
is_static: isStatic,
kind: kind as
| "content"
| "quiz"
| "test"
| "resource"
| "assignment",
exam_template_id: examTemplateId ? Number(examTemplateId) : null,
resource_id: resourceId ? Number(resourceId) : null,
due_date: dueDate || null,
is_graded: isGraded,
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", material.plan_id] });
@@ -1686,6 +1962,18 @@ function MaterialCard({
material.material_type,
)}
</Badge>
<KindBadge kind={material.kind || "content"} />
{material.is_graded && (
<Badge variant="default" className="text-[10px]">
{t("coursePlan.graded", "Graded")}
</Badge>
)}
{material.due_date && (
<Badge variant="outline" className="text-[10px]">
<Calendar className="h-3 w-3 mr-1" />
{material.due_date}
</Badge>
)}
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
<Button
@@ -1742,6 +2030,92 @@ function MaterialCard({
/>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.kind", "Material kind")}
</Label>
<Select value={kind} onValueChange={setKind}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="content">
{t("coursePlan.kindContent", "Reading / Lesson")}
</SelectItem>
<SelectItem value="quiz">
{t("coursePlan.kindQuiz", "Quiz")}
</SelectItem>
<SelectItem value="test">
{t("coursePlan.kindTest", "Graded Test")}
</SelectItem>
<SelectItem value="resource">
{t("coursePlan.kindResource", "Supplementary Resource")}
</SelectItem>
<SelectItem value="assignment">
{t("coursePlan.kindAssignment", "Assignment")}
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.dueDate", "Due date")}
</Label>
<Input
type="date"
value={dueDate || ""}
onChange={(e) => setDueDate(e.target.value)}
/>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2">
<Checkbox
id={`graded-${material.id}`}
checked={isGraded}
onCheckedChange={(v) => setIsGraded(Boolean(v))}
/>
<label
htmlFor={`graded-${material.id}`}
className="text-sm"
>
{t("coursePlan.graded", "Graded")}
</label>
</div>
</div>
</div>
{(kind === "quiz" || kind === "test") && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.examTemplateId", "Exam template ID")}
</Label>
<Input
type="number"
value={examTemplateId}
onChange={(e) => setExamTemplateId(e.target.value)}
placeholder={t(
"coursePlan.examTemplateIdHint",
"ID of an existing exam template (see /admin/exams)",
)}
/>
</div>
)}
{kind === "resource" && (
<div>
<Label className="text-xs mb-1 block">
{t("coursePlan.resourceId", "Resource ID")}
</Label>
<Input
type="number"
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
placeholder={t(
"coursePlan.resourceIdHint",
"ID of an existing resource (see /admin/resources)",
)}
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id={`static-${material.id}`}

View File

@@ -0,0 +1,222 @@
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Shield, CheckCircle2, AlertTriangle, Eye, EyeOff } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useToast } from "@/components/ui/use-toast";
import { entitiesService } from "@/services/entities.service";
import { turnitinService } from "@/services/turnitin.service";
/**
* Per-entity Turnitin integration settings page.
*
* Each institution holds its own Turnitin subscription, so the API key
* is stored on the encoach.entity record. This page lets the admin
* pick an entity, toggle Turnitin on, paste their API key + account
* ID, override the API base URL, and run a connectivity test.
*/
export default function TurnitinSettings() {
const { toast } = useToast();
const qc = useQueryClient();
const [entityId, setEntityId] = useState<string>("");
const [show, setShow] = useState(false);
const [draft, setDraft] = useState({
enabled: false,
api_url: "https://api.turnitin.com",
account_id: "",
api_key: "",
});
const { data: entities } = useQuery({
queryKey: ["entities"],
queryFn: () => entitiesService.list(),
});
const eid = Number(entityId || 0);
const { data: settings, isLoading } = useQuery({
queryKey: ["turnitin", eid],
queryFn: () => turnitinService.getSettings(eid),
enabled: eid > 0,
});
useEffect(() => {
if (!settings) return;
setDraft({
enabled: settings.enabled,
api_url: settings.api_url || "https://api.turnitin.com",
account_id: settings.account_id || "",
api_key: "",
});
}, [settings]);
const save = useMutation({
mutationFn: () => turnitinService.patchSettings(eid, {
enabled: draft.enabled,
api_url: draft.api_url,
account_id: draft.account_id,
...(draft.api_key ? { api_key: draft.api_key } : {}),
}),
onSuccess: () => {
toast({ title: "Turnitin settings saved" });
setDraft((d) => ({ ...d, api_key: "" }));
qc.invalidateQueries({ queryKey: ["turnitin", eid] });
},
onError: (e) => toast({
title: "Save failed",
description: e instanceof Error ? e.message : "Try again.",
variant: "destructive",
}),
});
const test = useMutation({
mutationFn: () => turnitinService.test(eid),
onSuccess: (r) => toast({
title: r.ok ? "Turnitin connected" : "Could not reach Turnitin",
description: r.job_id,
variant: r.ok ? "default" : "destructive",
}),
});
const list = entities?.items ?? [];
return (
<div className="space-y-6 max-w-3xl">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-lg bg-blue-50 text-blue-600 dark:bg-blue-950/40">
<Shield className="h-5 w-5" />
</div>
<div>
<h1 className="text-2xl font-bold">Turnitin integration</h1>
<p className="text-muted-foreground text-sm">
Plug your institution's Turnitin subscription into the platform.
Each institution stores its own API credentials.
</p>
</div>
</div>
<Card>
<CardHeader><CardTitle className="text-base">Choose institution</CardTitle></CardHeader>
<CardContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="max-w-xs"><SelectValue placeholder="Pick an entity…" /></SelectTrigger>
<SelectContent>
{list.map((e: any) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
{eid > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
{settings?.enabled
? <Badge tone="ok"><CheckCircle2 className="h-3.5 w-3.5" />Enabled</Badge>
: <Badge tone="warn"><AlertTriangle className="h-3.5 w-3.5" />Disabled</Badge>}
Configuration
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : (
<>
<div className="flex items-center justify-between">
<div>
<Label>Turnitin enabled</Label>
<div className="text-xs text-muted-foreground">Hides/Shows the originality button across the institution.</div>
</div>
<Switch
checked={draft.enabled}
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
/>
</div>
<div>
<Label>API base URL</Label>
<Input
value={draft.api_url}
onChange={(e) => setDraft({ ...draft, api_url: e.target.value })}
placeholder="https://api.turnitin.com"
/>
</div>
<div>
<Label>Account ID</Label>
<Input
value={draft.account_id}
onChange={(e) => setDraft({ ...draft, account_id: e.target.value })}
/>
</div>
<div>
<Label>API key {settings?.has_api_key && <span className="text-xs text-muted-foreground">(leave blank to keep current)</span>}</Label>
<div className="relative">
<Input
type={show ? "text" : "password"}
placeholder={settings?.has_api_key ? "•••••••••••••••" : "Paste your Turnitin token"}
value={draft.api_key}
onChange={(e) => setDraft({ ...draft, api_key: e.target.value })}
/>
<button
type="button"
className="absolute end-2 top-1/2 -translate-y-1/2 text-muted-foreground"
onClick={() => setShow(!show)}
>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
{!settings?.has_api_key && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>No API key configured</AlertTitle>
<AlertDescription>
Submissions will return a synthetic stub originality score until a key is provided.
</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
<Button onClick={() => save.mutate()} disabled={save.isPending}>
{save.isPending ? "Saving…" : "Save"}
</Button>
<Button
variant="outline"
onClick={() => test.mutate()}
disabled={test.isPending || !settings?.has_api_key}
>
{test.isPending ? "Testing…" : "Test connection"}
</Button>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
);
}
function Badge({ children, tone }: { children: React.ReactNode; tone: "ok" | "warn" }) {
const cls = tone === "ok"
? "bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-300"
: "bg-amber-50 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300";
return (
<span className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ${cls}`}>
{children}
</span>
);
}

View File

@@ -22,6 +22,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
import { cn } from "@/lib/utils";
import { mediaService } from "@/services/media.service";
import { useExamSecurity } from "@/hooks/useExamSecurity";
function normalizeType(t: string | null | undefined) {
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
@@ -88,6 +89,15 @@ export default function ExamSession() {
} as typeof rawSession;
}, [rawSession]);
// Phase 2 (2026-04-30): online-test SOFT security tier. Records tab
// blur, paste/copy, fullscreen exits, etc. into the per-attempt log
// visible to the teacher. Runs as long as the session is loaded.
useExamSecurity({
attemptId: (session as any)?.attempt_id ?? null,
examId,
enabled: !!session,
});
const [sectionIdx, setSectionIdx] = useState(0);
const [questionIdx, setQuestionIdx] = useState(0);
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());

View File

@@ -5,7 +5,8 @@ import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useCourse, useChapters, useGrades, useCourseCompletion } from "@/hooks/queries";
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy } from "lucide-react";
import { ArrowLeft, BookOpen, Lock, ChevronRight, Play, CheckCircle2, Trophy, MessageSquare } from "lucide-react";
import DiscussionTab from "@/components/discussion/DiscussionTab";
export default function StudentCourseDetail() {
const { id } = useParams();
@@ -69,6 +70,10 @@ export default function StudentCourseDetail() {
<TabsList>
<TabsTrigger value="chapters">Chapters ({chapters.length})</TabsTrigger>
<TabsTrigger value="grades">Grades ({gradeRecords.length})</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
Discussion
</TabsTrigger>
<TabsTrigger value="about">About</TabsTrigger>
</TabsList>
@@ -129,6 +134,10 @@ export default function StudentCourseDetail() {
))}
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab courseId={courseId} hideHeader />
</TabsContent>
<TabsContent value="about" className="mt-4">
<Card>
<CardHeader><CardTitle className="text-base">About this Course</CardTitle></CardHeader>

View File

@@ -1,11 +1,13 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import PlanReader from "@/components/coursePlan/PlanReader";
import DiscussionTab from "@/components/discussion/DiscussionTab";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
@@ -61,7 +63,25 @@ export default function StudentCoursePlanDetail() {
</div>
)}
{plan && <PlanReader plan={plan} mode="student" />}
{plan && (
<Tabs defaultValue="material">
<TabsList>
<TabsTrigger value="material">
{t("coursePlan.tabs.material", "Material")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="material" className="mt-4">
<PlanReader plan={plan} mode="student" />
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab planId={planId} hideHeader />
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -1,70 +1,300 @@
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
BookOpen,
GraduationCap,
Layers,
Play,
Sparkles,
Star,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Link } from "react-router-dom";
import { useMyEnrolledCourses } from "@/hooks/queries";
import { BookOpen, Users, Play } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import {
coursePlanService,
type StudentLearningItem,
} from "@/services/coursePlan.service";
/**
* Phase 1 (2026-04-29) — merged "My Learning" page.
*
* Replaces what used to be two separate sidebar entries ("My Courses"
* driven by `op.student.course` and "My Course Plans" driven by
* `encoach.course.plan.assignment`). Both lists now come from the
* unified `/api/student/learning` endpoint and are split into two
* sections by the `is_mandatory` flag:
*
* * **Accredited Core** — flagged courses/plans, surfaced with a
* bright "Mandatory" tag so students can immediately tell what
* counts toward graduation.
* * **Supplementary / Elective** — everything else, shown
* underneath in a calmer blue palette.
*
* Each card links to the same detail routes that used to power the
* separate pages (`/student/courses/:id` for op.course,
* `/student/course-plans/:planId` for AI plans), so existing deep
* links keep working.
*/
export default function StudentCourses() {
const { data: enrolledData, isLoading } = useMyEnrolledCourses();
const myCourses = enrolledData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const { t } = useTranslation();
const { data, isLoading, isError } = useQuery({
queryKey: ["student-learning"],
queryFn: () => coursePlanService.studentLearning(),
});
const mandatory = data?.mandatory ?? [];
const elective = data?.elective ?? [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Courses</h1>
<p className="text-muted-foreground">Browse and continue your enrolled courses.</p>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Layers className="h-6 w-6 text-primary" />
{t("learning.title", "My Learning")}
</h1>
<p className="text-muted-foreground">
{t(
"learning.subtitle",
"All your accredited courses and supplementary materials in one place.",
)}
</p>
</div>
<AiTipBanner context="student-courses" variant="recommendation" />
{myCourses.length === 0 ? (
<div className="text-center py-12">
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold">No Enrolled Courses</h3>
<p className="text-muted-foreground mt-1">You haven't been enrolled in any courses yet. Contact your administrator.</p>
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t("learning.loadFailed", "Failed to load your courses. Please try again.")}
</div>
)}
{!isError && mandatory.length === 0 && elective.length === 0 && (
<div className="text-center py-12 border rounded-2xl bg-muted/20">
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t(
"learning.empty",
"You haven't been enrolled in any courses or plans yet. Contact your administrator.",
)}
</p>
</div>
)}
{/*
Phase 1 (2026-04-29): always render BOTH sections — even when one
is empty — so students immediately see the structure (and the
intent that "Accredited Core" comes first). Empty sections fall
back to a calm empty-state instead of disappearing entirely.
*/}
{(mandatory.length > 0 || elective.length > 0) && (
<>
<Section
title={t("learning.accreditedCore", "Accredited Core")}
subtitle={t(
"learning.accreditedCoreSub",
"Mandatory programs that count toward your official record.",
)}
tone="mandatory"
items={mandatory}
/>
<Section
title={t("learning.supplementary", "Supplementary & Elective")}
subtitle={t(
"learning.supplementarySub",
"Optional content, AI-generated study plans, and self-paced material.",
)}
tone="elective"
items={elective}
/>
</>
)}
</div>
);
}
function Section({
title,
subtitle,
tone,
items,
}: {
title: string;
subtitle: string;
tone: "mandatory" | "elective";
items: StudentLearningItem[];
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div>
<h2 className="text-lg font-semibold flex items-center gap-2">
{tone === "mandatory" ? (
<Star className="h-4 w-4 text-primary" fill="currentColor" />
) : (
<Sparkles className="h-4 w-4 text-blue-500" />
)}
{title}
<Badge variant="secondary" className="ml-1">
{items.length}
</Badge>
</h2>
<p className="text-xs text-muted-foreground mt-0.5">{subtitle}</p>
</div>
</div>
{items.length === 0 ? (
<div className="text-center py-8 border rounded-2xl bg-muted/10">
<p className="text-sm text-muted-foreground">
{tone === "mandatory"
? "No accredited / mandatory programs assigned to you yet."
: "No supplementary or elective material yet."}
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id}>
<Card className="hover:shadow-md transition-shadow h-full">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<Badge variant="secondary" className="text-xs">
{c.progress === 100 ? "Completed" : c.progress > 0 ? "In Progress" : "Not Started"}
</Badge>
<Badge variant="outline" className="text-xs">{c.code}</Badge>
</div>
<h3 className="font-semibold mt-2">{c.title || c.name}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.chapter_count} chapters</span>
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.student_count} students</span>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{c.progress}%</span>
</div>
<Progress value={c.progress} className="h-2" />
</div>
<Button variant="outline" className="w-full" size="sm">
<Play className="mr-2 h-3 w-3" />
{c.progress > 0 ? "Continue Learning" : "Start Learning"}
</Button>
</CardContent>
</Card>
</Link>
{items.map((item) => (
<LearningCard key={`${item.kind}-${item.id}`} item={item} tone={tone} />
))}
</div>
)}
</div>
);
}
function LearningCard({
item,
tone,
}: {
item: StudentLearningItem;
tone: "mandatory" | "elective";
}) {
const { t } = useTranslation();
const href =
item.kind === "plan"
? `/student/course-plans/${item.id}`
: `/student/courses/${item.id}`;
const accentBar =
tone === "mandatory"
? "bg-gradient-to-r from-primary to-orange-400"
: "bg-gradient-to-r from-blue-400 to-cyan-400";
const progress = item.progress ?? 0;
const statusLabel =
item.kind === "plan"
? item.status === "approved"
? t("learning.statusApproved", "Approved")
: item.status === "generated"
? t("learning.statusGenerated", "Generated")
: t("learning.statusDraft", "Draft")
: progress >= 100
? t("learning.statusCompleted", "Completed")
: progress > 0
? t("learning.statusInProgress", "In Progress")
: t("learning.statusNotStarted", "Not Started");
return (
<Link to={href}>
<Card className="hover:shadow-lg transition-shadow h-full overflow-hidden">
<div className={`h-2 ${accentBar}`} />
<CardContent className="pt-5 space-y-4">
<div>
<div className="flex items-center justify-between mb-1 flex-wrap gap-1">
{item.is_mandatory ? (
<Badge className="bg-primary text-primary-foreground hover:bg-primary text-xs">
{t("learning.mandatory", "Mandatory")}
</Badge>
) : (
<Badge
variant="outline"
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
>
{t("learning.elective", "Elective")}
</Badge>
)}
{item.kind === "plan" ? (
<Badge variant="secondary" className="text-xs">
<Sparkles className="h-3 w-3 mr-1" />
{t("learning.aiPlan", "AI Plan")}
</Badge>
) : (
item.code && (
<Badge variant="outline" className="text-xs">
{item.code}
</Badge>
)
)}
<Badge variant="secondary" className="text-xs">
{statusLabel}
</Badge>
</div>
<h3 className="font-semibold mt-2 line-clamp-2">{item.name}</h3>
{item.description && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{item.description}
</p>
)}
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
{item.kind === "plan" ? (
<>
{item.total_weeks ? (
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
{t("learning.weeks", "{{n}} weeks", { n: item.total_weeks })}
</span>
) : null}
{item.cefr_level && (
<span className="uppercase">{item.cefr_level}</span>
)}
</>
) : (
<>
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" />
{t("learning.chapters", "{{n}} chapters", {
n: item.chapter_count ?? 0,
})}
</span>
{item.batch_name && (
<span className="text-muted-foreground/80">
{item.batch_name}
</span>
)}
</>
)}
</div>
{item.kind === "course" && (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">
{t("learning.progress", "Progress")}
</span>
<span className="font-medium">{progress}%</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
<Button variant="outline" className="w-full" size="sm">
<Play className="mr-2 h-3 w-3" />
{progress > 0 || item.kind === "plan"
? t("learning.continue", "Continue Learning")
: t("learning.start", "Start Learning")}
</Button>
</CardContent>
</Card>
</Link>
);
}

View File

@@ -10,6 +10,8 @@ import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AITutorAvatar from "@/components/ai/AITutorAvatar";
import PersonalizedPlanCard from "@/components/student/PersonalizedPlanCard";
import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() {
@@ -42,13 +44,18 @@ export default function StudentDashboard() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
</div>
<AITutorAvatar size={72} name="EnCoach AI Tutor" />
</div>
<AiTipBanner context="student-dashboard" variant="tip" />
<PersonalizedPlanCard />
<AiStudyCoach />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">

View File

@@ -0,0 +1,372 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Users,
CalendarCheck,
ClipboardCheck,
BookOpen,
TrendingUp,
AlertTriangle,
MessageSquare,
} from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api-client";
import DiscussionTab from "@/components/discussion/DiscussionTab";
interface CourseInsightsResponse {
course_id: number;
course_name: string;
students: {
total: number;
by_batch: { batch_id: number; batch_name: string; count: number }[];
};
attendance: {
lines_total: number;
present: number;
absent: number;
excused: number;
late: number;
rate_present: number;
rate_absent: number;
rate_late: number;
};
submissions: {
exams_assigned: number;
expected_attempts: number;
completed_attempts: number;
rate_completed: number;
};
materials: {
chapters: number;
students_started: number;
completed_progress_rows: number;
rate_chapters: number;
};
}
/**
* Phase 1 (2026-04-29) — Class Insights tab for teachers.
*
* Surfaces what teachers asked for: student count, attendance vs
* absence breakdown, task submission progress, and materials done.
* Pulls everything from `/api/teacher/courses/:id/insights`, which
* aggregates `op.student.course`, `op.attendance.line`,
* `encoach.exam.assignment`/`encoach.student.attempt`, and
* `encoach.chapter.progress` server-side.
*/
export default function TeacherCourseInsights() {
const { t } = useTranslation();
const { courseId: rawId } = useParams<{ courseId: string }>();
const courseId = Number(rawId);
const { data, isLoading, isError } = useQuery({
queryKey: ["teacher-course-insights", courseId],
enabled: Number.isFinite(courseId) && courseId > 0,
queryFn: () =>
api.get<CourseInsightsResponse>(`/teacher/courses/${courseId}/insights`),
});
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link to="/teacher/courses">
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
</Link>
</Button>
<div>
<h1 className="text-2xl font-bold">
{data?.course_name || t("teacher.insights.title", "Class Insights")}
</h1>
<p className="text-muted-foreground text-sm">
{t(
"teacher.insights.subtitle",
"Enrollment, attendance, submission progress, and chapter completion at a glance.",
)}
</p>
</div>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-32" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
<AlertTriangle className="inline h-4 w-4 mr-1" />
{t("teacher.insights.loadFailed", "Failed to load class insights.")}
</div>
)}
{data && (
<>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatCard
icon={<Users className="h-4 w-4" />}
label={t("teacher.insights.students", "Students enrolled")}
value={data.students.total}
tone="primary"
/>
<StatCard
icon={<CalendarCheck className="h-4 w-4" />}
label={t("teacher.insights.attendanceRate", "Attendance rate")}
value={`${data.attendance.rate_present}%`}
hint={`${data.attendance.present} present · ${data.attendance.absent} absent`}
tone="success"
/>
<StatCard
icon={<ClipboardCheck className="h-4 w-4" />}
label={t("teacher.insights.submissions", "Task submission")}
value={`${data.submissions.rate_completed}%`}
hint={
data.submissions.exams_assigned
? `${data.submissions.completed_attempts} / ${data.submissions.expected_attempts} attempts`
: t(
"teacher.insights.noAssignments",
"No exams assigned yet",
)
}
tone="info"
/>
<StatCard
icon={<BookOpen className="h-4 w-4" />}
label={t("teacher.insights.chapters", "Chapter completion")}
value={`${data.materials.rate_chapters}%`}
hint={`${data.materials.completed_progress_rows} completed · ${data.materials.chapters} chapters`}
tone="warning"
/>
</div>
<Tabs defaultValue="overview" className="mt-2">
<TabsList>
<TabsTrigger value="overview">
{t("teacher.insights.tabs.overview", "Overview")}
</TabsTrigger>
<TabsTrigger value="batches">
{t("teacher.insights.tabs.batches", "By Batch")}
</TabsTrigger>
<TabsTrigger value="discussion">
<MessageSquare className="h-3 w-3 mr-1" />
{t("discussion.title", "Discussion")}
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<CalendarCheck className="h-4 w-4 text-primary" />
{t("teacher.insights.attendanceBreakdown", "Attendance breakdown")}
</CardTitle>
</CardHeader>
<CardContent>
{data.attendance.lines_total === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noAttendance",
"No attendance recorded yet.",
)}
</p>
) : (
<div className="space-y-2">
<Bar
label={t("teacher.insights.present", "Present")}
value={data.attendance.present}
total={data.attendance.lines_total}
color="bg-green-500"
/>
<Bar
label={t("teacher.insights.absent", "Absent")}
value={data.attendance.absent}
total={data.attendance.lines_total}
color="bg-red-500"
/>
<Bar
label={t("teacher.insights.excused", "Excused")}
value={data.attendance.excused}
total={data.attendance.lines_total}
color="bg-amber-500"
/>
<Bar
label={t("teacher.insights.late", "Late")}
value={data.attendance.late}
total={data.attendance.lines_total}
color="bg-purple-500"
/>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-primary" />
{t("teacher.insights.tasks", "Task submission progress")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.assignedExams",
"Assigned exams",
)}
</span>
<span className="font-medium">
{data.submissions.exams_assigned}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.expectedAttempts",
"Expected attempts",
)}
</span>
<span className="font-medium">
{data.submissions.expected_attempts}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">
{t(
"teacher.insights.completedAttempts",
"Completed attempts",
)}
</span>
<span className="font-medium">
{data.submissions.completed_attempts}
</span>
</div>
<Progress
value={data.submissions.rate_completed}
className="h-2 mt-1"
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="batches" className="mt-4">
<Card>
<CardContent className="pt-6">
{data.students.by_batch.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
"teacher.insights.noBatches",
"No students assigned to batches.",
)}
</p>
) : (
<div className="space-y-2">
{data.students.by_batch.map((b) => (
<div
key={b.batch_id}
className="flex items-center justify-between py-2 border-b last:border-b-0"
>
<div>
<p className="text-sm font-medium">{b.batch_name}</p>
<p className="text-xs text-muted-foreground">
{t("teacher.insights.batchId", "Batch #{{id}}", {
id: b.batch_id,
})}
</p>
</div>
<Badge variant="secondary">
{b.count}{" "}
{t(
"teacher.insights.studentsLower",
"students",
)}
</Badge>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="discussion" className="mt-4">
<DiscussionTab courseId={courseId} hideHeader />
</TabsContent>
</Tabs>
</>
)}
</div>
);
}
function StatCard({
icon,
label,
value,
hint,
tone,
}: {
icon: React.ReactNode;
label: string;
value: string | number;
hint?: string;
tone: "primary" | "success" | "info" | "warning";
}) {
const toneClasses: Record<string, string> = {
primary: "from-primary/15 to-primary/5 text-primary",
success: "from-green-500/15 to-green-500/5 text-green-700 dark:text-green-300",
info: "from-blue-500/15 to-blue-500/5 text-blue-700 dark:text-blue-300",
warning:
"from-amber-500/15 to-amber-500/5 text-amber-700 dark:text-amber-300",
};
return (
<Card className="overflow-hidden">
<CardContent
className={`pt-5 pb-5 bg-gradient-to-br ${toneClasses[tone]}`}
>
<div className="flex items-center gap-2 text-xs uppercase tracking-wider opacity-80">
{icon}
{label}
</div>
<div className="text-3xl font-bold mt-2 text-foreground">{value}</div>
{hint && <div className="text-xs text-muted-foreground mt-1">{hint}</div>}
</CardContent>
</Card>
);
}
function Bar({
label,
value,
total,
color,
}: {
label: string;
value: number;
total: number;
color: string;
}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">
{value} ({pct}%)
</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div className={`h-full ${color}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { useCourses, useChapters } from "@/hooks/queries";
import { lmsService } from "@/services";
import { useQueryClient } from "@tanstack/react-query";
import { Link, useNavigate } from "react-router-dom";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2 } from "lucide-react";
import { Plus, Users, BookOpen, Pencil, FolderOpen, Wand2, BarChart3 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { Course } from "@/types";
@@ -61,6 +61,9 @@ function CourseCard({ course }: { course: Course }) {
<Button size="sm" variant="default" onClick={() => navigate(`/teacher/courses/${course.id}/chapters`)}>
<FolderOpen className="mr-1.5 h-3 w-3" /> Chapters & Materials
</Button>
<Button size="sm" variant="outline" onClick={() => navigate(`/teacher/courses/${course.id}/insights`)}>
<BarChart3 className="mr-1.5 h-3 w-3" /> Class Insights
</Button>
<Button size="sm" variant="ghost" onClick={() => navigate(`/teacher/courses/${course.id}/workbench`)}>
<Wand2 className="mr-1.5 h-3 w-3" /> AI Workbench
</Button>

View File

@@ -0,0 +1,180 @@
import { useState } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ShieldAlert, Eye, RefreshCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,
} from "@/components/ui/dialog";
import { examSecurityService, type SecurityEvent } from "@/services/examSecurity.service";
/**
* Live monitoring console for a proctored online test assignment.
* Lists every in-progress attempt with the count of "alert" events.
* Click any row to inspect that attempt's full security event log.
*/
export default function TeacherTestSessionConsole() {
const { assignmentId: raw } = useParams<{ assignmentId: string }>();
const assignmentId = Number(raw);
const { data, isLoading, refetch, isFetching } = useQuery({
queryKey: ["teacher-test-console", assignmentId],
queryFn: () => examSecurityService.liveTestConsole(assignmentId),
enabled: !!assignmentId,
refetchInterval: 5000,
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ShieldAlert className="h-6 w-6 text-orange-500" />
Test session console
</h1>
<p className="text-muted-foreground text-sm">
Live attempts in this assignment + suspicious-event log per student.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
<RefreshCw className={`h-3.5 w-3.5 me-1 ${isFetching ? "animate-spin" : ""}`} />
Refresh
</Button>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
In-progress attempts
<Badge variant="secondary" className="ms-2">{data?.in_progress.length ?? 0}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : data?.in_progress.length === 0 ? (
<div className="px-6 py-12 text-center text-muted-foreground">
No active attempts.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Started</TableHead>
<TableHead className="text-center">Alerts</TableHead>
<TableHead>Last event</TableHead>
<TableHead className="text-end">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.in_progress.map((row) => (
<TableRow key={row.attempt_id}>
<TableCell className="font-medium">{row.student_name || `User #${row.student_id}`}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{row.started_at ? new Date(row.started_at).toLocaleString() : "—"}
</TableCell>
<TableCell className="text-center">
<AlertBadge n={row.alert_count} />
</TableCell>
<TableCell className="text-sm">
{row.last_event ? (
<div>
<div className="capitalize">{row.last_event.event_type.replace("_", " ")}</div>
<div className="text-xs text-muted-foreground">
{row.last_event.occurred_at ? new Date(row.last_event.occurred_at).toLocaleTimeString() : ""}
</div>
</div>
) : <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell className="text-end">
<AttemptDetailsDialog attemptId={row.attempt_id} studentName={row.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
function AlertBadge({ n }: { n: number }) {
if (n === 0) return <Badge variant="secondary">0</Badge>;
if (n < 3) return <Badge className="bg-amber-500">{n}</Badge>;
return <Badge className="bg-red-500">{n}</Badge>;
}
function AttemptDetailsDialog({
attemptId, studentName,
}: { attemptId: number; studentName: string }) {
const [open, setOpen] = useState(false);
const { data, isLoading } = useQuery({
queryKey: ["attempt-events", attemptId],
queryFn: () => examSecurityService.listAttemptEvents(attemptId),
enabled: open,
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Eye className="h-3.5 w-3.5 me-1" />Details
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{studentName} security log</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="text-sm text-muted-foreground">Loading</div>
) : (
<div className="max-h-[60vh] overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>Event</TableHead>
<TableHead>Severity</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items.map((ev: SecurityEvent) => (
<TableRow key={ev.id}>
<TableCell className="text-xs">{ev.occurred_at ? new Date(ev.occurred_at).toLocaleString() : "—"}</TableCell>
<TableCell className="capitalize">{ev.event_type.replace("_", " ")}</TableCell>
<TableCell>
<Badge
className={
ev.severity === "alert" ? "bg-red-500"
: ev.severity === "warning" ? "bg-amber-500"
: "bg-zinc-400"
}
>
{ev.severity}
</Badge>
</TableCell>
</TableRow>
))}
{data?.items.length === 0 && (
<TableRow><TableCell colSpan={3} className="text-center text-muted-foreground">No events recorded for this attempt.</TableCell></TableRow>
)}
</TableBody>
</Table>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -11,7 +11,7 @@ import type {
import type { ApiSuccessResponse, PaginatedResponse, PaginationParams } from "@/types";
export const communicationService = {
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number }): Promise<DiscussionBoard[]> {
async listDiscussionBoards(params?: { course_id?: number; batch_id?: number; plan_id?: number }): Promise<DiscussionBoard[]> {
return api.get<DiscussionBoard[]>("/discussion-boards", params as Record<string, string | number | boolean | undefined>);
},
@@ -19,6 +19,17 @@ export const communicationService = {
return api.post<DiscussionBoard>("/discussion-boards", data);
},
// Phase 1 (2026-04-29): course/plan-scoped board lookup-or-create.
// Used by the embedded Discussion tab inside course / course-plan
// detail pages so the tab "just works" without manual setup.
async getBoardForCourse(courseId: number): Promise<DiscussionBoard> {
return api.get<DiscussionBoard>(`/discussion-boards/for-course/${courseId}`);
},
async getBoardForPlan(planId: number): Promise<DiscussionBoard> {
return api.get<DiscussionBoard>(`/discussion-boards/for-plan/${planId}`);
},
async updateDiscussionBoard(id: number, data: Partial<DiscussionBoard>): Promise<DiscussionBoard> {
return api.patch<DiscussionBoard>(`/discussion-boards/${id}`, data);
},

View File

@@ -13,6 +13,45 @@ import type {
WorkbookGroundedSource,
} from "@/types";
/**
* Item returned by GET /api/student/learning. The endpoint returns
* BOTH ``op.course`` enrollments and ``encoach.course.plan``
* assignments through a discriminated union on ``kind``. Each item
* carries the ``is_mandatory`` flag so the UI can group items into
* the Accredited Core / Supplementary sections.
*/
export interface StudentLearningItem {
kind: "course" | "plan";
id: number;
name: string;
description?: string;
is_mandatory: boolean;
cefr_level?: string;
difficulty_level?: string;
total_weeks?: number;
status?: string;
course_id?: number | null;
course_name?: string;
entity_id?: number | null;
entity_name?: string;
code?: string;
batch_id?: number | null;
batch_name?: string;
chapter_count?: number;
completed_chapters?: number;
progress?: number;
assignment_id?: number;
assignment_mode?: string;
}
export interface StudentLearningResponse {
mandatory: StudentLearningItem[];
elective: StudentLearningItem[];
count_mandatory: number;
count_elective: number;
count_total: number;
}
/**
* REST helpers for the AI course plan generator.
*
@@ -115,11 +154,46 @@ export const coursePlanService = {
body_text?: string;
share_date?: string | null;
is_static?: boolean;
// Phase 1 (2026-04-29) — kind/exam/resource/grading edits.
kind?: "content" | "quiz" | "test" | "resource" | "assignment";
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<{ data: CoursePlanMaterial }> {
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
},
// Phase 1 (2026-04-29): create a manual non-AI material (quiz /
// test / resource / assignment) inside a specific week of the
// delivery plan. The shape mirrors the new `/materials/manual` route
// on the backend.
async createManualMaterial(
planId: number,
weekNumber: number,
payload: {
title: string;
kind: "content" | "quiz" | "test" | "resource" | "assignment";
skill?: string;
material_type?: string;
summary?: string;
exam_template_id?: number | null;
resource_id?: number | null;
due_date?: string | null;
is_graded?: boolean;
},
): Promise<CoursePlanMaterial> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/manual`,
payload,
);
},
async deleteMaterial(materialId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/material/${materialId}`);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
@@ -329,6 +403,14 @@ export const coursePlanService = {
return api.get("/student/course-plans");
},
// Phase 1 (2026-04-29): merged "My Learning" view that returns
// both classic op.course enrollments AND AI course plans, each
// tagged with `is_mandatory` so the UI can group them under
// Accredited Core vs Supplementary.
async studentLearning(): Promise<StudentLearningResponse> {
return api.get("/student/learning");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},

View File

@@ -0,0 +1,89 @@
import { api } from "@/lib/api-client";
export type SecurityEventType =
| "tab_blur"
| "tab_focus"
| "window_blur"
| "window_focus"
| "paste"
| "copy"
| "cut"
| "context_menu"
| "fullscreen_exit"
| "shortcut"
| "multi_attempt_block"
| "other";
export type SecuritySeverity = "info" | "warning" | "alert";
export interface SecurityEventPayload {
attempt_id?: number | null;
exam_id?: number | null;
event_type: SecurityEventType;
severity?: SecuritySeverity;
payload?: Record<string, unknown>;
}
export interface SecurityEvent {
id: number;
attempt_id: number | null;
student_id: number;
student_name: string;
exam_id: number | null;
event_type: SecurityEventType;
severity: SecuritySeverity;
occurred_at: string | null;
payload: unknown;
}
export interface LiveTestRow {
attempt_id: number;
student_id: number | null;
student_name: string;
started_at: string | null;
alert_count: number;
last_event: SecurityEvent | null;
}
export const examSecurityService = {
/** Post a single suspicious-activity event to the server. */
async postEvent(p: SecurityEventPayload): Promise<{ event_id: number; auto_locked: boolean }> {
return api.post("/exam/security/event", p);
},
async singleAttemptCheck(examId: number) {
return api.get<{
allowed: boolean;
security_level: "off" | "soft" | "strict";
single_attempt: boolean;
existing_attempt_id: number | null;
}>("/exam/single-attempt-check", { exam_id: examId });
},
async listAssignmentEvents(assignmentId: number) {
return api.get<{ items: SecurityEvent[]; total: number }>(
`/teacher/exam/${assignmentId}/security/events`,
);
},
async listAttemptEvents(attemptId: number) {
return api.get<{
attempt_id: number;
student_id: number | null;
student_name: string;
exam_id: number | null;
status: string;
items: SecurityEvent[];
total: number;
}>(`/teacher/exam/attempt/${attemptId}/security/events`);
},
async liveTestConsole(assignmentId: number) {
return api.get<{
assignment_id: number;
exam_id: number;
in_progress: LiveTestRow[];
total: number;
}>(`/teacher/exam/${assignmentId}/live`);
},
};

View File

@@ -0,0 +1,69 @@
import { api, API_BASE_URL } from "@/lib/api-client";
export type HandwrittenStatus =
| "submitted"
| "grading"
| "graded"
| "reviewed"
| "failed";
export interface HandwrittenSubmission {
id: number;
student_id: number | null;
student_name: string;
material_id: number | null;
plan_id: number | null;
submitted_at: string | null;
status: HandwrittenStatus;
student_note: string;
image_count: number;
image_urls: string[];
ai_score: number | null;
ai_feedback: string;
ai_extracted_text: string;
teacher_score: number | null;
teacher_feedback: string;
reviewed_by: string;
reviewed_at: string | null;
}
export const handwrittenService = {
async upload(materialId: number, files: File[], note = ""): Promise<HandwrittenSubmission> {
const fd = new FormData();
for (const f of files) fd.append("pages", f);
if (note) fd.append("note", note);
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(
`${API_BASE_URL}/student/handwritten/${materialId}`,
{
method: "POST",
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,
},
);
if (!res.ok) {
throw new Error(await res.text());
}
return res.json();
},
async mySubmission(materialId: number): Promise<HandwrittenSubmission | null> {
const r = await api.get<{ submission: HandwrittenSubmission | null }>(
`/student/handwritten/${materialId}`,
);
return r.submission;
},
async listForTeacher(materialId: number) {
return api.get<{ items: HandwrittenSubmission[]; total: number }>(
`/teacher/handwritten/${materialId}`,
);
},
async review(submissionId: number, score: number, feedback: string) {
return api.post<HandwrittenSubmission>(
`/teacher/handwritten/${submissionId}/review`,
{ score, feedback },
);
},
};

View File

@@ -35,3 +35,9 @@ export { studentProgressService } from "./student-progress.service";
export { libraryService } from "./library.service";
export { activityService } from "./activity.service";
export { facilityService } from "./facility.service";
export { examSecurityService } from "./examSecurity.service";
export { liveSessionService } from "./liveSession.service";
export { personalizedPlanService } from "./personalizedPlan.service";
export { handwrittenService } from "./handwritten.service";
export { turnitinService } from "./turnitin.service";
export { reportsExportService } from "./reportsExport.service";

View File

@@ -0,0 +1,181 @@
import { api, API_BASE_URL } from "@/lib/api-client";
export type LiveSessionStatus = "scheduled" | "live" | "ended" | "cancelled";
export interface LiveSession {
id: number;
title: string;
description: string;
course_id: number | null;
course_name: string;
batch_id: number | null;
batch_name: string;
plan_id: number | null;
plan_name: string;
host_id: number | null;
host_name: string;
scheduled_at: string | null;
duration_min: number;
actual_started_at: string | null;
actual_ended_at: string | null;
status: LiveSessionStatus;
room_name: string;
waiting_room: boolean;
recording_enabled: boolean;
recording_url: string;
auto_attendance_minutes: number;
late_entry_minutes: number;
notification_sent: boolean;
participant_count: number;
}
export type LiveAttendanceStatus =
| "not_joined"
| "attending"
| "present"
| "left_early"
| "removed";
export interface LiveSessionParticipant {
id: number;
session_id: number;
user_id: number;
user_name: string;
joined_at: string | null;
left_at: string | null;
duration_seconds: number;
attendance_status: LiveAttendanceStatus;
removed_reason: string;
is_host: boolean;
}
export interface LiveSessionDetail extends LiveSession {
participants: LiveSessionParticipant[];
enrolled_user_ids: number[];
}
export interface CreateLiveSessionPayload {
title: string;
description?: string;
scheduled_at: string;
duration_min?: number;
course_id?: number;
batch_id?: number;
plan_id?: number;
entity_id?: number;
waiting_room?: boolean;
recording_enabled?: boolean;
auto_attendance_minutes?: number;
late_entry_minutes?: number;
notify?: boolean;
}
export const liveSessionService = {
async list(filters: {
course_id?: number;
batch_id?: number;
plan_id?: number;
host_id?: number;
status?: LiveSessionStatus;
mine?: boolean;
} = {}): Promise<{ items: LiveSession[]; total: number }> {
return api.get("/live-sessions", filters);
},
async create(payload: CreateLiveSessionPayload): Promise<LiveSession> {
return api.post("/live-sessions", payload);
},
async read(id: number): Promise<LiveSessionDetail> {
return api.get(`/live-sessions/${id}`);
},
async update(id: number, payload: Partial<CreateLiveSessionPayload> & {
status?: LiveSessionStatus;
recording_url?: string;
}): Promise<LiveSession> {
return api.patch(`/live-sessions/${id}`, payload);
},
async cancel(id: number): Promise<{ ok: boolean }> {
return api.delete(`/live-sessions/${id}`);
},
async notify(id: number): Promise<{ sent: number }> {
return api.post(`/live-sessions/${id}/notify`, {});
},
async join(id: number) {
return api.post<{
ok: boolean;
participant: LiveSessionParticipant;
room_name: string;
is_host: boolean;
jitsi_jwt: string;
}>(`/live-sessions/${id}/join`, {});
},
async leave(id: number) {
return api.post<{ ok: boolean; participant?: LiveSessionParticipant; no_participant?: boolean }>(
`/live-sessions/${id}/leave`,
{},
);
},
async end(id: number) {
return api.post<{ ok: boolean }>(`/live-sessions/${id}/end`, {});
},
async storeRecording(id: number, url: string) {
return api.post<{ ok: boolean; url: string }>(`/live-sessions/${id}/recording`, {
url,
});
},
async removeParticipant(id: number, userId: number, reason: string) {
return api.post<{ ok: boolean; participant: LiveSessionParticipant }>(
`/live-sessions/${id}/remove-participant`,
{ user_id: userId, reason },
);
},
async attendanceRoll(id: number) {
return api.get<{
session_id: number;
status: LiveSessionStatus;
roll: {
user_id: number;
user_name: string;
attendance_status: LiveAttendanceStatus;
duration_seconds: number;
removed_reason: string;
}[];
}>(`/live-sessions/${id}/attendance`);
},
/**
* Fetch the .ics calendar file for this session (JWT-protected) and
* trigger a browser download. Calendar apps (Gmail/Outlook/Apple Mail)
* recognize the .ics MIME type and prompt the user to add the event.
*/
async downloadIcs(id: number, filenameHint = "live-session") {
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(`${API_BASE_URL}/live-sessions/${id}/ics`, {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = `${filenameHint}-${id}.ics`;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 100);
},
};

View File

@@ -0,0 +1,43 @@
import { api } from "@/lib/api-client";
export interface PersonalizedWeakness {
attempts: number;
averages: {
reading: number | null;
listening: number | null;
writing: number | null;
speaking: number | null;
};
weak_skills: string[];
weakest_skill: string | null;
}
export interface PersonalizedPlanSummary {
id: number;
name: string;
cefr_level: string;
total_weeks: number;
is_personalized: boolean;
is_mandatory: boolean;
created_at: string | null;
}
export interface PersonalizedPlanResponse {
plan: PersonalizedPlanSummary | null;
weakness: PersonalizedWeakness;
}
export const personalizedPlanService = {
async fetch(): Promise<PersonalizedPlanResponse> {
return api.get("/student/personalized-plan");
},
async generate(opts: {
cefr_level?: string;
total_weeks?: number;
focus?: string;
grammar_focus?: string[];
} = {}): Promise<PersonalizedPlanResponse> {
return api.post("/student/personalized-plan", opts);
},
};

View File

@@ -91,43 +91,61 @@ export interface RecordResponse {
export interface ReportsFiltersResponse {
entities: { id: number; name: string }[];
students: { id: number; name: string; login: string }[];
branches?: { id: number; name: string; entity_id: number | null }[];
subjects?: { id: number; name: string; code: string }[];
genders?: { value: string; label: string }[];
}
type QueryParams = Record<string, string | number | boolean | undefined>;
// Phase 1 (2026-04-29): comparative-analysis filters added to all
// three report endpoints. These params are optional everywhere so
// existing callers keep working unchanged.
export interface CommonReportFilters {
branch_id?: number;
subject_id?: number;
gender?: "m" | "f" | "o" | string;
}
export const reportsService = {
async studentPerformance(params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
}): Promise<StudentPerformanceResponse> {
async studentPerformance(
params?: {
entity_id?: number;
level?: string;
search?: string;
since?: string;
} & CommonReportFilters,
): Promise<StudentPerformanceResponse> {
return api.get<StudentPerformanceResponse>(
"/reports/student-performance",
params as QueryParams,
);
},
async statsCorporate(params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
}): Promise<StatsCorporateResponse> {
async statsCorporate(
params?: {
entity_id?: number;
threshold?: number;
months?: number;
since?: string;
} & CommonReportFilters,
): Promise<StatsCorporateResponse> {
return api.get<StatsCorporateResponse>(
"/reports/stats-corporate",
params as QueryParams,
);
},
async record(params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
}): Promise<RecordResponse> {
async record(
params?: {
user_id?: number;
entity_id?: number;
period?: "day" | "week" | "month";
status?: string;
page?: number;
size?: number;
} & CommonReportFilters,
): Promise<RecordResponse> {
return api.get<RecordResponse>("/reports/record", params as QueryParams);
},

View File

@@ -0,0 +1,53 @@
import { API_BASE_URL } from "@/lib/api-client";
/**
* Trigger a CSV/PDF download for one of the admin reports.
* The browser handles the download via the Content-Disposition
* header from the backend; we add the JWT manually because the
* <a download> tag cannot send Authorization headers.
*/
async function download(path: string, filename: string, params: Record<string, unknown> = {}) {
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== "") {
url.searchParams.set(k, String(v));
}
});
const token = localStorage.getItem("encoach_token") || "";
const res = await fetch(url.toString(), {
method: "GET",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) {
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
}
const blob = await res.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 100);
}
export const reportsExportService = {
async studentPerformance(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
const ext = format === "pdf" ? "pdf" : "csv";
return download(
"/reports/student-performance/export",
`student-performance.${ext}`,
{ ...filters, format },
);
},
async record(format: "csv" | "pdf", filters: Record<string, unknown> = {}) {
const ext = format === "pdf" ? "pdf" : "csv";
return download(
"/reports/record/export",
`attempt-record.${ext}`,
{ ...filters, format },
);
},
};

View File

@@ -0,0 +1,42 @@
import { api } from "@/lib/api-client";
export interface TurnitinSettings {
entity_id: number;
entity_name: string;
enabled: boolean;
api_url: string;
account_id: string;
has_api_key: boolean;
api_key: string | null;
}
export const turnitinService = {
async getSettings(entityId: number): Promise<TurnitinSettings> {
return api.get(`/turnitin/settings/${entityId}`);
},
async patchSettings(entityId: number, payload: {
enabled?: boolean;
api_key?: string;
api_url?: string;
account_id?: string;
}): Promise<TurnitinSettings> {
return api.patch(`/turnitin/settings/${entityId}`, payload);
},
async test(entityId: number) {
return api.post<{
ok: boolean;
job_id: string;
originality_score: number | null;
}>(`/turnitin/test/${entityId}`, {});
},
async submit(text: string, title: string, entityId?: number) {
return api.post<{
job_id: string;
entity_id: number;
originality_score: number | null;
}>(`/turnitin/submit`, { text, title, entity_id: entityId });
},
};

View File

@@ -7,6 +7,8 @@ export interface DiscussionBoard {
batch_name?: string;
chapter_id?: number;
chapter_name?: string;
plan_id?: number | null;
plan_name?: string | null;
assignment_id?: number;
is_enabled: boolean;
allow_student_posts: boolean;

View File

@@ -190,6 +190,16 @@ export interface CoursePlanWeek {
material_count: number;
}
// Phase 1 (2026-04-29): material `kind` describes how the student
// interacts with the row. Existing materials (created before the
// migration) default to `content` so the UI can keep rendering them.
export type CoursePlanMaterialKind =
| "content"
| "quiz"
| "test"
| "resource"
| "assignment";
export interface CoursePlanMaterial {
id: number;
plan_id: number;
@@ -197,6 +207,18 @@ export interface CoursePlanMaterial {
week_number: number;
skill: string;
material_type: CoursePlanMaterialType | string;
/** Phase 1: top-level interaction kind (content / quiz / test / …). */
kind?: CoursePlanMaterialKind;
/** Phase 1: optional link to an exam template (used when kind=quiz/test). */
exam_template_id?: number | null;
exam_template_name?: string | null;
/** Phase 1: optional link to a library resource (used when kind=resource). */
resource_id?: number | null;
resource_name?: string | null;
/** Phase 1: whether this row counts toward the course grade. */
is_graded?: boolean;
/** Phase 1: optional deadline displayed on the student weekly view. */
due_date?: string | null;
title: string;
is_static?: boolean;
share_date?: string | null;

View File

@@ -28,6 +28,13 @@ export default {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
// Dark charcoal CTA (default Button color in the new visual
// identity). Kept distinct from `primary` so the brand orange
// remains available for accents / highlights / charts.
cta: {
DEFAULT: "hsl(var(--cta))",
foreground: "hsl(var(--cta-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
@@ -76,9 +83,15 @@ export default {
},
},
borderRadius: {
// `--radius` is now 1rem (16px) — matching the soft-card / pill
// language of the new visual identity. The full ladder gives
// shadcn primitives sensible scaled steps without needing
// every component to opt in to a specific class.
sm: "calc(var(--radius) - 8px)",
md: "calc(var(--radius) - 4px)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "calc(var(--radius) + 4px)",
"2xl": "calc(var(--radius) + 12px)",
},
keyframes: {
"accordion-down": {