Files
full_encoach_platform/frontend/src/pages/teacher/TeacherCourses.tsx
Yamen Ahmad d5b1987bba 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
2026-04-30 14:06:48 +04:00

124 lines
6.2 KiB
TypeScript

import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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, BarChart3 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import type { Course } from "@/types";
function CourseCard({ course }: { course: Course }) {
const [editOpen, setEditOpen] = useState(false);
const [form, setForm] = useState({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity });
const { data: chapters = [] } = useChapters(course.id);
const qc = useQueryClient();
const { toast } = useToast();
const navigate = useNavigate();
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
await lmsService.updateCourse(course.id, form);
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast({ title: "Course updated" });
setEditOpen(false);
} catch (e: unknown) {
toast({ title: "Error", description: e instanceof Error ? e.message : String(e), variant: "destructive" });
} finally {
setSaving(false);
}
}
const totalMaterials = chapters.reduce((s, ch) => s + (ch.material_count || 0), 0);
return (
<>
<Card className="hover:shadow-md transition-shadow">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-3">
<div className="flex items-center justify-between">
<Badge variant={course.status === "active" ? "default" : "secondary"} className="capitalize">{course.status}</Badge>
<span className="text-xs text-muted-foreground">{course.code}</span>
</div>
<h3 className="font-semibold">{course.title}</h3>
{course.description && <p className="text-sm text-muted-foreground line-clamp-2">{course.description}</p>}
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{course.enrolled} students</span>
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{chapters.length} chapters · {totalMaterials} materials</span>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => { setForm({ title: course.title, code: course.code, description: course.description, max_capacity: course.max_capacity }); setEditOpen(true); }}>
<Pencil className="mr-1.5 h-3 w-3" /> Edit
</Button>
<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>
</div>
</CardContent>
</Card>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Course</DialogTitle></DialogHeader>
<div className="space-y-4 py-2">
<div><Label>Title *</Label><Input value={form.title} onChange={e => setForm(f => ({ ...f, title: e.target.value }))} /></div>
<div><Label>Code</Label><Input value={form.code} onChange={e => setForm(f => ({ ...f, code: e.target.value }))} /></div>
<div><Label>Description</Label><Textarea value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} rows={3} /></div>
<div><Label>Max Capacity</Label><Input type="number" value={form.max_capacity} onChange={e => setForm(f => ({ ...f, max_capacity: Number(e.target.value) || 30 }))} /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? "Saving..." : "Save Changes"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default function TeacherCourses() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.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>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">My Courses</h1>
<p className="text-muted-foreground">Manage your courses and materials.</p>
</div>
<Button asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" />New Course</Link></Button>
</div>
{courses.length === 0 ? (
<div className="text-center py-16">
<BookOpen className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-semibold">No Courses Yet</h3>
<p className="text-muted-foreground mt-1">Create your first course to get started.</p>
<Button className="mt-4" asChild><Link to="/teacher/courses/new"><Plus className="mr-2 h-4 w-4" /> Create Course</Link></Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{courses.map(c => <CourseCard key={c.id} course={c} />)}
</div>
)}
</div>
);
}