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:
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal file
372
frontend/src/pages/teacher/TeacherCourseInsights.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal file
180
frontend/src/pages/teacher/TeacherTestSessionConsole.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user