Some checks failed
Deploy Frontend to Staging / Deploy frontend to staging (push) Has been cancelled
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
148 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|