Files
encoach_frontend_v4/src/pages/student/StudentCoursePlans.tsx
Yamen Ahmad c2a07a6e5c
Some checks failed
Deploy Frontend to Staging / Deploy frontend to staging (push) Has been cancelled
feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:

Phase A (Sources / RAG)
  - encoach.course.plan.source model (file | url | text)
  - SourceIndexer extracts PDF (pypdf), DOCX (python-docx), HTML, plain
    text and embeds chunks via the existing pgvector pipeline scoped to
    plan_id, so resources.search only returns the plan's own corpus
  - Endpoints: list/create/upload/reindex/delete + plan-scoped retrieval

Phase B (Deliverables)
  - services.deliverables.compute_deliverables walks the plan, derives
    {planned, generated, ready} per week from material + media state
  - GET /api/ai/course-plan/<id>/deliverables drives the new wizard
    preview step and the live progress strip on the detail page

Phase C (Multi-modal media)
  - encoach.course.plan.media model + MediaService:
    audio: AWS Polly (default) or ElevenLabs
    image: OpenAI DALL-E 3, capped per plan via system parameter
    video: local ffmpeg subprocess (image + audio -> MP4 1280x720)
  - Three new agent tools (media.synthesize_audio / generate_image /
    compose_video), wired into course_week_materials and a new
    course_media_director agent
  - Endpoints per material + week-level batch generator

Phase D (Assignments)
  - encoach.course.plan.assignment supports mode='batch' (op.batch) or
    mode='students' (res.users), with due_date + message + state
  - REST endpoints to list / create / delete assignments

Phase E (Student view)
  - /api/student/course-plans + /api/student/course-plans/<id>
    enforce visibility via assignment.expand_user_ids()
  - New /student/course-plans list + read-only drilldown rendering
    audio/image/video tiles from /web/content/<attachment_id>

Cross-cutting
  - encoach.ai.tool.category: + media (so the new tools register)
  - encoach.embedding gains a plan_id filter for plan-scoped RAG
  - Wizard adds Sources + Multimedia steps; AdminCoursePlanDetail
    rewritten with DeliverablesStrip + SourcesCard + AssignmentsCard +
    per-material MediaDrawer
  - ~280 new EN + AR i18n keys (full RTL coverage)
  - smoke_course_plan.py exercises every phase via odoo-bin shell;
    last run: PASS A/B/D/E + DALL-E 3 image (753 KB), Polly audio
    fails cleanly when AWS creds aren't configured (expected)

Documentation: §24 added to docs/PROJECT_SUMMARY.md with phase-by-phase
artefact list, endpoints, smoke test, ops notes, and gotchas.

Made-with: Cursor
2026-04-25 17:13:01 +04:00

148 lines
4.7 KiB
TypeScript

import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowRight,
Calendar,
GraduationCap,
Sparkles,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student-facing list of AI course plans assigned to the current user.
*
* Reads from `GET /api/student/course-plans`, which only returns plans
* the user has been linked to either directly (Phase D `students` mode)
* or via the batch they're enrolled in. The card itself is intentionally
* lightweight: tap → drill into `/student/course-plans/:id` for the full
* weekly plan and embedded media.
*/
export default function StudentCoursePlans() {
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const items = data?.items ?? [];
return (
<div className="space-y-6">
<div>
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-bold">{t("coursePlan.student.listTitle")}</h1>
</div>
<p className="text-muted-foreground mt-1 max-w-2xl">
{t("coursePlan.student.listSubtitle")}
</p>
</div>
{isLoading && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-44 rounded-lg" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<div className="text-center py-12 border rounded-lg bg-muted/20">
<GraduationCap className="h-12 w-12 text-muted-foreground mx-auto mb-3" />
<p className="text-muted-foreground">
{t("coursePlan.student.empty")}
</p>
</div>
)}
{items.length > 0 && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<PlanCard key={plan.id} plan={plan} />
))}
</div>
)}
</div>
);
}
function PlanCard({ plan }: { plan: CoursePlan }) {
const { t } = useTranslation();
const a = plan.assignment;
return (
<Card className="hover:shadow-md transition-shadow flex flex-col">
<CardHeader>
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base flex-1 min-w-0 line-clamp-2">
{plan.name}
</CardTitle>
<Badge variant="secondary" className="uppercase shrink-0">
{plan.cefr_level}
</Badge>
</div>
{plan.description && (
<CardDescription className="line-clamp-2">
{plan.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-3">
<div className="flex flex-wrap gap-1.5">
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
{a && (
<div className="text-xs text-muted-foreground space-y-0.5">
{a.assigned_by_name && (
<div>
{t("coursePlan.student.assignedBy", { name: a.assigned_by_name })}
</div>
)}
<div className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{a.due_date
? t("coursePlan.student.due", { date: a.due_date })
: t("coursePlan.student.noDue")}
</div>
</div>
)}
<div className="mt-auto pt-2">
<Button asChild size="sm" className="w-full">
<Link to={`/student/course-plans/${plan.id}`}>
{t("coursePlan.student.open")}
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
</div>
</CardContent>
</Card>
);
}