16 Commits

Author SHA1 Message Date
Yamen Ahmad
fdc5097ce8 Merge frontend-v2/main into split-frontend-new-v2
Strategy: ours for our v4-ported changes (StudentDashboard course-plans widget,
InteractiveWorkbook, PlanReader, AdminBranches, media upload UI), theirs only
where they don't conflict. No protected files on the frontend side.

Made-with: Cursor
2026-04-28 00:23:10 +04:00
Yamen Ahmad
a0cee77628 feat(frontend): student dashboard course-plans, interactive workbook, media uploads, branches
Ported from monorepo v4 commit 3b62075d (frontend/* portion).

- StudentDashboard: surface assigned AI course plans alongside enrollments;
  enrolled-courses stat now counts plans + enrollments
- InteractiveWorkbook + PlanReader components for live exercise solving
- AdminCoursePlanDetail: media drawer with upload buttons (audio/image/video),
  hidden file inputs, upload mutation
- AdminBranches page (entity-scoped LMS branches) + sidebar entry
- coursePlan / lms / classrooms services + types updated for new endpoints
  (assignments, branches, classroom_ext, workbook attempts)
- i18n: studentDash.myCoursePlans / studentDash.noCoursePlans (en + ar)

Made-with: Cursor
2026-04-28 00:22:29 +04:00
c9bf12ad8e Merge pull request 'chore(release): sync frontend from monorepo v4' (#6) from release-v4-sync-20260426 into main
All checks were successful
Deploy Frontend to Staging / Deploy frontend to staging (push) Successful in 55s
Reviewed-on: #6
2026-04-26 01:14:24 +02:00
Yamen Ahmad
8df6804dcf chore(release): sync frontend from monorepo v4 2026-04-26 03:12:52 +04:00
Yamen Ahmad
b4b5868223 feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform.

Made-with: Cursor
2026-04-26 02:34:52 +04:00
Yamen Ahmad
c2a07a6e5c 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
Yamen Ahmad
01a32e7785 ci(frontend): add Gitea staging deploy workflow under frontend/ (monorepo mirror of encoach_frontend New_v2 main)
Made-with: Cursor
2026-04-25 11:55:20 +04:00
devops
724edea349 ci: add auto-deploy workflow to staging
All checks were successful
Deploy Frontend to Staging / Deploy frontend to staging (push) Successful in 52s
2026-04-25 09:25:47 +02:00
d6413a0496 Merge pull request 'docs: update all SRS documents to reflect implemented state' (#2) from docs/update-srs-documents into main
Reviewed-on: #2
2026-04-06 11:02:40 +02:00
4383db7fa1 docs: update all SRS documents to reflect implemented state
- ENCOACH_UNIFIED_SRS.md v2.0: updated header, added 8 new Part VIII-B
  sections (student leave, fees, lessons, gradebook, student progress,
  library, activities, facilities), extended permissions with roles CRUD
  and authority matrix, updated tech specs (93 pages, ~377 API routes,
  41 modules), added implementation traceability to all sections
- ENCOACH_ODOO19_BACKEND_SRS.md v3.0: updated status to implemented,
  added beyond-SRS features section, updated module count to 41,
  endpoint count to ~377
- ODOO_DEVELOPER_HANDOFF.md: rewritten with current repo references
  and implementation status
- ENCOACH_SYSTEM_FEATURES_GUIDE.md: added system features guide
- Superseded notices added to ODOO_BACKEND_SRS_v3.md,
  ODOO_MIGRATION_SRS_v2.md, ODOO_MIGRATION_SRS.md,
  ODOO_MIGRATION_FRONTEND_SRS.md, MATH_IT_ADAPTIVE_LEARNING_SRS.md

Made-with: Cursor
2026-04-06 12:57:52 +04:00
6543081011 Merge feature/full-frontend-v1: add Dockerfile for staging build 2026-04-01 18:32:17 +04:00
Yamen Ahmad
b66a623141 feat: add Dockerfile for production deployment
Multi-stage build: Node 18 builds the Vite app, nginx serves
the static output. Nginx config proxies /api/ to the backend
container and handles SPA client-side routing with try_files.

Build args VITE_API_BASE_URL and VITE_APP_NAME are configurable
at build time via docker build --build-arg.

Made-with: Cursor
2026-04-01 18:29:18 +04:00
41846a6eb6 Merge feature/full-frontend-v1 into main (initial frontend codebase by Yamen)
Made-with: Cursor
2026-04-01 18:18:52 +04:00
Yamen Ahmad
110a0b7105 feat: add complete EnCoach frontend application
Full React 18 + TypeScript + Vite frontend with:
- 90+ pages (admin, student, teacher, public)
- shadcn/ui component library with 50+ components
- JWT authentication with role-based access control
- TanStack React Query for server state management
- 30+ API service modules
- AI-powered features (coaching, grading, generation)
- Adaptive learning UI (diagnostics, proficiency, plans)
- Institutional LMS management (courses, batches, timetable)
- Communication suite (discussions, announcements, DMs)
- Full CRUD with validation and confirmation dialogs

Made-with: Cursor
2026-04-01 16:59:11 +04:00
b04db0b06c chore: verify deployment hook
Made-with: Cursor
2026-03-30 19:41:30 +04:00
3ebcec2b43 chore: add docker-compose, .gitignore and README
Infrastructure files for staging deployment pipeline.
The post-receive hook on the staging server will build and
deploy automatically when PRs are merged to main.

Made-with: Cursor
2026-03-30 19:40:44 +04:00
39 changed files with 9282 additions and 542 deletions

View File

@@ -0,0 +1,57 @@
name: Deploy Frontend to Staging
# Triggered on every push to main.
# Syncs this repo's source into the backend monorepo's frontend/
# directory and rebuilds the encoach-frontend Docker container.
on:
push:
branches: [main]
concurrency:
group: deploy-frontend
cancel-in-progress: true
jobs:
deploy:
name: Deploy frontend to staging
runs-on: self-hosted
steps:
- name: Sync frontend source to backend repo
run: |
FRONTEND_SRC="/opt/encoach/encoach_frontend_new_v2"
FRONTEND_DEST="/opt/encoach/encoach_backend_new_v2/frontend"
# Keep a clean clone of this repo on the server
if [ -d "$FRONTEND_SRC/.git" ]; then
git -C "$FRONTEND_SRC" fetch origin
git -C "$FRONTEND_SRC" reset --hard origin/main
else
git clone http://localhost:3003/devops/encoach_frontend_new_v2.git "$FRONTEND_SRC"
fi
echo "Syncing to backend frontend/ dir..."
rsync -a --delete \
--exclude '.git' \
--exclude 'node_modules' \
--exclude 'dist' \
--exclude 'docs' \
"$FRONTEND_SRC/" "$FRONTEND_DEST/"
echo "Latest commit: $(git -C $FRONTEND_SRC log -1 --oneline)"
- name: Rebuild frontend container
run: |
cd /opt/encoach/encoach_backend_new_v2
docker compose \
-f docker-compose.yml \
-f /opt/encoach/overrides/encoach.override.yml \
up -d --build frontend
docker ps --format "table {{.Names}}\t{{.Status}}" | grep encoach-frontend
- name: Smoke test
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/)
echo "frontend / => HTTP $STATUS"
test "$STATUS" = "200" || exit 1
echo "Deploy successful!"

View File

@@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses")); const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents")); const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers")); const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches")); const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail")); const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable")); const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
@@ -133,6 +134,8 @@ const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscuss
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements")); const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages")); const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney")); const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse")); const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse")); const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession")); const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
@@ -269,6 +272,8 @@ const App = () => (
<Route path="/student/placement/access" element={<PlacementAccess />} /> <Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} /> <Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} /> <Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} /> <Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} /> <Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} /> <Route path="/student/course/:courseId" element={<CourseDelivery />} />
@@ -316,10 +321,11 @@ const App = () => (
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} /> <Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
{/* Original platform dashboard */} {/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} /> <Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */} {/* LMS pages (entity-scoped, includes branch management) */}
<Route path="/admin/courses" element={<AdminCourses />} /> <Route path="/admin/courses" element={<AdminCourses />} />
<Route path="/admin/students" element={<AdminStudents />} /> <Route path="/admin/students" element={<AdminStudents />} />
<Route path="/admin/teachers" element={<AdminTeachers />} /> <Route path="/admin/teachers" element={<AdminTeachers />} />
<Route path="/admin/branches" element={<AdminBranches />} />
<Route path="/admin/batches" element={<AdminBatches />} /> <Route path="/admin/batches" element={<AdminBatches />} />
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} /> <Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
<Route path="/admin/timetable" element={<AdminTimetable />} /> <Route path="/admin/timetable" element={<AdminTimetable />} />

View File

@@ -1,4 +1,4 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom"; import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
import { Suspense } from "react"; import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { import {
@@ -16,6 +16,7 @@ import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar"; import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
@@ -53,6 +54,8 @@ const lmsItems: NavItem[] = [
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen }, { titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users }, { titleKey: "nav.students", url: "/admin/students", icon: Users },
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap }, { titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers }, { titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar }, { titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 }, { titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
@@ -96,7 +99,6 @@ const institutionalItems: NavItem[] = [
const managementItems: NavItem[] = [ const managementItems: NavItem[] = [
{ titleKey: "nav.users", url: "/admin/users", icon: Users }, { titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 }, { titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog }, { titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings }, { titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow }, { titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
@@ -166,7 +168,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem
const routeLabelKeys: Record<string, string> = { const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard", admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses", platform: "breadcrumb.platform", courses: "nav.courses",
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches", students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports", timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList", assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics", "exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
@@ -238,9 +240,16 @@ function RouteContentFallback() {
// ============= Main Layout ============= // ============= Main Layout =============
export default function AdminLmsLayout() { export default function AdminLmsLayout() {
const { user, logout } = useAuth(); const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
// on the wizard's own Next/Finish footer (real bug repro: trying to
// click "Next" in the Course-plan wizard at the standard 1024×768
// viewport hits the pill instead). The AI Assistant orb also at the
// bottom-right is preserved — it's smaller and useful in-wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"; const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -261,6 +270,44 @@ export default function AdminLmsLayout() {
</div> </div>
<AiSearchBar /> <AiSearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t("nav.entities")}
</div>
<DropdownMenuSeparator />
{user.entities.map((entity) => (
<DropdownMenuItem
key={entity.id}
onClick={() => {
setSelectedEntityId(entity.id);
// Force page data to refresh against the newly
// selected entity scope.
window.location.reload();
}}
className="flex items-center justify-between gap-2"
>
<span className="truncate">{entity.name}</span>
{selectedEntity?.id === entity.id && (
<Badge variant="secondary" className="text-[10px]">
{t("common.active", "Active")}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<LanguageToggle /> <LanguageToggle />
<ThemeToggle /> <ThemeToggle />
<NotificationDropdown /> <NotificationDropdown />
@@ -303,13 +350,15 @@ export default function AdminLmsLayout() {
</div> </div>
</div> </div>
<AiAssistantDrawer /> <AiAssistantDrawer />
{!isWizardRoute && (
<Link <Link
to="/admin/tickets" to="/admin/tickets"
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">{t("chrome.needHelp")}</span> <span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link> </Link>
)}
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -1,4 +1,4 @@
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom"; import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
import { Suspense } from "react"; import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar"; import { AppSidebar } from "@/components/AppSidebar";
@@ -7,14 +7,16 @@ import {
BreadcrumbPage, BreadcrumbSeparator, BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"; } from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react"; import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
import React from "react"; import React from "react";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer"; import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar"; import AiSearchBar from "@/components/ai/AiSearchBar";
import { useAuth } from "@/contexts/AuthContext";
const routeLabels: Record<string, string> = { const routeLabels: Record<string, string> = {
"dashboard": "Dashboard", "dashboard": "Dashboard",
@@ -90,6 +92,14 @@ function RouteContentFallback() {
export default function AppLayout() { export default function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const { user, selectedEntity, setSelectedEntityId } = useAuth();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
// intercepting clicks on the wizard's own Next/Finish footer at most
// viewport heights, blocking users from finishing the flow. The AI
// Assistant orb (also bottom-right) stays — it's smaller and
// genuinely useful inside the wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
return ( return (
<SidebarProvider> <SidebarProvider>
@@ -103,6 +113,34 @@ export default function AppLayout() {
</div> </div>
<AiSearchBar /> <AiSearchBar />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{user.entities.map((entity) => {
const active = selectedEntity?.id === entity.id;
return (
<DropdownMenuItem
key={entity.id}
onClick={() => setSelectedEntityId(entity.id)}
className="flex items-center justify-between gap-3"
>
<span className="truncate">{entity.name}</span>
{active ? <Badge variant="secondary">Current</Badge> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground"> <Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" /> <Ticket className="h-4 w-4" />
</Button> </Button>
@@ -145,14 +183,15 @@ export default function AppLayout() {
</div> </div>
</div> </div>
<AiAssistantDrawer /> <AiAssistantDrawer />
{/* Floating help button */} {!isWizardRoute && (
<Link <Link
to="/tickets" to="/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity" className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
> >
<HelpCircle className="h-4 w-4" /> <HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span> <span className="text-sm font-medium">Need help?</span>
</Link> </Link>
)}
</SidebarProvider> </SidebarProvider>
); );
} }

View File

@@ -22,12 +22,12 @@ const mainItems = [
{ title: "Rubrics", url: "/rubrics", icon: BookOpen }, { title: "Rubrics", url: "/rubrics", icon: BookOpen },
{ title: "Generation", url: "/generation", icon: Wand2 }, { title: "Generation", url: "/generation", icon: Wand2 },
{ title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch }, { title: "Approval Workflows", url: "/approval-workflows", icon: GitBranch },
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
]; ];
const managementItems = [ const managementItems = [
{ title: "Users", url: "/users", icon: Users }, { title: "Users", url: "/users", icon: Users },
{ title: "Entities", url: "/entities", icon: Building2 }, { title: "Entities", url: "/entities", icon: Building2 },
{ title: "Classrooms", url: "/classrooms", icon: GraduationCap },
]; ];
const reportItems = [ const reportItems = [
@@ -103,7 +103,7 @@ export function AppSidebar() {
<SidebarSeparator /> <SidebarSeparator />
<SidebarContent> <SidebarContent>
<SidebarNavGroup label="Academic" items={mainItems} /> <SidebarNavGroup label="LMS" items={mainItems} />
<SidebarNavGroup label="Management" items={managementItems} /> <SidebarNavGroup label="Management" items={managementItems} />
<SidebarNavGroup label="Reports" items={reportItems} /> <SidebarNavGroup label="Reports" items={reportItems} />

View File

@@ -3,7 +3,7 @@ import ExamPopup from "./student/ExamPopup";
import { import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3, LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks, CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react"; } from "lucide-react";
/** /**
@@ -17,6 +17,7 @@ const navGroups: NavGroup[] = [
items: [ items: [
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard }, { titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen }, { titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks }, { titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList }, { titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
], ],

View File

@@ -0,0 +1,867 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Award,
CheckCircle2,
Eye,
GripVertical,
Lightbulb,
Loader2,
RotateCcw,
Save,
XCircle,
} from "lucide-react";
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 { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { cn } from "@/lib/utils";
import { coursePlanService } from "@/services/coursePlan.service";
import type {
CoursePlanMaterial,
GapFillExercise,
MatchPairsExercise,
MultipleChoiceExercise,
ReorderWordsExercise,
ShortAnswerExercise,
TransformationExercise,
WorkbookAttempt,
WorkbookAttemptScoreItem,
WorkbookBody,
WorkbookExercise,
} from "@/types";
/**
* Workbook renderer for `material_type === "interactive_workbook"`.
*
* Modes:
* - `student` → answers persist server-side (graded, scored, saved).
* - `preview` → no persistence, no Submit; "Show answers" toggle reveals
* the key so the admin can verify the v2 generator's output.
*
* The renderer also accepts a `WorkbookBody` directly (for skill bodies
* that embed `interactive_workbook: { exercises: [...] }`), so it can be
* reused inside other material renderers without duplicating the logic.
*/
export type WorkbookMode = "student" | "preview";
interface InteractiveWorkbookProps {
material: Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "title" | "summary" | "material_type"
>;
/** Optional override — when present we read exercises from here instead
* of `material.body`. Used by skill bodies that embed a workbook one
* level deep. */
bodyOverride?: WorkbookBody;
mode?: WorkbookMode;
}
type AnswerValue = string | string[] | number[][] | undefined;
function toExercises(body: unknown): WorkbookExercise[] {
if (!body || typeof body !== "object") return [];
const b = body as Record<string, unknown>;
if (Array.isArray(b.exercises)) {
return b.exercises as WorkbookExercise[];
}
// Skill bodies sometimes nest the workbook under `interactive_workbook`.
const nested = b.interactive_workbook as { exercises?: WorkbookExercise[] } | undefined;
if (nested && Array.isArray(nested.exercises)) {
return nested.exercises;
}
return [];
}
function toLessonPlan(body: unknown): WorkbookBody["lesson_plan"] | undefined {
if (!body || typeof body !== "object") return undefined;
const b = body as Record<string, unknown>;
if (b.lesson_plan && typeof b.lesson_plan === "object") {
return b.lesson_plan as WorkbookBody["lesson_plan"];
}
const nested = b.interactive_workbook as { lesson_plan?: WorkbookBody["lesson_plan"] } | undefined;
return nested?.lesson_plan;
}
// Local copies of the backend grading functions for instant per-exercise
// feedback while typing. The authoritative score still comes from the
// server when we POST — these are only used for the "Check" button.
function normText(s: unknown): string {
if (s == null) return "";
return String(s).replace(/\s+/g, " ").trim().toLowerCase();
}
function normPunct(s: unknown): string {
return normText(s).replace(/[\s.?!,]+$/u, "");
}
function globMatch(pattern: string, value: string): boolean {
const pat = normText(pattern);
const val = normText(value);
if (!pat) return false;
if (!pat.includes("*")) return pat === val;
const rx = new RegExp(
"^" + pat.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\*/g, ".*") + "$",
);
return rx.test(val);
}
function gradeOne(ex: WorkbookExercise, given: AnswerValue): boolean {
if (given === undefined) return false;
switch (ex.type) {
case "gap_fill": {
const accepted = [ex.answer, ...(ex.alt ?? [])];
const n = normText(given as string);
return accepted.some((a) => normText(a) === n);
}
case "multiple_choice": {
const opts = ex.options ?? [];
const ans = ex.answer;
const g = normText(given as string);
if (g === normText(ans)) return true;
if (typeof ans === "string" && ans.length === 1 && /[a-z]/i.test(ans)) {
const idx = ans.toUpperCase().charCodeAt(0) - 65;
if (opts[idx] && normText(opts[idx]) === g) return true;
}
if (typeof given === "string" && given.length === 1 && /[a-z]/i.test(given)) {
const idx = given.toUpperCase().charCodeAt(0) - 65;
if (opts[idx] && normText(opts[idx]) === normText(ans)) return true;
}
return false;
}
case "match_pairs": {
const pairs = (given as number[][]) ?? [];
const expected = ex.answer ?? [];
const setOf = (rows: number[][]) =>
new Set(rows.filter((p) => p.length === 2).map((p) => `${p[0]},${p[1]}`));
const a = setOf(expected);
const b = setOf(pairs);
if (a.size !== b.size) return false;
for (const v of a) if (!b.has(v)) return false;
return true;
}
case "reorder_words": {
const value = Array.isArray(given) ? (given as string[]).join(" ") : String(given ?? "");
return normText(value) === normText(ex.answer);
}
case "transformation": {
const accepted = [ex.answer, ...(ex.alt ?? [])];
const n = normPunct(given as string);
return accepted.some((a) => normPunct(a) === n);
}
case "short_answer": {
const accepted = [...(ex.accepted ?? []), ex.answer].filter(Boolean) as string[];
return accepted.some((p) => globMatch(p, given as string));
}
default:
return false;
}
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function InteractiveWorkbook({
material,
bodyOverride,
mode = "student",
}: InteractiveWorkbookProps) {
const exercises = useMemo<WorkbookExercise[]>(
() => toExercises(bodyOverride ?? material.body),
[bodyOverride, material.body],
);
const lessonPlan = useMemo(
() => toLessonPlan(bodyOverride ?? material.body),
[bodyOverride, material.body],
);
const { toast } = useToast();
const [answers, setAnswers] = useState<Record<string, AnswerValue>>({});
const [revealedIds, setRevealedIds] = useState<Set<string>>(new Set());
const [showKey, setShowKey] = useState(false);
const [serverScore, setServerScore] = useState<{
correct: number;
total: number;
percent: number;
items?: WorkbookAttemptScoreItem[];
is_final?: boolean;
} | null>(null);
const [saving, setSaving] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(mode === "student");
// Load existing attempt (student mode only).
useEffect(() => {
if (mode !== "student") return;
let cancelled = false;
(async () => {
try {
const res = await coursePlanService.myWorkbookAttempt(
material.plan_id,
material.id,
);
if (cancelled) return;
const attempt = res.data;
if (attempt) {
setAnswers((attempt.answers ?? {}) as Record<string, AnswerValue>);
if (attempt.score && "items" in attempt.score) {
setServerScore({
correct: attempt.correct_count,
total: attempt.total_count,
percent: attempt.percent,
items: attempt.score.items,
is_final: attempt.is_final,
});
}
}
} catch {
// Non-fatal — empty workbook on first open.
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [material.id, material.plan_id, mode]);
const isFinal = !!serverScore?.is_final;
const liveCorrect = useMemo(
() => exercises.filter((ex) => gradeOne(ex, answers[ex.id])).length,
[answers, exercises],
);
const livePercent = exercises.length
? Math.round((liveCorrect / exercises.length) * 100)
: 0;
const onChange = (id: string, value: AnswerValue) => {
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const onCheck = (id: string) => {
setRevealedIds((prev) => new Set(prev).add(id));
if (mode === "student") void persist(false);
};
const persist = async (finalize: boolean) => {
if (mode !== "student") return;
if (finalize) setSubmitting(true);
else setSaving(true);
try {
const res = await coursePlanService.saveWorkbookAttempt(
material.plan_id,
material.id,
{ answers, finalize },
);
const attempt: WorkbookAttempt = res.data;
setServerScore({
correct: attempt.correct_count,
total: attempt.total_count,
percent: attempt.percent,
items: attempt.score && "items" in attempt.score ? attempt.score.items : [],
is_final: attempt.is_final,
});
if (finalize) {
toast({
title: "Submitted",
description: `${attempt.correct_count} / ${attempt.total_count} correct (${attempt.percent.toFixed(0)}%)`,
});
}
} catch (err) {
toast({
title: finalize ? "Submit failed" : "Save failed",
description: err instanceof Error ? err.message : String(err),
variant: "destructive",
});
} finally {
setSaving(false);
setSubmitting(false);
}
};
// Debounce per-keystroke saves: every 1.5s after the last change.
const saveTimer = useRef<number | null>(null);
useEffect(() => {
if (mode !== "student" || isFinal || loading) return;
if (saveTimer.current) window.clearTimeout(saveTimer.current);
saveTimer.current = window.setTimeout(() => {
void persist(false);
}, 1500);
return () => {
if (saveTimer.current) window.clearTimeout(saveTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [answers]);
const reset = () => {
setAnswers({});
setRevealedIds(new Set());
};
if (!exercises.length) {
return (
<div className="rounded-lg border bg-muted/30 p-6 text-sm text-muted-foreground">
This workbook has no exercises yet generate or extract first.
</div>
);
}
return (
<div className="space-y-4">
{mode === "preview" && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-900 flex items-center justify-between">
<span>
<strong>Preview</strong> answers won't be saved. Toggle the
answer key to verify the generated content.
</span>
<Button
type="button"
size="sm"
variant={showKey ? "default" : "outline"}
onClick={() => setShowKey((v) => !v)}
>
<Eye className="mr-1 h-4 w-4" />
{showKey ? "Hide answers" : "Show answers"}
</Button>
</div>
)}
{lessonPlan && Object.values(lessonPlan).some(Boolean) && (
<details className="rounded-lg border bg-background/70 p-3">
<summary className="cursor-pointer text-sm font-medium">
Teacher's lesson plan
</summary>
<div className="mt-2 grid gap-2 sm:grid-cols-2 text-sm">
{(["warmup", "presentation", "controlled_practice", "freer_practice", "exit_ticket"] as const).map(
(k) =>
lessonPlan[k] ? (
<div key={k}>
<div className="text-xs uppercase tracking-wide text-muted-foreground">
{k.replace(/_/g, " ")}
</div>
<div className="leading-6">{lessonPlan[k]}</div>
</div>
) : null,
)}
</div>
</details>
)}
<div className="rounded-lg border bg-background/70 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 text-sm">
<Award className="h-4 w-4 text-primary" />
<strong>{liveCorrect}</strong>
<span className="text-muted-foreground">/ {exercises.length} correct (live)</span>
{serverScore && (
<Badge variant="secondary" className="ml-2">
Server: {serverScore.correct}/{serverScore.total} ({serverScore.percent.toFixed(0)}%)
</Badge>
)}
</div>
{mode === "student" && (
<div className="text-xs text-muted-foreground flex items-center gap-2">
{saving ? (
<>
<Loader2 className="h-3 w-3 animate-spin" /> Saving
</>
) : (
<>
<Save className="h-3 w-3" />{" "}
{serverScore ? "Auto-saved" : "Auto-save on"}
</>
)}
</div>
)}
</div>
<Progress value={livePercent} className="h-2" />
</div>
<ol className="space-y-3 list-none p-0">
{exercises.map((ex, idx) => {
const value = answers[ex.id];
const checked = revealedIds.has(ex.id) || isFinal || showKey;
const correct = checked ? gradeOne(ex, value) : null;
return (
<li
key={ex.id || idx}
className={cn(
"rounded-lg border p-4 bg-background/70 space-y-3",
checked && correct === true && "border-emerald-400 bg-emerald-50/40",
checked && correct === false && "border-rose-400 bg-rose-50/40",
)}
>
<div className="flex items-start justify-between gap-3">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Exercise {idx + 1} · {ex.type.replace(/_/g, " ")}
</div>
{checked && (
<Badge variant={correct ? "default" : "destructive"} className="capitalize">
{correct ? (
<>
<CheckCircle2 className="mr-1 h-3 w-3" /> Correct
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" /> Try again
</>
)}
</Badge>
)}
</div>
{renderExercise(ex, value, (v) => onChange(ex.id, v), {
disabled: mode === "preview" || isFinal,
showKey: showKey,
})}
{ex.hint && !checked && (
<div className="text-xs text-muted-foreground flex items-center gap-1">
<Lightbulb className="h-3 w-3" /> {ex.hint}
</div>
)}
{checked && (
<div className="text-xs text-muted-foreground space-y-1">
{!correct && (
<div>
Expected: <span className="font-medium">{formatAnswer(ex)}</span>
</div>
)}
{ex.rationale && <div>{ex.rationale}</div>}
</div>
)}
{!isFinal && mode !== "preview" && (
<div className="flex justify-end">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => onCheck(ex.id)}
>
<CheckCircle2 className="mr-1 h-4 w-4" /> Check
</Button>
</div>
)}
</li>
);
})}
</ol>
{mode === "student" && (
<div className="flex items-center justify-between rounded-lg border bg-background/70 p-3">
<Button type="button" variant="ghost" onClick={reset} disabled={isFinal}>
<RotateCcw className="mr-1 h-4 w-4" />
Reset
</Button>
<Button
type="button"
disabled={submitting || isFinal}
onClick={() => persist(true)}
>
{submitting ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Save className="mr-1 h-4 w-4" />
)}
{isFinal ? "Submitted" : "Submit final"}
</Button>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Per-type renderers
// ---------------------------------------------------------------------------
function renderExercise(
ex: WorkbookExercise,
value: AnswerValue,
onChange: (v: AnswerValue) => void,
opts: { disabled?: boolean; showKey?: boolean },
): React.ReactNode {
switch (ex.type) {
case "gap_fill":
return <GapFill ex={ex} value={value as string} onChange={onChange} disabled={opts.disabled} />;
case "multiple_choice":
return (
<MultipleChoice
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "match_pairs":
return (
<MatchPairs
ex={ex}
value={value as number[][]}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "reorder_words":
return (
<ReorderWords
ex={ex}
value={value as string[]}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "transformation":
return (
<Transformation
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
case "short_answer":
return (
<ShortAnswer
ex={ex}
value={value as string}
onChange={onChange}
disabled={opts.disabled}
/>
);
default:
return <p className="text-sm text-muted-foreground">Unsupported exercise type.</p>;
}
}
function formatAnswer(ex: WorkbookExercise): string {
switch (ex.type) {
case "match_pairs":
return (ex.answer ?? [])
.map((p) => `${ex.left[p[0]] ?? "?"}${ex.right[p[1]] ?? "?"}`)
.join(", ");
case "reorder_words":
return ex.answer;
default:
return String((ex as { answer?: unknown }).answer ?? "");
}
}
// ---------------------------------------------------------------------------
// Type-specific input subcomponents
// ---------------------------------------------------------------------------
function GapFill({
ex,
value,
onChange,
disabled,
}: {
ex: GapFillExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
// Replace ___ in the stem with an inline input. Falls back to a stem
// label + standalone input when there's no underscore marker.
const parts = ex.stem.split(/_{2,}/);
if (parts.length === 1) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<Input
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="Type your answer"
/>
</div>
);
}
return (
<p className="leading-8 text-base flex flex-wrap items-center gap-1">
{parts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-1">
<span>{part}</span>
{i < parts.length - 1 && (
<Input
type="text"
className="inline-block w-32 align-baseline"
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder="…"
/>
)}
</span>
))}
</p>
);
}
function MultipleChoice({
ex,
value,
onChange,
disabled,
}: {
ex: MultipleChoiceExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<RadioGroup
value={value ?? ""}
onValueChange={onChange}
disabled={disabled}
className="grid gap-2 sm:grid-cols-2"
>
{ex.options.map((opt, i) => {
const id = `${ex.id}_opt_${i}`;
return (
<Label
key={id}
htmlFor={id}
className="flex items-start gap-2 rounded-md border p-2 cursor-pointer hover:bg-muted"
>
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
<span className="text-sm">{opt}</span>
</Label>
);
})}
</RadioGroup>
</div>
);
}
function MatchPairs({
ex,
value,
onChange,
disabled,
}: {
ex: MatchPairsExercise;
value: number[][] | undefined;
onChange: (v: number[][]) => void;
disabled?: boolean;
}) {
const [selectedLeft, setSelectedLeft] = useState<number | null>(null);
const pairs = value ?? [];
const pairedLeft = new Set(pairs.map((p) => p[0]));
const pairedRight = new Set(pairs.map((p) => p[1]));
const tapLeft = (idx: number) => {
if (disabled) return;
setSelectedLeft(idx);
};
const tapRight = (idx: number) => {
if (disabled || selectedLeft == null) return;
const next = pairs.filter((p) => p[0] !== selectedLeft && p[1] !== idx);
next.push([selectedLeft, idx]);
onChange(next);
setSelectedLeft(null);
};
const clear = (li: number) => {
if (disabled) return;
onChange(pairs.filter((p) => p[0] !== li));
};
return (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Match these</div>
{ex.left.map((item, i) => {
const paired = pairs.find((p) => p[0] === i);
return (
<button
type="button"
key={`L${i}`}
onClick={() => (paired ? clear(i) : tapLeft(i))}
disabled={disabled}
className={cn(
"block w-full rounded border px-2 py-1.5 text-left",
selectedLeft === i && "border-primary ring-1 ring-primary",
paired && "bg-muted",
)}
>
<span className="text-muted-foreground mr-1">{i + 1}.</span>
{item}
{paired ? (
<span className="ml-2 text-xs text-muted-foreground">
{ex.right[paired[1]]}
</span>
) : null}
</button>
);
})}
</div>
<div className="space-y-1">
<div className="text-xs uppercase tracking-wide text-muted-foreground">with these</div>
{ex.right.map((item, j) => (
<button
type="button"
key={`R${j}`}
onClick={() => tapRight(j)}
disabled={disabled || selectedLeft == null}
className={cn(
"block w-full rounded border px-2 py-1.5 text-left",
pairedRight.has(j) && "bg-muted",
)}
>
<span className="text-muted-foreground mr-1">{String.fromCharCode(65 + j)}.</span>
{item}
</button>
))}
</div>
<div className="col-span-2 text-xs text-muted-foreground">
Tap a left item, then tap its match on the right. Tap a paired left
item to clear it.
</div>
{!pairedLeft.size && (
<div className="col-span-2 text-xs text-muted-foreground">
{ex.left.length} pair(s) to match.
</div>
)}
</div>
);
}
function ReorderWords({
ex,
value,
onChange,
disabled,
}: {
ex: ReorderWordsExercise;
value: string[] | undefined;
onChange: (v: string[]) => void;
disabled?: boolean;
}) {
// Stateful tap-to-insert: tap a token in the bank to append; tap a
// token in the answer strip to remove. Keeps it touch-friendly.
const order = value ?? [];
const remaining: { tok: string; bankIdx: number }[] = [];
const used: number[] = [];
// Build maps so duplicate tokens stay independently selectable.
const usedCounts = new Map<string, number>();
order.forEach((t) => usedCounts.set(t, (usedCounts.get(t) ?? 0) + 1));
const seenWhileBuilding = new Map<string, number>();
ex.tokens.forEach((tok, idx) => {
const seen = seenWhileBuilding.get(tok) ?? 0;
const usesLeft = (usedCounts.get(tok) ?? 0) - seen;
if (usesLeft > 0) {
used.push(idx);
seenWhileBuilding.set(tok, seen + 1);
} else {
remaining.push({ tok, bankIdx: idx });
}
});
const append = (tok: string) => {
if (disabled) return;
onChange([...order, tok]);
};
const removeAt = (i: number) => {
if (disabled) return;
onChange(order.filter((_, idx) => idx !== i));
};
return (
<div className="space-y-2 text-sm">
<div className="rounded border bg-muted/30 p-2 min-h-10 flex flex-wrap gap-1">
{order.length === 0 ? (
<span className="text-xs text-muted-foreground">Tap tokens below to build the sentence</span>
) : (
order.map((tok, i) => (
<button
type="button"
key={`a${i}`}
onClick={() => removeAt(i)}
disabled={disabled}
className="rounded bg-background border px-2 py-1"
>
<GripVertical className="mr-1 inline h-3 w-3 text-muted-foreground" />
{tok}
</button>
))
)}
</div>
<div className="flex flex-wrap gap-1">
{remaining.map(({ tok, bankIdx }) => (
<button
type="button"
key={`b${bankIdx}`}
onClick={() => append(tok)}
disabled={disabled}
className="rounded border px-2 py-1 hover:bg-muted"
>
{tok}
</button>
))}
</div>
</div>
);
}
function Transformation({
ex,
value,
onChange,
disabled,
}: {
ex: TransformationExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<div className="rounded border bg-muted/30 px-3 py-2 text-sm">
<Badge variant="outline" className="mr-2">
{ex.instruction}
</Badge>
<span className="font-medium">{ex.from}</span>
</div>
<Textarea
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
rows={2}
placeholder="Transformed sentence"
/>
</div>
);
}
function ShortAnswer({
ex,
value,
onChange,
disabled,
}: {
ex: ShortAnswerExercise;
value: string | undefined;
onChange: (v: string) => void;
disabled?: boolean;
}) {
return (
<div className="space-y-2">
<p className="leading-7">{ex.stem}</p>
<Textarea
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
rows={3}
placeholder="Type your answer"
/>
</div>
);
}

View File

@@ -0,0 +1,214 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Library, Loader2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { resourcesService } from "@/services/resources.service";
import type { Resource } from "@/types";
/**
* Generic, reusable picker over `/api/resources`.
*
* Two callers wire this up today:
*
* 1. **AdminCoursePlanDetail** — for an *existing* plan, so it forwards
* the picked resources to `coursePlanService.attachResources` in the
* `onConfirm` handler and invalidates the sources query.
*
* 2. **CoursePlanWizard** — the plan does not exist yet at pick time,
* so the wizard simply pushes the picks into its draft state and
* attaches them in one batch after the plan is created.
*
* The dialog itself is purposefully agnostic: it only reports the
* selected `Resource` rows; the parent decides what to do.
*/
export function LibraryPickerDialog({
open,
onOpenChange,
alreadyLinkedIds,
onConfirm,
isPending,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Resources whose ids are in this set render disabled + checked. */
alreadyLinkedIds?: Set<number>;
/** Called when the admin clicks "Attach selected". */
onConfirm: (resources: Resource[]) => void | Promise<void>;
/** Optional spinner state from the parent's mutation. */
isPending?: boolean;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selected, setSelected] = useState<Set<number>>(new Set());
// Reset every time the dialog re-opens so a previous session's
// selection doesn't bleed into the next attach.
useEffect(() => {
if (open) {
setSelected(new Set());
setSearch("");
setTypeFilter("all");
}
}, [open]);
const { data, isLoading } = useQuery({
queryKey: ["library-resources", { search, type: typeFilter }],
queryFn: () =>
resourcesService.list({
search: search || undefined,
resource_type: typeFilter === "all" ? undefined : typeFilter,
review_status: "approved",
}),
enabled: open,
});
const resources: Resource[] = data?.items ?? [];
const toggle = (id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const handleConfirm = async () => {
const picked = resources.filter((r) => selected.has(r.id));
if (!picked.length) return;
await onConfirm(picked);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Library className="h-5 w-5 text-primary" />
{t("coursePlan.sources.libraryTitle")}
</DialogTitle>
<DialogDescription>
{t("coursePlan.sources.libraryDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex gap-2 pt-2">
<Input
placeholder={t("coursePlan.sources.librarySearchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1"
/>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("coursePlan.sources.libraryTypeAll")}
</SelectItem>
<SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="document">DOCX</SelectItem>
<SelectItem value="link">URL</SelectItem>
<SelectItem value="article">Article</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex-1 overflow-y-auto -mx-2 px-2 space-y-1">
{isLoading && <Skeleton className="h-32 w-full" />}
{!isLoading && resources.length === 0 && (
<p className="text-center text-sm text-muted-foreground py-8">
{t("coursePlan.sources.libraryEmpty")}
</p>
)}
{!isLoading &&
resources.map((r) => {
const isLinked = alreadyLinkedIds?.has(r.id) ?? false;
const isSelected = selected.has(r.id);
return (
<label
key={r.id}
className={`flex items-start gap-3 rounded-md border p-2 text-sm transition-colors ${
isLinked
? "opacity-60 cursor-not-allowed bg-muted/40"
: "cursor-pointer hover:bg-accent/50"
}`}
>
<Checkbox
checked={isLinked || isSelected}
disabled={isLinked}
onCheckedChange={() => !isLinked && toggle(r.id)}
className="mt-0.5"
/>
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium truncate">{r.name}</span>
<Badge
variant="outline"
className="text-[10px] capitalize"
>
{r.resource_type || r.type || "resource"}
</Badge>
{isLinked && (
<Badge variant="secondary" className="text-[10px]">
{t("coursePlan.sources.libraryAlreadyLinked")}
</Badge>
)}
</div>
{(r.subject_name || (r.topic_names ?? []).length > 0) && (
<p className="text-xs text-muted-foreground truncate">
{[r.subject_name, ...(r.topic_names?.slice(0, 2) ?? [])]
.filter(Boolean)
.join(" ")}
</p>
)}
</div>
</label>
);
})}
</div>
<DialogFooter className="border-t pt-3">
<span className="mr-auto text-xs text-muted-foreground self-center">
{t("coursePlan.sources.librarySelectedCount", {
count: selected.size,
})}
</span>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={!selected.size || isPending}
>
{isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("coursePlan.sources.libraryAttach")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,139 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CoursePlanMaterial, WorkbookBody } from "@/types";
import InteractiveWorkbook, { type WorkbookMode } from "./InteractiveWorkbook";
export const SKILL_STYLE: Record<string, string> = {
reading: "bg-blue-100 text-blue-800 border-blue-200",
writing: "bg-purple-100 text-purple-800 border-purple-200",
listening: "bg-emerald-100 text-emerald-800 border-emerald-200",
speaking: "bg-amber-100 text-amber-800 border-amber-200",
grammar: "bg-rose-100 text-rose-800 border-rose-200",
vocabulary: "bg-cyan-100 text-cyan-800 border-cyan-200",
integrated: "bg-slate-100 text-slate-800 border-slate-200",
};
export function SkillBadge({ skill }: { skill: string }) {
return (
<Badge
variant="outline"
className={cn("capitalize border", SKILL_STYLE[skill] ?? SKILL_STYLE.integrated)}
>
{skill || "integrated"}
</Badge>
);
}
function renderAny(value: unknown): React.ReactNode {
if (value == null) return null;
if (typeof value === "string" || 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">
{value.map((item, idx) => (
<li key={idx}>{renderAny(item)}</li>
))}
</ul>
);
}
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>
);
}
return null;
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
>;
export default function MaterialBookView({
material,
mode = "student",
}: {
// The legacy callsites only pass body/body_text/material_type, so we
// tolerate that by making id/plan_id optional in the prop type even
// though the types/index file marks them as required. Required props
// are validated at runtime when interactive_workbook is dispatched.
material: Partial<MaterialForBook> &
Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
mode?: WorkbookMode;
}) {
const body = material.body ?? {};
const hasStructured = Object.keys(body).length > 0;
// Dispatch to the interactive renderer for workbook materials. The
// skill-bodied workbooks (grammar/vocabulary that embed their own
// `interactive_workbook` block) render via the generic walker AND
// the embedded workbook below for the exercises portion.
if (
material.material_type === "interactive_workbook" &&
typeof material.id === "number" &&
typeof material.plan_id === "number"
) {
return (
<InteractiveWorkbook
material={material as MaterialForBook}
mode={mode}
/>
);
}
// For grammar / vocabulary that embed an interactive workbook inside
// their body, we render the prose first and then the workbook UI so
// students can practise without leaving the lesson.
const embeddedWorkbook = (body as { interactive_workbook?: WorkbookBody })
.interactive_workbook;
const hasEmbedded =
embeddedWorkbook &&
Array.isArray(embeddedWorkbook.exercises) &&
embeddedWorkbook.exercises.length > 0 &&
typeof material.id === "number" &&
typeof material.plan_id === "number";
return (
<div className="rounded-lg border bg-background/70 p-4 space-y-4">
{hasStructured ? (
renderAny(stripEmbedded(body))
) : (
<p className="text-sm whitespace-pre-wrap leading-7">
{material.body_text || "No content available yet."}
</p>
)}
{hasEmbedded && (
<div className="border-t pt-3">
<div className="text-sm font-medium mb-2">Practice</div>
<InteractiveWorkbook
material={material as MaterialForBook}
bodyOverride={embeddedWorkbook!}
mode={mode}
/>
</div>
)}
</div>
);
}
function stripEmbedded(body: Record<string, unknown>): Record<string, unknown> {
if (!body || typeof body !== "object") return body;
if (!("interactive_workbook" in body)) return body;
const { interactive_workbook: _drop, ...rest } = body;
return rest;
}

View File

@@ -0,0 +1,390 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
Link2,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { withAuthQuery } from "@/lib/api-client";
import MaterialBookView, {
SkillBadge,
} from "@/components/coursePlan/MaterialBookView";
import type { WorkbookMode } from "@/components/coursePlan/InteractiveWorkbook";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanMedia,
CoursePlanWeek,
} from "@/types";
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
/**
* Read-only renderer for a course plan that BOTH the student page and
* the admin "View as Student" preview share. The only difference is
* the workbook `mode`:
*
* - `mode="student"` → answers persist server-side (real attempts).
* - `mode="preview"` → InteractiveWorkbook runs in preview mode (no
* persistence) and surfaces an "Show answer key" toggle so admins
* can verify the v2 generator's output.
*
* Materials are rendered through `MaterialBookView`, which dispatches
* `interactive_workbook` materials into the live workbook component.
*/
export interface PlanReaderProps {
plan: CoursePlan;
mode?: WorkbookMode;
/** Heading level for accessibility — admins use this inside their own
* Card; students use it as the page's main grid block. Defaults to 'h3'. */
headingClassName?: string;
}
export default function PlanReader({ plan, mode = "student" }: PlanReaderProps) {
const { t } = useTranslation();
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan.materials]);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level}
</Badge>
<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>
</div>
{plan.assignment && (
<p className="text-xs text-muted-foreground flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" />
{plan.assignment.due_date
? t("coursePlan.student.due", { date: plan.assignment.due_date })
: t("coursePlan.student.noDue")}
{plan.assignment.assigned_by_name && (
<span>
·{" "}
{t("coursePlan.student.assignedBy", {
name: plan.assignment.assigned_by_name,
})}
</span>
)}
</p>
)}
{plan.assignment?.message && (
<p className="text-sm bg-muted/40 rounded-md px-3 py-2 mt-2">
{plan.assignment.message}
</p>
)}
</CardHeader>
</Card>
{plan.objectives && plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((o, i) => (
<li key={i}>{o}</li>
))}
</ol>
</CardContent>
</Card>
)}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>{t("coursePlan.deliveryHint")}</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((w) => (
<ReaderWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
mode={mode}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</div>
);
}
function ReaderWeek({
week,
materials,
mode,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
const skills = useMemo(
() => Array.from(new Set(materials.map((m) => m.skill))).sort(),
[materials],
);
const filteredMaterials = useMemo(
() =>
materials.filter(
(m) => skillFilter === "all" || m.skill === skillFilter,
),
[materials, skillFilter],
);
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{week.focus || week.unit || "—"}
</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
</div>
</AccordionTrigger>
<AccordionContent className="space-y-3">
{skills.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
<Button
size="sm"
variant={skillFilter === "all" ? "default" : "outline"}
onClick={() => setSkillFilter("all")}
>
{t("common.all", "All")}
</Button>
{skills.map((s) => (
<Button
key={s}
size="sm"
variant={skillFilter === s ? "default" : "outline"}
onClick={() => setSkillFilter(s)}
className="capitalize"
>
{s}
</Button>
))}
</div>
)}
{materials.length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("coursePlan.media.noMedia")}
</p>
)}
{filteredMaterials.map((m) => (
<ReaderMaterial key={m.id} material={m} mode={mode} />
))}
</AccordionContent>
</AccordionItem>
);
}
function ReaderMaterial({
material,
mode,
}: {
material: CoursePlanMaterial;
mode: WorkbookMode;
}) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">
{material.title}
</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(
`coursePlan.materialType.${material.material_type}`,
material.material_type,
)}
</Badge>
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
</div>
{material.summary && (
<CardDescription>{material.summary}</CardDescription>
)}
{material.share_date && (
<CardDescription>
{t("coursePlan.shareDate", "Share date")}: {material.share_date}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-3">
{(material.media ?? []).map((m) => (
<ReaderMediaTile key={m.id} media={m} />
))}
<MaterialBookView material={material} mode={mode} />
</CardContent>
</Card>
);
}
function ReaderMediaTile({ media }: { media: CoursePlanMedia }) {
const url = withAuthQuery(media.preview_url || media.download_url || "");
if (!url) return null;
return (
<div className="rounded-md border bg-muted/20 p-2 space-y-2">
<div className="flex items-center gap-2">
{media.kind === "audio" && <Music className="h-4 w-4" />}
{media.kind === "image" && <ImageIcon className="h-4 w-4" />}
{media.kind === "video" && <Video className="h-4 w-4" />}
<span className="capitalize text-xs text-muted-foreground">
{media.kind}
</span>
</div>
{media.kind === "audio" && <audio src={url} controls className="w-full" />}
{media.kind === "image" && (
<img
src={url}
alt={media.title}
className="w-full rounded-md max-h-72 object-contain bg-muted/30"
/>
)}
{media.kind === "video" && (
<video src={url} controls className="w-full rounded-md max-h-72" />
)}
</div>
);
}
/** Pill that surfaces RAG / extraction provenance for one material. */
export function GroundingBadge({ material }: { material: CoursePlanMaterial }) {
const grounded = material.grounded_on ?? [];
const extracted = material.extracted_from ?? null;
if (!grounded.length && !extracted) return null;
const total =
grounded.reduce((acc, g) => acc + (g.chunks_used || 0), 0) +
(extracted ? 1 : 0);
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>
<Badge variant="secondary" className="cursor-help">
<Link2 className="mr-1 h-3 w-3" />
Grounded on {total} reference{total === 1 ? "" : "s"}
</Badge>
</HoverCardTrigger>
<HoverCardContent className="w-72 text-xs">
{grounded.length > 0 && (
<div className="space-y-1">
<div className="font-medium text-sm">RAG sources</div>
<ul className="space-y-1">
{grounded.map((g) => (
<li key={g.source_id} className="flex justify-between gap-2">
<span className="truncate">{g.title || `Source #${g.source_id}`}</span>
<span className="text-muted-foreground">{g.chunks_used} chunk(s)</span>
</li>
))}
</ul>
</div>
)}
{extracted && (
<div className={grounded.length ? "mt-3 border-t pt-2" : ""}>
<div className="font-medium text-sm">Extracted from</div>
<div className="text-muted-foreground">
{extracted.source_title || `Source #${extracted.source_id}`}
{extracted.page_hint ? ` · ${extracted.page_hint}` : ""}
</div>
{extracted.topic_keywords && extracted.topic_keywords.length > 0 && (
<div className="mt-1 text-muted-foreground">
Topics: {extracted.topic_keywords.slice(0, 5).join(", ")}
</div>
)}
</div>
)}
</HoverCardContent>
</HoverCard>
);
}

View File

@@ -1,6 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react"; import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from "react";
import { authService } from "@/services/auth.service"; import { authService } from "@/services/auth.service";
import { clearToken } from "@/lib/api-client"; import { clearToken, getActiveEntityId, setActiveEntityId } from "@/lib/api-client";
import type { User, UserRole } from "@/types/auth"; import type { User, UserRole } from "@/types/auth";
export type { UserRole }; export type { UserRole };
@@ -9,6 +9,9 @@ interface AuthContextType {
user: User | null; user: User | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
selectedEntityId: number | null;
selectedEntity: User["entities"][number] | null;
setSelectedEntityId: (entityId: number | null) => void;
login: (email: string, password: string) => Promise<User>; login: (email: string, password: string) => Promise<User>;
logout: () => Promise<void>; logout: () => Promise<void>;
} }
@@ -17,8 +20,23 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [selectedEntityId, setSelectedEntityIdState] = useState<number | null>(getActiveEntityId());
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const syncEntitySelection = useCallback((nextUser: User | null) => {
const available = nextUser?.entities ?? [];
if (available.length === 0) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
const stored = getActiveEntityId();
const validStored = stored && available.some((e) => e.id === stored) ? stored : null;
const next = validStored ?? available[0].id;
setSelectedEntityIdState(next);
setActiveEntityId(next);
}, []);
useEffect(() => { useEffect(() => {
const token = localStorage.getItem("encoach_token"); const token = localStorage.getItem("encoach_token");
if (!token) { if (!token) {
@@ -28,30 +46,61 @@ export function AuthProvider({ children }: { children: ReactNode }) {
authService authService
.getUser() .getUser()
.then(setUser) .then((u) => {
setUser(u);
syncEntitySelection(u);
})
.catch(() => { .catch(() => {
clearToken(); clearToken();
setActiveEntityId(null);
}) })
.finally(() => setIsLoading(false)); .finally(() => setIsLoading(false));
}, []); }, [syncEntitySelection]);
const login = useCallback(async (email: string, password: string): Promise<User> => { const login = useCallback(async (email: string, password: string): Promise<User> => {
const res = await authService.login({ login: email, password }); const res = await authService.login({ login: email, password });
setUser(res.user); setUser(res.user);
syncEntitySelection(res.user);
return res.user; return res.user;
}, []); }, [syncEntitySelection]);
const setSelectedEntityId = useCallback((entityId: number | null) => {
if (!user) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
if (entityId == null) {
setSelectedEntityIdState(null);
setActiveEntityId(null);
return;
}
if (!user.entities.some((e) => e.id === entityId)) return;
setSelectedEntityIdState(entityId);
setActiveEntityId(entityId);
}, [user]);
const logout = useCallback(async () => { const logout = useCallback(async () => {
await authService.logout(); await authService.logout();
setUser(null); setUser(null);
setSelectedEntityIdState(null);
setActiveEntityId(null);
}, []); }, []);
const selectedEntity =
user?.entities.find((e) => e.id === selectedEntityId) ??
user?.entities[0] ??
null;
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
user, user,
isAuthenticated: !!user, isAuthenticated: !!user,
isLoading, isLoading,
selectedEntityId,
selectedEntity,
setSelectedEntityId,
login, login,
logout, logout,
}} }}

View File

@@ -60,8 +60,10 @@ const ar: Translations = {
dashboard: "لوحة التحكم", dashboard: "لوحة التحكم",
courses: "الدورات", courses: "الدورات",
myCourses: "دوراتي", myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
students: "الطلاب", students: "الطلاب",
teachers: "المعلمون", teachers: "المعلمون",
branches: "الفروع",
batches: "الدفعات", batches: "الدفعات",
timetable: "الجدول الدراسي", timetable: "الجدول الدراسي",
reports: "التقارير", reports: "التقارير",
@@ -202,6 +204,8 @@ const ar: Translations = {
averageGrade: "متوسط الدرجات", averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول", totalChapters: "إجمالي الفصول",
myCourses: "دوراتي", myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.", noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة", chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة", quickActions: "إجراءات سريعة",
@@ -739,8 +743,14 @@ const ar: Translations = {
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.", basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
coverage: "التغطية", coverage: "التغطية",
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.", coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
sources: "المصادر المرجعية",
sourcesDesc:
"ارفع ملفات PDF أو روابط أو نصوص. يستخدمها الذكاء الاصطناعي كمرجع لتوليد مواد كل أسبوع.",
scope: "النطاق", scope: "النطاق",
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.", scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
media: "الوسائط",
mediaDesc:
"اختر أي وسائط يولّدها الذكاء الاصطناعي تلقائياً مع المواد النصّية.",
review: "المراجعة", review: "المراجعة",
reviewDesc: "راجع البيانات قبل بدء التوليد.", reviewDesc: "راجع البيانات قبل بدء التوليد.",
}, },
@@ -767,8 +777,179 @@ const ar: Translations = {
cefrRequired: "يرجى اختيار مستوى CEFR.", cefrRequired: "يرجى اختيار مستوى CEFR.",
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.", weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
}, },
review: {
sourcesCount_one: "{{count}} مصدر",
sourcesCount_other: "{{count}} مصادر",
noSources: "لا توجد مصادر",
mediaNone: "لا يوجد توليد وسائط",
}, },
}, },
sections_extras: {
sources: "المصادر المرجعية",
progress: "تقدّم التوليد",
assignments: "الإسنادات",
media: "الوسائط المُولَّدة",
},
sources: {
sectionTitle: "المصادر المرجعية (RAG)",
sectionDesc:
"ارفع PDF أو أضف روابط أو نصوصاً. يستخدمها الذكاء الاصطناعي كمرجع عند توليد المواد الأسبوعية.",
collected_one: "{{count}} مصدر جاهز",
collected_other: "{{count}} مصادر جاهزة",
empty: "لا توجد مصادر مرجعية بعد.",
dropTitle: "أضف مواد مرجعية",
dropHint: "PDF أو DOCX أو TXT أو HTML أو نص — حتى بضعة ميغابايت لكل ملف.",
uploadFiles: "رفع ملفات",
urlLabel: "إضافة رابط",
textLabel: "أو ألصق نصاً مباشرة",
textPlaceholder: "ألصق فقرة لتوجيه الذكاء الاصطناعي…",
uploadFailed: "تعذّر رفع \"{{name}}\".",
reindex: "إعادة فهرسة",
reindexed: "أُعيدت فهرسة المصدر.",
reindexFailed: "فشلت إعادة الفهرسة.",
deleted: "تم حذف المصدر.",
deleteFailed: "تعذّر حذف المصدر.",
status: {
pending: "في الانتظار",
indexing: "يُفهرس…",
indexed: "مُفهرس",
failed: "فشل",
},
kindLabel: {
file: "ملف",
url: "رابط",
text: "نص",
resource: "المكتبة",
},
chunks_one: "{{count}} جزء",
chunks_other: "{{count}} أجزاء",
pickFromLibrary: "اختيار من المكتبة",
libraryHint: "أعد استخدام مورد قمت برفعه من قبل.",
libraryTitle: "اختر من مكتبة الموارد",
libraryDescription:
"اختر مورداً واحداً أو أكثر من الموارد المعتمدة لدعم الذكاء الاصطناعي. سنقوم بفهرستها تماماً مثل الملفات المرفوعة.",
librarySearchPlaceholder: "ابحث بالعنوان…",
libraryTypeAll: "كل الأنواع",
libraryEmpty: "لا توجد موارد مطابقة لهذه الفلاتر.",
libraryAlreadyLinked: "مربوط مسبقاً",
libraryAttach: "ربط المختار",
librarySelectedCount_one: "تم اختيار {{count}}",
librarySelectedCount_other: "تم اختيار {{count}}",
libraryAttached_one: "تم ربط {{count}} مورد.",
libraryAttached_other: "تم ربط {{count}} موارد.",
librarySkipped_one: "{{count}} مورد كان مربوطاً مسبقاً.",
librarySkipped_other: "{{count}} موارد كانت مربوطة مسبقاً.",
libraryAttachFailed: "تعذّر ربط الموارد المختارة.",
linkedToLibrary: "مرتبط بـ /admin/resources",
fromLibrary: "المكتبة",
libraryPickTitle: "اختر من المكتبة",
libraryPickHint: "أعد استخدام موارد معتمدة سبق رفعها إلى /admin/resources.",
libraryPickButton: "تصفح المكتبة",
},
sourceKind: {
file: "ملف",
url: "رابط",
text: "نص",
},
deliverables: {
title: "تقدّم التوليد",
subtitle:
"ما الذي سيُنتجه الذكاء الاصطناعي، وما تم إنتاجه، وما اكتمل تماماً (مواد + وسائط).",
summary: {
planned_one: "{{count}} مخطّط",
planned_other: "{{count}} مخطّط",
generated_one: "{{count}} مُولَّد",
generated_other: "{{count}} مُولَّد",
ready_one: "{{count}} جاهز",
ready_other: "{{count}} جاهز",
},
mediaCounts: "صوت {{audio}} · صورة {{image}} · فيديو {{video}}",
status: {
planned: "مخطّط",
generated: "مُولَّد",
ready: "جاهز",
},
perWeek: "تفصيل لكل أسبوع",
noWeeks: "لا توجد أسابيع — ولّد الخطة أولاً.",
},
media: {
audio: "صوت",
image: "صورة",
video: "فيديو",
audioTitle: "تعليق صوتي",
audioDesc:
"توليد صوت منطوق (Polly / ElevenLabs) لنصوص الاستماع ومحفّزات المحادثة.",
imageTitle: "صور تغطية",
imageDesc:
"توليد صور DALL·E 3 لنصوص القراءة والاستماع. يحتسب من ميزانية الصور للخطة.",
videoTitle: "فيديو شرائح",
videoDesc: "دمج الصوت + الصورة في فيديو MP4 قصير عبر ffmpeg. أبطأ الخيارات.",
hint:
"الصوت مفعّل افتراضياً. الفيديو معطّل افتراضياً — فعّله بعد مراجعة الصوت والصورة.",
drawerTitle: "وسائط \"{{title}}\"",
drawerSubtitle: "ولّد أو احذف الصوت والصورة والفيديو.",
generateAudio: "توليد صوت",
generateImage: "توليد صورة",
generateVideo: "توليد فيديو",
generating: "جاري التوليد…",
audioReady: "الصوت جاهز",
imageReady: "الصورة جاهزة",
videoReady: "الفيديو جاهز",
audioFailed: "فشل توليد الصوت.",
imageFailed: "فشل توليد الصورة.",
videoFailed: "فشل توليد الفيديو.",
deleted: "تم حذف الوسيط.",
deleteFailed: "تعذّر حذف الوسيط.",
open: "فتح",
download: "تنزيل",
noMedia: "لا توجد وسائط لهذه المادة بعد.",
mediaForMaterial: "وسائط",
bulk: "توليد وسائط الأسبوع",
bulkSuccess: "اكتمل توليد وسائط الأسبوع.",
bulkFailed: "تعذّر توليد وسائط الأسبوع.",
provider: "المزوّد",
},
assignments: {
title: "مُسندة إلى",
empty: "لم تُسند إلى أحد بعد.",
assign: "إسناد",
assignTitle: "إسناد خطة المقرر",
assignSubtitle:
"اختر شعبة (دفعة) للجميع، أو طلاباً محدّدين. ستظهر الخطة في لوحة الطالب.",
modeBatch: "دفعة كاملة",
modeStudents: "طلاب محدّدون",
pickBatch: "اختر دفعة",
pickStudents: "اختر طلاباً",
noBatches: "لا توجد دفعات.",
noStudents: "لا يوجد طلاب.",
dueDate: "تاريخ التسليم (اختياري)",
message: "رسالة للمتعلّمين (اختياري)",
messagePlaceholder: "ترحيب، توقّعات، مواعيد…",
created: "تم إسناد الخطة.",
createFailed: "تعذّر إسناد الخطة.",
removed: "تمت إزالة الإسناد.",
removeFailed: "تعذّر إزالة الإسناد.",
remove: "إزالة",
confirmRemove: "إزالة هذا الإسناد؟",
assignedBy: "أسندها {{name}}",
students_one: "{{count}} طالب",
students_other: "{{count}} طلاب",
cancel: "إلغاء",
save: "إسناد",
},
student: {
listTitle: "خططي الدراسية",
listSubtitle:
"خطط مناهج مخصّصة يُنشئها الذكاء الاصطناعي ويُسندها معلّمك. افتح خطة لرؤية الأسابيع والمواد والوسائط.",
empty: "لم تُسند إليك خطط بعد.",
open: "فتح المقرر",
assignedBy: "أسندها {{name}}",
due: "موعد التسليم {{date}}",
noDue: "بدون موعد",
backToList: "العودة إلى خططي",
},
wizardSourcesStep: "المصادر",
},
aiAdmin: { aiAdmin: {
title: "وكلاء الذكاء الاصطناعي والأدوات", title: "وكلاء الذكاء الاصطناعي والأدوات",
subtitle: subtitle:
@@ -777,6 +958,83 @@ const ar: Translations = {
agents: "الوكلاء", agents: "الوكلاء",
tools: "الأدوات", tools: "الأدوات",
prompts: "التعليمات", prompts: "التعليمات",
providers: "المزوّدون والمفاتيح",
},
},
aiProviders: {
title: "مزوّدو الذكاء الاصطناعي ومفاتيح API",
subtitle:
"اختر المزوّد الفعّال لكل قدرة واحفظ مفاتيح API. تُطبَّق التغييرات في الطلب التالي مباشرةً دون الحاجة لإعادة تشغيل Odoo.",
activeProvider: "المزوّد الفعّال",
save: "حفظ الإعدادات",
testButton: "اختبار سلسلة الاحتياط",
noPaidKeys:
"لا توجد مفاتيح API مدفوعة لهذه القدرة — سيتم استخدام البديل المجاني.",
paidConfigured: "المزوّدون المدفوعون المهيَّؤون:",
empty: "تعذّر تحميل الإعدادات.",
footer:
"تُقرأ إعدادات المزوّدين من ir.config_parameter في كل طلب — بدون تخزين مؤقّت ولا حاجة لإعادة تشغيل.",
cap: {
text: "توليد النصوص",
text_hint: "يستخدمه كل وكلاء LangGraph (مخطّط المقرر، مولّد الاختبارات، المصحّحون).",
image: "توليد الصور",
image_hint: "رسومات لنصوص القراءة ومشاهد الاستماع وبطاقات المفردات.",
audio: "الصوت (TTS)",
audio_hint: "سرد نصوص الاستماع وأمثلة إجابات أسئلة المحادثة.",
video: "تركيب الفيديو",
video_hint: "مقاطع MP4 تجمع بين الصورة المُولَّدة والسرد الصوتي.",
},
kind: {
paid: "مدفوع",
free: "مجاني",
auto: "تلقائي",
},
keys: {
title: "مفاتيح API",
subtitle:
"المفاتيح للكتابة فقط وتُحفَظ مشفّرة في ir.config_parameter. لا تُرجَع أبداً إلى المتصفّح.",
saved: "محفوظ",
unsaved: "غير محفوظ",
clear: "مسح",
placeholderSaved: "•••••• (انقر للاستبدال)",
openai: "مفتاح OpenAI",
openai_hint: "يُستخدَم لـ GPT-4o والتضمين و DALL-E 3.",
aws_access: "AWS Access Key ID",
aws_secret: "AWS Secret Access Key",
elevenlabs: "مفتاح ElevenLabs",
gptzero: "مفتاح GPTZero",
paymob_api: "مفتاح Paymob API",
paymob_integration: "Paymob integration ID",
paymob_iframe: "Paymob iframe ID",
paymob_hmac: "Paymob HMAC secret",
},
group: {
openai: "OpenAI",
aws: "AWS Polly",
elevenlabs: "ElevenLabs",
other: "خدمات ذكاء اصطناعي أخرى",
paymob: "Paymob (المدفوعات)",
},
plain: {
title: "النموذج والتشغيل",
subtitle:
"معلَمات تشغيل غير سرّية: النموذج الافتراضي، منطقة AWS، مهلة الطلب.",
openai_model: "نموذج OpenAI",
openai_fast: "نموذج OpenAI السريع",
aws_region: "منطقة AWS",
elevenlabs_model: "نموذج ElevenLabs",
timeout: "مهلة الطلب (ثوانٍ)",
max_retries: "أقصى عدد محاولات",
},
toast: {
loadFailed: "تعذّر تحميل إعدادات الذكاء الاصطناعي",
saved: "تم حفظ إعدادات المزوّد",
savedDescription:
"تم تحديث المزوّدين. تُطبَّق التغييرات في الطلب التالي.",
saveFailed: "تعذّر حفظ الإعدادات",
testOk: "سلسلة المزوّد جاهزة",
testPartial: "بعض المزوّدين تنقصه بيانات الاعتماد",
testFailed: "فشل اختبار المزوّد",
}, },
}, },
agents: { agents: {

View File

@@ -44,6 +44,8 @@ export interface Translations {
aiAdmin: Record<string, unknown>; aiAdmin: Record<string, unknown>;
agents: Record<string, unknown>; agents: Record<string, unknown>;
tools: Record<string, unknown>; tools: Record<string, unknown>;
/** AI provider selection & API key management (Providers & Keys tab). */
aiProviders: Record<string, unknown>;
} }
const en: Translations = { const en: Translations = {
@@ -105,8 +107,10 @@ const en: Translations = {
dashboard: "Dashboard", dashboard: "Dashboard",
courses: "Courses", courses: "Courses",
myCourses: "My Courses", myCourses: "My Courses",
myCoursePlans: "My Course Plans",
students: "Students", students: "Students",
teachers: "Teachers", teachers: "Teachers",
branches: "Branches",
batches: "Batches", batches: "Batches",
timetable: "Timetable", timetable: "Timetable",
reports: "Reports", reports: "Reports",
@@ -247,6 +251,8 @@ const en: Translations = {
averageGrade: "Average Grade", averageGrade: "Average Grade",
totalChapters: "Total Chapters", totalChapters: "Total Chapters",
myCourses: "My Courses", myCourses: "My Courses",
myCoursePlans: "My Course Plans",
noCoursePlans: "No course plans assigned yet.",
noEnrolledCourses: "No enrolled courses yet.", noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials", chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions", quickActions: "Quick Actions",
@@ -796,8 +802,14 @@ const en: Translations = {
basicsDesc: "Name the course and set level, duration, and weekly hours.", basicsDesc: "Name the course and set level, duration, and weekly hours.",
coverage: "Coverage", coverage: "Coverage",
coverageDesc: "Tell the AI how hours split across skills and who the learners are.", coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
sources: "Reference sources",
sourcesDesc:
"Drop PDFs, URLs, or pasted text. The AI uses them as grounding when generating each week's materials.",
scope: "Scope", scope: "Scope",
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.", scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
media: "Multimedia",
mediaDesc:
"Pick which media the AI should auto-produce alongside the text materials.",
review: "Review", review: "Review",
reviewDesc: "Double-check your brief before kicking off the generation.", reviewDesc: "Double-check your brief before kicking off the generation.",
}, },
@@ -825,8 +837,181 @@ const en: Translations = {
cefrRequired: "Please pick a CEFR level.", cefrRequired: "Please pick a CEFR level.",
weeksRange: "Total weeks must be at least 1.", weeksRange: "Total weeks must be at least 1.",
}, },
review: {
sourcesCount_one: "{{count}} reference source",
sourcesCount_other: "{{count}} reference sources",
noSources: "No reference sources",
mediaNone: "No media generation",
}, },
}, },
sections_extras: {
sources: "Reference sources",
progress: "Generation progress",
assignments: "Assignments",
media: "Generated media",
},
sources: {
sectionTitle: "Reference sources (RAG)",
sectionDesc:
"Upload PDFs, paste URLs, or drop in raw text. The AI will use the indexed content as grounding when it writes weekly materials.",
collected_one: "{{count}} source ready",
collected_other: "{{count}} sources ready",
empty: "No reference sources yet.",
dropTitle: "Add reference materials",
dropHint: "PDF, DOCX, TXT, HTML, or plain text — up to a few MB each.",
uploadFiles: "Upload files",
urlLabel: "Add a URL",
textLabel: "Or paste text directly",
textPlaceholder: "Paste a passage to ground the AI on…",
uploadFailed: "Couldn't upload \"{{name}}\".",
reindex: "Reindex",
reindexed: "Source reindexed.",
reindexFailed: "Reindex failed.",
deleted: "Source deleted.",
deleteFailed: "Couldn't delete source.",
status: {
pending: "Pending",
indexing: "Indexing…",
indexed: "Indexed",
failed: "Failed",
},
kindLabel: {
file: "File",
url: "URL",
text: "Text",
resource: "Library",
},
chunks_one: "{{count}} chunk",
chunks_other: "{{count}} chunks",
// Library picker — reuses /admin/resources items as RAG sources.
pickFromLibrary: "Pick from library",
libraryHint: "Reuse a resource you've already uploaded.",
libraryTitle: "Pick from resource library",
libraryDescription:
"Select one or more approved resources to ground the AI on. We'll index them just like uploaded files.",
librarySearchPlaceholder: "Search by title…",
libraryTypeAll: "All types",
libraryEmpty: "No resources match those filters.",
libraryAlreadyLinked: "Already linked",
libraryAttach: "Attach selected",
librarySelectedCount_one: "{{count}} selected",
librarySelectedCount_other: "{{count}} selected",
libraryAttached_one: "Attached {{count}} resource.",
libraryAttached_other: "Attached {{count}} resources.",
librarySkipped_one: "{{count}} resource was already linked.",
librarySkipped_other: "{{count}} resources were already linked.",
libraryAttachFailed: "Couldn't attach the selected resources.",
linkedToLibrary: "Linked to /admin/resources",
fromLibrary: "Library",
libraryPickTitle: "Pick from library",
libraryPickHint: "Reuse approved resources you've already uploaded to /admin/resources.",
libraryPickButton: "Browse library",
},
sourceKind: {
file: "File",
url: "URL",
text: "Inline text",
},
deliverables: {
title: "Generation progress",
subtitle:
"What the AI is going to produce, what it has produced, and what is fully ready (materials + media).",
summary: {
planned_one: "{{count}} planned",
planned_other: "{{count}} planned",
generated_one: "{{count}} generated",
generated_other: "{{count}} generated",
ready_one: "{{count}} ready",
ready_other: "{{count}} ready",
},
mediaCounts: "Audio {{audio}} · Image {{image}} · Video {{video}}",
status: {
planned: "Planned",
generated: "Generated",
ready: "Ready",
},
perWeek: "Per-week breakdown",
noWeeks: "No weeks yet — generate the plan first.",
},
media: {
audio: "Audio",
image: "Image",
video: "Video",
audioTitle: "Narration audio",
audioDesc:
"Generate spoken audio (Polly / ElevenLabs) for listening scripts and speaking prompts.",
imageTitle: "Cover images",
imageDesc:
"Generate DALL·E 3 images for reading texts and listening scripts. Counts against the per-plan image budget.",
videoTitle: "Slideshow video",
videoDesc:
"Combine audio + image into a short MP4 with ffmpeg. Slowest option.",
hint:
"Audio is on by default. Video is off by default — turn it on once you've reviewed the audio + image.",
drawerTitle: "Media for \"{{title}}\"",
drawerSubtitle: "Generate or remove audio, images, and slideshow video.",
generateAudio: "Generate audio",
generateImage: "Generate image",
generateVideo: "Generate video",
generating: "Generating…",
audioReady: "Audio ready",
imageReady: "Image ready",
videoReady: "Video ready",
audioFailed: "Audio generation failed.",
imageFailed: "Image generation failed.",
videoFailed: "Video generation failed.",
deleted: "Media deleted.",
deleteFailed: "Couldn't delete media.",
open: "Open",
download: "Download",
noMedia: "No media yet for this material.",
mediaForMaterial: "Media",
bulk: "Generate week media",
bulkSuccess: "Week media generation done.",
bulkFailed: "Couldn't generate week media.",
provider: "Provider",
},
assignments: {
title: "Assigned to",
empty: "Not assigned to anyone yet.",
assign: "Assign",
assignTitle: "Assign this course plan",
assignSubtitle:
"Pick a class (batch) for everyone, or individual students. They'll see the plan in their student dashboard.",
modeBatch: "Whole class (batch)",
modeStudents: "Individual students",
pickBatch: "Select a batch",
pickStudents: "Pick students",
noBatches: "No batches available.",
noStudents: "No students available.",
dueDate: "Due date (optional)",
message: "Message to learners (optional)",
messagePlaceholder: "Welcome note, expectations, deadlines…",
created: "Plan assigned.",
createFailed: "Couldn't assign plan.",
removed: "Assignment removed.",
removeFailed: "Couldn't remove assignment.",
remove: "Remove",
confirmRemove: "Remove this assignment?",
assignedBy: "Assigned by {{name}}",
students_one: "{{count}} student",
students_other: "{{count}} students",
cancel: "Cancel",
save: "Assign",
},
student: {
listTitle: "My course plans",
listSubtitle:
"Personalised AI-generated curricula assigned by your teacher. Open one to see the weekly plan, materials, and media.",
empty: "No plans assigned to you yet.",
open: "Open course",
assignedBy: "Assigned by {{name}}",
due: "Due {{date}}",
noDue: "No deadline",
backToList: "Back to my plans",
},
wizardSourcesStep: "Sources",
},
aiAdmin: { aiAdmin: {
title: "AI Agents & Tools", title: "AI Agents & Tools",
subtitle: subtitle:
@@ -835,6 +1020,83 @@ const en: Translations = {
agents: "Agents", agents: "Agents",
tools: "Tools", tools: "Tools",
prompts: "Prompts", prompts: "Prompts",
providers: "Providers & Keys",
},
},
aiProviders: {
title: "AI Providers & API Keys",
subtitle:
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
activeProvider: "Active provider",
save: "Save settings",
testButton: "Test fallback chain",
noPaidKeys:
"No paid API keys configured for this capability — free fallback will be used.",
paidConfigured: "Configured paid providers:",
empty: "Settings could not be loaded.",
footer:
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
cap: {
text: "Text generation",
text_hint: "Used by every LangGraph agent (course planner, exam generator, graders).",
image: "Image generation",
image_hint: "Illustrations for reading texts, listening scenes, and vocabulary cards.",
audio: "Audio (TTS)",
audio_hint: "Listening-script narration and speaking-prompt model answers.",
video: "Video composition",
video_hint: "Slideshow MP4s combining a generated image with the audio narration.",
},
kind: {
paid: "Paid",
free: "Free",
auto: "Auto",
},
keys: {
title: "API keys",
subtitle:
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
saved: "Saved",
unsaved: "Unsaved",
clear: "Clear",
placeholderSaved: "•••••• (click to replace)",
openai: "OpenAI API key",
openai_hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
aws_access: "AWS Access Key ID",
aws_secret: "AWS Secret Access Key",
elevenlabs: "ElevenLabs API key",
gptzero: "GPTZero API key",
paymob_api: "Paymob API key",
paymob_integration: "Paymob integration ID",
paymob_iframe: "Paymob iframe ID",
paymob_hmac: "Paymob HMAC secret",
},
group: {
openai: "OpenAI",
aws: "AWS Polly",
elevenlabs: "ElevenLabs",
other: "Other AI services",
paymob: "Paymob (payments)",
},
plain: {
title: "Model & runtime",
subtitle:
"Non-secret runtime parameters: default model, AWS region, request timeout.",
openai_model: "OpenAI model",
openai_fast: "OpenAI fast model",
aws_region: "AWS region",
elevenlabs_model: "ElevenLabs model",
timeout: "Request timeout (seconds)",
max_retries: "Max retries",
},
toast: {
loadFailed: "Could not load AI settings",
saved: "AI provider settings saved",
savedDescription:
"Active providers updated. Changes apply to the next request.",
saveFailed: "Could not save settings",
testOk: "Provider chain ready",
testPartial: "Some providers missing credentials",
testFailed: "Provider test failed",
}, },
}, },
agents: { agents: {

View File

@@ -101,11 +101,53 @@ export function describeApiError(
const ACCESS_KEY = "encoach_token"; const ACCESS_KEY = "encoach_token";
const REFRESH_KEY = "encoach_refresh_token"; const REFRESH_KEY = "encoach_refresh_token";
const EXP_KEY = "encoach_token_exp"; const EXP_KEY = "encoach_token_exp";
const ENTITY_KEY = "encoach_entity_id";
function getAccessToken(): string | null { function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY); return localStorage.getItem(ACCESS_KEY);
} }
export function getActiveEntityId(): number | null {
try {
const raw = localStorage.getItem(ENTITY_KEY);
if (!raw) return null;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
export function setActiveEntityId(entityId: number | null | undefined): void {
try {
if (entityId && Number.isFinite(entityId)) {
localStorage.setItem(ENTITY_KEY, String(Math.floor(entityId)));
} else {
localStorage.removeItem(ENTITY_KEY);
}
} catch {
// ignore storage errors
}
}
/**
* Build a URL to a media-streaming endpoint with the JWT attached as a
* query parameter. Used by ``<img>`` / ``<audio>`` / ``<video>`` tags
* (which can't attach custom Authorization headers) and by ``<a download>``
* tags so the browser can fetch the binary directly without going through
* a fetch + blob URL dance.
*
* Returns the original path unchanged when no token is stored, which lets
* callers render a placeholder rather than crashing on ``null``.
*/
export function withAuthQuery(path: string): string {
if (!path) return path;
const token = getAccessToken();
if (!token) return path;
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}token=${encodeURIComponent(token)}`;
}
function getRefreshToken(): string | null { function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY); return localStorage.getItem(REFRESH_KEY);
} }
@@ -240,6 +282,11 @@ export type QueryParamValue =
*/ */
export type QueryParams = object; export type QueryParams = object;
const ENTITY_QUERY_SCOPE_RE =
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
const ENTITY_BODY_SCOPE_RE =
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
function buildUrl(path: string, params?: QueryParams): string { function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin); const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) { if (params) {
@@ -253,9 +300,30 @@ function buildUrl(path: string, params?: QueryParams): string {
url.searchParams.set(key, String(rawValue)); url.searchParams.set(key, String(rawValue));
} }
} }
// Multi-entity LMS scope: when the user has selected an active entity
// in the UI, append it to entity-scoped endpoints unless the caller
// already provided an explicit entity_id.
const activeEntityId = getActiveEntityId();
if (
activeEntityId &&
!url.searchParams.has("entity_id") &&
ENTITY_QUERY_SCOPE_RE.test(path)
) {
url.searchParams.set("entity_id", String(activeEntityId));
}
return url.toString(); return url.toString();
} }
function maybeInjectEntityIntoBody(path: string, body: unknown): unknown {
const activeEntityId = getActiveEntityId();
if (!activeEntityId) return body;
if (!ENTITY_BODY_SCOPE_RE.test(path)) return body;
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const rec = body as Record<string, unknown>;
if (rec.entity_id !== undefined && rec.entity_id !== null) return body;
return { ...rec, entity_id: activeEntityId };
}
async function parseResponse<T>(response: Response): Promise<T> { async function parseResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null); const data = await response.json().catch(() => null);
if (!response.ok) throw new ApiError(response.status, response.statusText, data); if (!response.ok) throw new ApiError(response.status, response.statusText, data);
@@ -315,33 +383,37 @@ export const api = {
}, },
async post<T>(path: string, body?: unknown): Promise<T> { async post<T>(path: string, body?: unknown): Promise<T> {
const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), { return performRequest<T>(buildUrl(path), {
method: "POST", method: "POST",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
}, },
async patch<T>(path: string, body?: unknown): Promise<T> { async patch<T>(path: string, body?: unknown): Promise<T> {
const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), { return performRequest<T>(buildUrl(path), {
method: "PATCH", method: "PATCH",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
}, },
async put<T>(path: string, body?: unknown): Promise<T> { async put<T>(path: string, body?: unknown): Promise<T> {
const payload = maybeInjectEntityIntoBody(path, body);
return performRequest<T>(buildUrl(path), { return performRequest<T>(buildUrl(path), {
method: "PUT", method: "PUT",
headers: buildHeaders(), headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined, body: payload ? JSON.stringify(payload) : undefined,
}); });
}, },
async delete<T>(path: string): Promise<T> { async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
return performRequest<T>(buildUrl(path), { return performRequest<T>(buildUrl(path), {
method: "DELETE", method: "DELETE",
headers: buildHeaders(), headers: buildHeaders(),
body: payload ? JSON.stringify(payload) : undefined,
}); });
}, },

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -7,10 +7,12 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react"; import { Search, Plus, Building2, Trash2, Pencil, Users, Shield, UserCog } from "lucide-react";
import { entitiesService } from "@/services/entities.service"; import { entitiesService } from "@/services/entities.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Checkbox } from "@/components/ui/checkbox";
import { useAuth } from "@/contexts/AuthContext";
const ENTITY_TYPES = [ const ENTITY_TYPES = [
{ value: "corporate", label: "Corporate" }, { value: "corporate", label: "Corporate" },
@@ -21,10 +23,16 @@ const ENTITY_TYPES = [
]; ];
export default function EntitiesPage() { export default function EntitiesPage() {
const { user } = useAuth();
const isAdminUser = user?.user_type === "admin";
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null); const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
const [manageOpen, setManageOpen] = useState(false);
const [manageEntity, setManageEntity] = useState<{ id: number; name: string } | null>(null);
const [usersSearch, setUsersSearch] = useState("");
const [selectedUserIds, setSelectedUserIds] = useState<Set<number>>(new Set());
const [form, setForm] = useState({ name: "", code: "", type: "" }); const [form, setForm] = useState({ name: "", code: "", type: "" });
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
@@ -34,6 +42,18 @@ export default function EntitiesPage() {
queryFn: () => entitiesService.list({ size: 200 }), queryFn: () => entitiesService.list({ size: 200 }),
}); });
const platformUsersQ = useQuery({
queryKey: ["platform-users-for-entities"],
queryFn: () => entitiesService.listPlatformUsers({ size: 500 }),
enabled: isAdminUser && manageOpen,
});
const entityUsersQ = useQuery({
queryKey: ["entity-users", manageEntity?.id],
queryFn: () => entitiesService.listEntityUsers(manageEntity!.id),
enabled: isAdminUser && !!manageEntity?.id,
});
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (data: { name: string; code?: string; type?: string }) => mutationFn: (data: { name: string; code?: string; type?: string }) =>
entitiesService.create(data as Partial<import("@/types").Entity>), entitiesService.create(data as Partial<import("@/types").Entity>),
@@ -66,6 +86,21 @@ export default function EntitiesPage() {
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
}); });
const saveEntityUsersMut = useMutation({
mutationFn: ({ entityId, userIds }: { entityId: number; userIds: number[] }) =>
entitiesService.updateEntityUsers(entityId, userIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
qc.invalidateQueries({ queryKey: ["entity-users"] });
setManageOpen(false);
setManageEntity(null);
setUsersSearch("");
setSelectedUserIds(new Set());
toast({ title: "Entity users updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const raw = entitiesQ.data as unknown; const raw = entitiesQ.data as unknown;
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => { const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
if (!raw) return []; if (!raw) return [];
@@ -82,11 +117,43 @@ export default function EntitiesPage() {
); );
const loading = entitiesQ.isLoading; const loading = entitiesQ.isLoading;
useEffect(() => {
if (!entityUsersQ.data) return;
setSelectedUserIds(new Set(entityUsersQ.data.map((u) => u.id)));
}, [entityUsersQ.data]);
const filteredPlatformUsers = useMemo(() => {
const all = platformUsersQ.data ?? [];
const q = usersSearch.trim().toLowerCase();
if (!q) return all;
return all.filter((u) =>
(u.name || "").toLowerCase().includes(q)
|| (u.email || "").toLowerCase().includes(q)
|| (u.login || "").toLowerCase().includes(q),
);
}, [platformUsersQ.data, usersSearch]);
function openEdit(e: typeof entities[0]) { function openEdit(e: typeof entities[0]) {
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type }); setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
setEditOpen(true); setEditOpen(true);
} }
function openManageUsers(e: typeof entities[0]) {
setManageEntity({ id: e.id, name: e.name });
setUsersSearch("");
setSelectedUserIds(new Set());
setManageOpen(true);
}
function toggleUser(userId: number) {
setSelectedUserIds((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -159,6 +226,11 @@ export default function EntitiesPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">
{isAdminUser ? (
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openManageUsers(e)}>
<UserCog className="h-4 w-4" />
</Button>
) : null}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}> <Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
@@ -279,6 +351,79 @@ export default function EntitiesPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Manage Users Dialog (admin only) */}
<Dialog open={manageOpen} onOpenChange={(open) => {
setManageOpen(open);
if (!open) {
setManageEntity(null);
setUsersSearch("");
setSelectedUserIds(new Set());
}
}}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Manage Users {manageEntity?.name ?? ""}</DialogTitle>
</DialogHeader>
{!isAdminUser ? (
<div className="text-sm text-muted-foreground py-4">Only admin users can manage entity members.</div>
) : (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name/email/login..."
className="pl-9"
value={usersSearch}
onChange={(e) => setUsersSearch(e.target.value)}
/>
</div>
<div className="rounded-md border max-h-[420px] overflow-y-auto">
{platformUsersQ.isLoading || entityUsersQ.isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading users...</div>
) : filteredPlatformUsers.length === 0 ? (
<div className="p-4 text-sm text-muted-foreground">No users found.</div>
) : (
<div className="divide-y">
{filteredPlatformUsers.map((u) => (
<label key={u.id} className="flex items-center gap-3 p-3 hover:bg-muted/40 cursor-pointer">
<Checkbox
checked={selectedUserIds.has(u.id)}
onCheckedChange={() => toggleUser(u.id)}
/>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{u.name || u.login || u.email}</div>
<div className="text-xs text-muted-foreground truncate">{u.email || u.login}</div>
</div>
</label>
))}
</div>
)}
</div>
</div>
)}
<DialogFooter>
<div className="text-xs text-muted-foreground mr-auto">
{selectedUserIds.size} user(s) selected
</div>
<Button variant="outline" onClick={() => setManageOpen(false)}>Cancel</Button>
<Button
disabled={!manageEntity || saveEntityUsersMut.isPending || !isAdminUser}
onClick={() => {
if (!manageEntity) return;
saveEntityUsersMut.mutate({
entityId: manageEntity.id,
userIds: Array.from(selectedUserIds),
});
}}
>
{saveEntityUsersMut.isPending ? "Saving..." : "Save Users"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@@ -40,6 +40,7 @@ import {
useRenderAIPrompt, useRenderAIPrompt,
} from "@/hooks/queries/useAIPrompts"; } from "@/hooks/queries/useAIPrompts";
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel"; import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
import AIProviderSettings from "@/pages/admin/AIProviderSettings";
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel"; import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
import type { AIPromptSummary } from "@/types/ai-prompt"; import type { AIPromptSummary } from "@/types/ai-prompt";
import { import {
@@ -47,6 +48,7 @@ import {
CheckCircle2, CheckCircle2,
FileText, FileText,
History, History,
KeyRound,
Play, Play,
PlusCircle, PlusCircle,
Wrench, Wrench,
@@ -582,6 +584,13 @@ export default function AIPromptEditor() {
<FileText className="me-1 h-4 w-4" /> <FileText className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.prompts", "Prompts")} {t("aiAdmin.tabs.prompts", "Prompts")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger
value="providers"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<KeyRound className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.providers", "Providers & Keys")}
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="agents" className="mt-2"> <TabsContent value="agents" className="mt-2">
@@ -593,6 +602,9 @@ export default function AIPromptEditor() {
<TabsContent value="prompts" className="mt-2"> <TabsContent value="prompts" className="mt-2">
<AIPromptsPanel /> <AIPromptsPanel />
</TabsContent> </TabsContent>
<TabsContent value="providers" className="mt-2">
<AIProviderSettings />
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );

View File

@@ -0,0 +1,704 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Activity,
CheckCircle2,
Eye,
EyeOff,
Image as ImageIcon,
Loader2,
Save,
Settings2,
Volume2,
Wand2,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
aiSettingsService,
type AISettingsPatchPayload,
type AISettingsState,
type CapabilityKey,
type CapabilityState,
type ProviderTestResult,
} from "@/services/aiSettings.service";
/**
* Admin UI for AI provider selection and API-key management.
*
* - Dropdowns set the active provider per capability (text / image / audio /
* video). Selecting "auto" tells the backend to try paid providers first
* and silently fall back to free providers on quota / billing errors.
* - API-key inputs are write-only — the backend never returns a key value;
* we only render a "saved" badge based on `keys_set[<name>]`.
* - Changes persist to `ir.config_parameter` and take effect on the very
* next request (no caching), so admins can flip OpenAI -> Mock without
* restarting Odoo.
*
* Mounted as the fourth tab on `/admin/ai/prompts` so the user keeps a
* single "AI" surface in the admin nav.
*/
const CAPABILITY_META: Array<{
key: CapabilityKey;
icon: React.ComponentType<{ className?: string }>;
i18nKey: string;
defaultLabel: string;
}> = [
{ key: "text", icon: Wand2, i18nKey: "aiProviders.cap.text", defaultLabel: "Text generation" },
{ key: "image", icon: ImageIcon, i18nKey: "aiProviders.cap.image", defaultLabel: "Image generation" },
{ key: "audio", icon: Volume2, i18nKey: "aiProviders.cap.audio", defaultLabel: "Audio (TTS)" },
{ key: "video", icon: Activity, i18nKey: "aiProviders.cap.video", defaultLabel: "Video composition" },
];
interface KeyFieldDef {
shortName: string;
i18nKey: string;
defaultLabel: string;
placeholder?: string;
hint?: string;
hintI18nKey?: string;
group: "openai" | "aws" | "elevenlabs" | "other" | "paymob";
}
const KEY_FIELDS: KeyFieldDef[] = [
// OpenAI
{
shortName: "openai_api_key",
i18nKey: "aiProviders.keys.openai",
defaultLabel: "OpenAI API key",
placeholder: "sk-…",
hintI18nKey: "aiProviders.keys.openai_hint",
hint: "Used for GPT-4o, embeddings, and DALL-E 3.",
group: "openai",
},
// AWS
{
shortName: "aws_access_key",
i18nKey: "aiProviders.keys.aws_access",
defaultLabel: "AWS Access Key ID",
placeholder: "AKIA…",
group: "aws",
},
{
shortName: "aws_secret_key",
i18nKey: "aiProviders.keys.aws_secret",
defaultLabel: "AWS Secret Access Key",
placeholder: "wJalr…",
group: "aws",
},
// ElevenLabs
{
shortName: "elevenlabs_api_key",
i18nKey: "aiProviders.keys.elevenlabs",
defaultLabel: "ElevenLabs API key",
placeholder: "el_…",
group: "elevenlabs",
},
// GPTZero
{
shortName: "gptzero_api_key",
i18nKey: "aiProviders.keys.gptzero",
defaultLabel: "GPTZero API key",
group: "other",
},
// Paymob (payments)
{
shortName: "paymob_api_key",
i18nKey: "aiProviders.keys.paymob_api",
defaultLabel: "Paymob API key",
group: "paymob",
},
{
shortName: "paymob_integration_id",
i18nKey: "aiProviders.keys.paymob_integration",
defaultLabel: "Paymob integration ID",
group: "paymob",
},
{
shortName: "paymob_iframe_id",
i18nKey: "aiProviders.keys.paymob_iframe",
defaultLabel: "Paymob iframe ID",
group: "paymob",
},
{
shortName: "paymob_hmac_secret",
i18nKey: "aiProviders.keys.paymob_hmac",
defaultLabel: "Paymob HMAC secret",
group: "paymob",
},
];
const KEY_GROUPS: Array<{
group: KeyFieldDef["group"];
i18nKey: string;
defaultLabel: string;
}> = [
{ group: "openai", i18nKey: "aiProviders.group.openai", defaultLabel: "OpenAI" },
{ group: "aws", i18nKey: "aiProviders.group.aws", defaultLabel: "AWS Polly" },
{ group: "elevenlabs", i18nKey: "aiProviders.group.elevenlabs", defaultLabel: "ElevenLabs" },
{ group: "other", i18nKey: "aiProviders.group.other", defaultLabel: "Other AI services" },
{ group: "paymob", i18nKey: "aiProviders.group.paymob", defaultLabel: "Paymob (payments)" },
];
const KIND_BADGE: Record<string, { className: string; labelKey: string; defaultLabel: string }> = {
paid: { className: "bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200",
labelKey: "aiProviders.kind.paid", defaultLabel: "Paid" },
free: { className: "bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200",
labelKey: "aiProviders.kind.free", defaultLabel: "Free" },
auto: { className: "bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
labelKey: "aiProviders.kind.auto", defaultLabel: "Auto" },
};
function CapabilityCard({
capKey,
state,
onChange,
onTest,
testing,
testResult,
}: {
capKey: CapabilityKey;
state: CapabilityState;
onChange: (newValue: string) => void;
onTest: () => void;
testing: boolean;
testResult: ProviderTestResult | null;
}) {
const { t } = useTranslation();
const meta = CAPABILITY_META.find((m) => m.key === capKey)!;
const Icon = meta.icon;
const activeOption = state.options.find((o) => o.value === state.active);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Icon className="h-4 w-4" />
{t(meta.i18nKey, meta.defaultLabel)}
</CardTitle>
{activeOption ? (
<Badge
variant="outline"
className={KIND_BADGE[activeOption.kind]?.className}
>
{t(
KIND_BADGE[activeOption.kind]?.labelKey ?? "aiProviders.kind.auto",
KIND_BADGE[activeOption.kind]?.defaultLabel ?? activeOption.kind,
)}
</Badge>
) : null}
</div>
<CardDescription>
{t(`${meta.i18nKey}_hint`, "Active provider for this capability.")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<Label htmlFor={`provider-${capKey}`}>
{t("aiProviders.activeProvider", "Active provider")}
</Label>
<Select value={state.active} onValueChange={onChange}>
<SelectTrigger id={`provider-${capKey}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{state.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<span className="flex items-center gap-2">
{opt.label}
<Badge
variant="outline"
className={`text-xs ${KIND_BADGE[opt.kind]?.className ?? ""}`}
>
{t(
KIND_BADGE[opt.kind]?.labelKey ?? "aiProviders.kind.auto",
KIND_BADGE[opt.kind]?.defaultLabel ?? opt.kind,
)}
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{state.paid_with_credentials.length === 0 && capKey !== "video" ? (
<p className="text-muted-foreground text-xs">
{t(
"aiProviders.noPaidKeys",
"No paid API keys configured for this capability — free fallback will be used.",
)}
</p>
) : (
<p className="text-muted-foreground text-xs">
{t("aiProviders.paidConfigured", "Configured paid providers:")}{" "}
{state.paid_with_credentials.join(", ") || "—"}
</p>
)}
<div className="flex items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={onTest}
disabled={testing}
>
{testing ? (
<Loader2 className="me-1 h-3 w-3 animate-spin" />
) : null}
{t("aiProviders.testButton", "Test fallback chain")}
</Button>
{testResult ? (
<span className="text-muted-foreground text-xs">
{testResult.chain
.map((c) => `${c.provider}${c.ok ? " ✓" : " ✗"}`)
.join(" → ")}
</span>
) : null}
</div>
</CardContent>
</Card>
);
}
function SecretField({
field,
isSet,
value,
onChange,
onClear,
}: {
field: KeyFieldDef;
isSet: boolean;
value: string | undefined;
onChange: (v: string) => void;
onClear: () => void;
}) {
const { t } = useTranslation();
const [revealed, setRevealed] = useState(false);
const dirty = value !== undefined;
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`key-${field.shortName}`} className="text-sm">
{t(field.i18nKey, field.defaultLabel)}
</Label>
<div className="flex items-center gap-2">
{isSet && !dirty ? (
<Badge
variant="outline"
className="bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
>
<CheckCircle2 className="me-1 h-3 w-3" />
{t("aiProviders.keys.saved", "Saved")}
</Badge>
) : null}
{dirty ? (
<Badge
variant="outline"
className="bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-200"
>
{t("aiProviders.keys.unsaved", "Unsaved")}
</Badge>
) : null}
</div>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
id={`key-${field.shortName}`}
type={revealed ? "text" : "password"}
placeholder={
isSet
? t("aiProviders.keys.placeholderSaved", "•••••• (click to replace)")
: (field.placeholder ?? "")
}
value={value ?? ""}
onChange={(e) => onChange(e.target.value)}
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={() => setRevealed((v) => !v)}
className="text-muted-foreground hover:text-foreground absolute end-2 top-1/2 -translate-y-1/2"
tabIndex={-1}
aria-label={revealed ? "Hide value" : "Show value"}
>
{revealed ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{isSet ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClear}
>
{t("aiProviders.keys.clear", "Clear")}
</Button>
) : null}
</div>
{field.hint ? (
<p className="text-muted-foreground text-xs">
{t(field.hintI18nKey ?? "", field.hint)}
</p>
) : null}
</div>
);
}
function PlainField({
shortName,
label,
value,
onChange,
}: {
shortName: string;
label: string;
value: string;
onChange: (v: string) => void;
}) {
return (
<div className="space-y-1">
<Label htmlFor={`plain-${shortName}`} className="text-sm">
{label}
</Label>
<Input
id={`plain-${shortName}`}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export default function AIProviderSettings() {
const { t } = useTranslation();
const [state, setState] = useState<AISettingsState | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// Pending diff applied on Save. Provider edits are confirmed
// immediately so the UI reflects the active selection — but we still
// batch them into one PATCH so we don't burn requests on every click.
const [pendingProviders, setPendingProviders] = useState<
Partial<Record<CapabilityKey, string>>
>({});
const [pendingKeys, setPendingKeys] = useState<Record<string, string>>({});
const [pendingPlain, setPendingPlain] = useState<Record<string, string>>({});
const [testing, setTesting] = useState<CapabilityKey | null>(null);
const [testResults, setTestResults] = useState<
Partial<Record<CapabilityKey, ProviderTestResult>>
>({});
useEffect(() => {
void load();
}, []);
async function load() {
setLoading(true);
try {
const data = await aiSettingsService.get();
setState(data);
setPendingProviders({});
setPendingKeys({});
setPendingPlain({});
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("aiProviders.toast.loadFailed", "Could not load AI settings"), {
description: msg,
});
} finally {
setLoading(false);
}
}
const dirty = useMemo(
() =>
Object.keys(pendingProviders).length > 0 ||
Object.keys(pendingKeys).length > 0 ||
Object.keys(pendingPlain).length > 0,
[pendingProviders, pendingKeys, pendingPlain],
);
async function handleSave() {
if (!state || !dirty) return;
setSaving(true);
try {
const payload: AISettingsPatchPayload = {};
if (Object.keys(pendingProviders).length) payload.providers = pendingProviders;
if (Object.keys(pendingKeys).length) payload.keys = pendingKeys;
if (Object.keys(pendingPlain).length) payload.plain = pendingPlain;
const updated = await aiSettingsService.update(payload);
setState(updated);
setPendingProviders({});
setPendingKeys({});
setPendingPlain({});
toast.success(
t("aiProviders.toast.saved", "AI provider settings saved"),
{
description: t(
"aiProviders.toast.savedDescription",
"Active providers updated. Changes apply to the next request.",
),
},
);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("aiProviders.toast.saveFailed", "Could not save settings"), {
description: msg,
});
} finally {
setSaving(false);
}
}
async function handleTest(cap: CapabilityKey) {
setTesting(cap);
try {
const result = await aiSettingsService.test(cap);
setTestResults((prev) => ({ ...prev, [cap]: result }));
const allOk = result.chain.every((c) => c.ok);
if (allOk) {
toast.success(
t("aiProviders.toast.testOk", "Provider chain ready") +
` · ${result.chain.map((c) => c.provider).join(" → ")}`,
);
} else {
toast.warning(
t("aiProviders.toast.testPartial", "Some providers missing credentials") +
` · ${result.chain
.map((c) => `${c.provider}${c.ok ? "✓" : "✗"}`)
.join(" → ")}`,
);
}
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(t("aiProviders.toast.testFailed", "Provider test failed"), {
description: msg,
});
} finally {
setTesting(null);
}
}
if (loading) {
return (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
if (!state) {
return (
<div className="text-muted-foreground py-12 text-center text-sm">
{t("aiProviders.empty", "Settings could not be loaded.")}
</div>
);
}
const effectiveProviders: Record<CapabilityKey, CapabilityState> = {
text: { ...state.providers.text, active: pendingProviders.text ?? state.providers.text.active },
image: { ...state.providers.image, active: pendingProviders.image ?? state.providers.image.active },
audio: { ...state.providers.audio, active: pendingProviders.audio ?? state.providers.audio.active },
video: { ...state.providers.video, active: pendingProviders.video ?? state.providers.video.active },
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<Settings2 className="h-5 w-5" />
{t("aiProviders.title", "AI Providers & API Keys")}
</CardTitle>
<CardDescription>
{t(
"aiProviders.subtitle",
"Pick the active provider per capability and store API keys. Changes take effect on the next request — no Odoo restart required.",
)}
</CardDescription>
</div>
<Button
onClick={handleSave}
disabled={!dirty || saving}
className="shrink-0"
>
{saving ? (
<Loader2 className="me-1 h-4 w-4 animate-spin" />
) : (
<Save className="me-1 h-4 w-4" />
)}
{t("aiProviders.save", "Save settings")}
</Button>
</div>
</CardHeader>
</Card>
<div className="grid gap-4 md:grid-cols-2">
{CAPABILITY_META.map((meta) => (
<CapabilityCard
key={meta.key}
capKey={meta.key}
state={effectiveProviders[meta.key]}
onChange={(newValue) =>
setPendingProviders((prev) => ({ ...prev, [meta.key]: newValue }))
}
onTest={() => handleTest(meta.key)}
testing={testing === meta.key}
testResult={testResults[meta.key] ?? null}
/>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("aiProviders.keys.title", "API keys")}
</CardTitle>
<CardDescription>
{t(
"aiProviders.keys.subtitle",
"Keys are write-only and stored encrypted at rest in ir.config_parameter. They are never returned to the browser.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{KEY_GROUPS.map((group) => {
const fields = KEY_FIELDS.filter((f) => f.group === group.group);
if (!fields.length) return null;
return (
<div key={group.group} className="space-y-3">
<h3 className="text-sm font-semibold">
{t(group.i18nKey, group.defaultLabel)}
</h3>
<div className="grid gap-4 md:grid-cols-2">
{fields.map((field) => (
<SecretField
key={field.shortName}
field={field}
isSet={Boolean(state.keys_set[field.shortName])}
value={pendingKeys[field.shortName]}
onChange={(v) =>
setPendingKeys((prev) => ({
...prev,
[field.shortName]: v,
}))
}
onClear={() =>
setPendingKeys((prev) => ({
...prev,
[field.shortName]: "",
}))
}
/>
))}
</div>
</div>
);
})}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">
{t("aiProviders.plain.title", "Model & runtime")}
</CardTitle>
<CardDescription>
{t(
"aiProviders.plain.subtitle",
"Non-secret runtime parameters: default model, AWS region, request timeout.",
)}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2">
<PlainField
shortName="openai_model"
label={t("aiProviders.plain.openai_model", "OpenAI model")}
value={pendingPlain.openai_model ?? state.plain.openai_model ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, openai_model: v }))
}
/>
<PlainField
shortName="openai_fast_model"
label={t("aiProviders.plain.openai_fast", "OpenAI fast model")}
value={pendingPlain.openai_fast_model ?? state.plain.openai_fast_model ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, openai_fast_model: v }))
}
/>
<PlainField
shortName="aws_region"
label={t("aiProviders.plain.aws_region", "AWS region")}
value={pendingPlain.aws_region ?? state.plain.aws_region ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, aws_region: v }))
}
/>
<PlainField
shortName="elevenlabs_model"
label={t("aiProviders.plain.elevenlabs_model", "ElevenLabs model")}
value={pendingPlain.elevenlabs_model ?? state.plain.elevenlabs_model ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, elevenlabs_model: v }))
}
/>
<PlainField
shortName="request_timeout"
label={t("aiProviders.plain.timeout", "Request timeout (seconds)")}
value={pendingPlain.request_timeout ?? state.plain.request_timeout ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, request_timeout: v }))
}
/>
<PlainField
shortName="max_retries"
label={t("aiProviders.plain.max_retries", "Max retries")}
value={pendingPlain.max_retries ?? state.plain.max_retries ?? ""}
onChange={(v) =>
setPendingPlain((prev) => ({ ...prev, max_retries: v }))
}
/>
</CardContent>
</Card>
<p className="text-muted-foreground text-xs">
{t(
"aiProviders.footer",
"Provider settings are read fresh from ir.config_parameter on every request — no caching, no restart required.",
)}
</p>
</div>
);
}

View File

@@ -4,34 +4,106 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useBatches, useCourses } from "@/hooks/queries"; import { useBatches, useCourses, useTeachers } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service"; import { lmsService } from "@/services/lms.service";
import { classroomsService } from "@/services/classrooms.service";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2 } from "lucide-react"; import { Search, Plus, Trash2, Pencil } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer"; import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant"; import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner"; import AiTipBanner from "@/components/ai/AiTipBanner";
type BatchForm = {
name: string;
course_id: string;
course_section_id: string;
term_key: string;
classroom_id: string;
start_date: string;
end_date: string;
max_students: string;
teacher_ids: number[];
};
const EMPTY_FORM: BatchForm = {
name: "",
course_id: "",
course_section_id: "",
term_key: "",
classroom_id: "",
start_date: "",
end_date: "",
max_students: "30",
teacher_ids: [],
};
export default function AdminBatches() { export default function AdminBatches() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); const [editOpen, setEditOpen] = useState(false);
const [editingBatchId, setEditingBatchId] = useState<number | null>(null);
const [form, setForm] = useState<BatchForm>(EMPTY_FORM);
const [editForm, setEditForm] = useState<BatchForm>(EMPTY_FORM);
const { toast } = useToast(); const { toast } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const { data: batchesData, isLoading } = useBatches(); const { data: batchesData, isLoading } = useBatches();
const { data: coursesData } = useCourses({ size: 200 }); const { data: coursesData } = useCourses({ size: 200 });
const { data: teachersData } = useTeachers({ size: 200 });
const classroomsQ = useQuery({
queryKey: ["lms-classrooms-light"],
queryFn: async () => (await classroomsService.list({ size: 200 })).items,
});
const createSectionsQ = useQuery({
queryKey: ["course-sections", form.course_id],
queryFn: () =>
form.course_id
? lmsService.listCourseSections(Number(form.course_id))
: Promise.resolve({ items: [], total: 0 }),
enabled: !!form.course_id,
});
const editSectionsQ = useQuery({
queryKey: ["course-sections", editForm.course_id],
queryFn: () =>
editForm.course_id
? lmsService.listCourseSections(Number(editForm.course_id))
: Promise.resolve({ items: [], total: 0 }),
enabled: !!editForm.course_id,
});
const batches = batchesData?.items ?? []; const batches = batchesData?.items ?? [];
const courses = coursesData?.items ?? []; const courses = coursesData?.items ?? [];
const teachers = teachersData?.items ?? [];
const classrooms = classroomsQ.data ?? [];
const createSections = createSectionsQ.data?.items ?? [];
const editSections = editSectionsQ.data?.items ?? [];
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never), mutationFn: (data: Record<string, unknown>) => lmsService.createBatch(data as never),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); toast({ title: "Batch created" }); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "30" }); }, onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setCreateOpen(false);
setForm(EMPTY_FORM);
toast({ title: "Batch created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setEditOpen(false);
setEditingBatchId(null);
setEditForm(EMPTY_FORM);
toast({ title: "Batch updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
}); });
@@ -47,9 +119,26 @@ export default function AdminBatches() {
} }
} }
function openEdit(batch: (typeof batches)[number]) {
setEditingBatchId(batch.id);
setEditForm({
name: batch.name || "",
course_id: String(batch.course_id || ""),
course_section_id: batch.course_section_id ? String(batch.course_section_id) : "",
term_key: batch.term_key || "",
classroom_id: batch.classroom_id ? String(batch.classroom_id) : "",
start_date: batch.start_date || "",
end_date: batch.end_date || "",
max_students: String(batch.capacity || 30),
teacher_ids: batch.teacher_ids || [],
});
setEditOpen(true);
}
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 (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 filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase())); const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -62,7 +151,81 @@ export default function AdminBatches() {
</div> </div>
<AiTipBanner context="admin-batches" variant="recommendation" /> <AiTipBanner context="admin-batches" variant="recommendation" />
<div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div> <div className="relative max-w-sm"><Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /><Input placeholder="Search batches..." value={search} onChange={e => setSearch(e.target.value)} className="pl-9" /></div>
<Card><CardContent className="pt-6"><Table><TableHeader><TableRow><TableHead>Batch</TableHead><TableHead>Course</TableHead><TableHead>Teacher</TableHead><TableHead>Students</TableHead><TableHead>Schedule</TableHead><TableHead>Status</TableHead><TableHead></TableHead></TableRow></TableHeader><TableBody>{filtered.map(b => (<TableRow key={b.id}><TableCell className="font-medium">{b.name}</TableCell><TableCell>{b.course_name}</TableCell><TableCell>{b.teacher_name}</TableCell><TableCell>{b.student_count}/{b.capacity}</TableCell><TableCell className="text-xs">{b.schedule}</TableCell><TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell><TableCell><div className="flex gap-1"><Button size="sm" variant="ghost" asChild><Link to={`/admin/batches/${b.id}`}>View</Link></Button><Button variant="ghost" size="icon" onClick={() => handleDelete(b.id, b.name)}><Trash2 className="h-4 w-4 text-destructive" /></Button></div></TableCell></TableRow>))}</TableBody></Table></CardContent></Card> <Card>
<CardContent className="pt-6">
<Table>
<TableHeader>
<TableRow>
<TableHead>Batch</TableHead>
<TableHead>Course</TableHead>
<TableHead>Section</TableHead>
<TableHead>Term</TableHead>
<TableHead>Classroom</TableHead>
<TableHead>Teachers</TableHead>
<TableHead>Students</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Status</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell>
{b.course_section_name ? (
<Badge variant="outline" className="font-mono text-xs">
{b.course_section_code || b.course_section_name}
</Badge>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{b.term_key || "—"}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{b.classroom_name || "—"}
</TableCell>
<TableCell>
{b.teacher_names?.length ? b.teacher_names.join(", ") : b.teacher_name || "—"}
</TableCell>
<TableCell>
{b.student_count}/{b.capacity}
</TableCell>
<TableCell className="text-xs">{b.schedule}</TableCell>
<TableCell>
<Badge
variant={b.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{b.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button size="sm" variant="ghost" asChild>
<Link to={`/admin/batches/${b.id}`}>View</Link>
</Button>
<Button variant="ghost" size="icon" onClick={() => openEdit(b)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(b.id, b.name)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<Dialog open={createOpen} onOpenChange={setCreateOpen}> <Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent> <DialogContent>
<DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Create Batch</DialogTitle></DialogHeader>
@@ -70,7 +233,16 @@ export default function AdminBatches() {
<div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div> <div className="space-y-2"><Label>Batch Name *</Label><Input placeholder="e.g. IELTS Morning B" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Course *</Label> <Label>Course *</Label>
<Select value={form.course_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, course_id: v === "__none__" ? "" : v }))}> <Select
value={form.course_id || "__none__"}
onValueChange={(v) =>
setForm((f) => ({
...f,
course_id: v === "__none__" ? "" : v,
course_section_id: "",
}))
}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger> <SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="__none__"> Select </SelectItem> <SelectItem value="__none__"> Select </SelectItem>
@@ -78,15 +250,233 @@ export default function AdminBatches() {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Section (optional)</Label>
<Select
value={form.course_section_id || "__none__"}
onValueChange={(v) =>
setForm((f) => ({ ...f, course_section_id: v === "__none__" ? "" : v }))
}
disabled={!form.course_id}
>
<SelectTrigger>
<SelectValue placeholder="Whole course" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Whole course (no section)</SelectItem>
{createSections.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.code ? `${s.code} · ${s.name}` : s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Pick a course section template to keep batches isolated per group.
</p>
</div>
<div className="space-y-2">
<Label>Term Key (optional)</Label>
<Input
placeholder="e.g. 2026-T1"
value={form.term_key}
onChange={(e) => setForm((f) => ({ ...f, term_key: e.target.value }))}
/>
<p className="text-[11px] text-muted-foreground">
One batch per (classroom × course × section × term).
</p>
</div>
</div>
<div className="space-y-2">
<Label>Classroom (optional)</Label>
<Select value={form.classroom_id || "__none__"} onValueChange={v => setForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No classroom</SelectItem>
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Selecting a classroom links the batch to that homeroom. Use the Classrooms page to auto-enroll the roster.</p>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div> <div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={form.start_date} onChange={e => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div> <div className="space-y-2"><Label>End Date *</Label><Input type="date" value={form.end_date} onChange={e => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div> </div>
<div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div> <div className="space-y-2"><Label>Capacity</Label><Input type="number" placeholder="30" value={form.max_students} onChange={e => setForm(f => ({ ...f, max_students: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Teachers (multi-select)</Label>
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
{teachers.map((t) => (
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={form.teacher_ids.includes(t.id)}
onCheckedChange={() =>
setForm((prev) => ({
...prev,
teacher_ids: prev.teacher_ids.includes(t.id)
? prev.teacher_ids.filter((id) => id !== t.id)
: [...prev.teacher_ids, t.id],
}))
}
/>
<span className="text-sm">{t.name}</span>
</label>
))}
</div>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button> <Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMutation.isPending || !form.name || !form.course_id || !form.start_date || !form.end_date} onClick={() => createMutation.mutate({ name: form.name, course_id: Number(form.course_id), start_date: form.start_date, end_date: form.end_date, max_students: Number(form.max_students) || 30 })}>Create</Button> <Button
disabled={
createMutation.isPending ||
!form.name ||
!form.course_id ||
!form.start_date ||
!form.end_date
}
onClick={() =>
createMutation.mutate({
name: form.name,
course_id: Number(form.course_id),
course_section_id: form.course_section_id ? Number(form.course_section_id) : undefined,
term_key: form.term_key.trim() || undefined,
classroom_id: form.classroom_id ? Number(form.classroom_id) : undefined,
start_date: form.start_date,
end_date: form.end_date,
max_students: Number(form.max_students) || 30,
teacher_ids: form.teacher_ids,
})
}
>
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Batch</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Batch Name *</Label><Input value={editForm.name} onChange={e => setEditForm(f => ({ ...f, name: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Course *</Label>
<Select
value={editForm.course_id || "__none__"}
onValueChange={(v) =>
setEditForm((f) => ({
...f,
course_id: v === "__none__" ? "" : v,
course_section_id: "",
}))
}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Section (optional)</Label>
<Select
value={editForm.course_section_id || "__none__"}
onValueChange={(v) =>
setEditForm((f) => ({
...f,
course_section_id: v === "__none__" ? "" : v,
}))
}
disabled={!editForm.course_id}
>
<SelectTrigger>
<SelectValue placeholder="Whole course" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Whole course (no section)</SelectItem>
{editSections.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.code ? `${s.code} · ${s.name}` : s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Term Key (optional)</Label>
<Input
placeholder="e.g. 2026-T1"
value={editForm.term_key}
onChange={(e) => setEditForm((f) => ({ ...f, term_key: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Classroom (optional)</Label>
<Select value={editForm.classroom_id || "__none__"} onValueChange={v => setEditForm(f => ({ ...f, classroom_id: v === "__none__" ? "" : v }))}>
<SelectTrigger><SelectValue placeholder="No classroom" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No classroom</SelectItem>
{classrooms.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.name} ({c.student_count} students)</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>Start Date *</Label><Input type="date" value={editForm.start_date} onChange={e => setEditForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date *</Label><Input type="date" value={editForm.end_date} onChange={e => setEditForm(f => ({ ...f, end_date: e.target.value }))} /></div>
</div>
<div className="space-y-2"><Label>Capacity</Label><Input type="number" value={editForm.max_students} onChange={e => setEditForm(f => ({ ...f, max_students: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Teachers (multi-select)</Label>
<div className="max-h-40 overflow-y-auto rounded border p-2 space-y-1">
{teachers.map((t) => (
<label key={t.id} className="flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<Checkbox
checked={editForm.teacher_ids.includes(t.id)}
onCheckedChange={() =>
setEditForm((prev) => ({
...prev,
teacher_ids: prev.teacher_ids.includes(t.id)
? prev.teacher_ids.filter((id) => id !== t.id)
: [...prev.teacher_ids, t.id],
}))
}
/>
<span className="text-sm">{t.name}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMutation.isPending || !editingBatchId || !editForm.name || !editForm.course_id || !editForm.start_date || !editForm.end_date}
onClick={() => {
if (!editingBatchId) return;
updateMutation.mutate({
id: editingBatchId,
data: {
name: editForm.name,
course_id: Number(editForm.course_id),
course_section_id: editForm.course_section_id
? Number(editForm.course_section_id)
: null,
term_key: editForm.term_key.trim(),
classroom_id: editForm.classroom_id ? Number(editForm.classroom_id) : null,
start_date: editForm.start_date,
end_date: editForm.end_date,
max_students: Number(editForm.max_students) || 30,
teacher_ids: editForm.teacher_ids,
},
});
}}
>
Save
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -0,0 +1,260 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Building2, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { lmsService } from "@/services/lms.service";
import type { LmsBranch } from "@/types";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
type BranchForm = {
name: string;
code: string;
notes: string;
active: boolean;
};
const EMPTY_FORM: BranchForm = { name: "", code: "", notes: "", active: true };
export default function AdminBranches() {
const { toast } = useToast();
const queryClient = useQueryClient();
const { selectedEntity } = useAuth();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<LmsBranch | null>(null);
const [search, setSearch] = useState("");
const [form, setForm] = useState<BranchForm>(EMPTY_FORM);
const branchesQ = useQuery({
queryKey: ["lms-branches", selectedEntity?.id ?? 0, search],
queryFn: async () => {
const res = await lmsService.listBranches({
size: 200,
search: search.trim() || undefined,
});
return res.items;
},
});
const createMut = useMutation({
mutationFn: (payload: Partial<LmsBranch>) => lmsService.createBranch(payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
setOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
toast({ title: "Branch created" });
},
onError: () => toast({ title: "Error", description: "Failed to create branch", variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: ({ id, payload }: { id: number; payload: Partial<LmsBranch> }) =>
lmsService.updateBranch(id, payload),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
setOpen(false);
setEditing(null);
setForm(EMPTY_FORM);
toast({ title: "Branch updated" });
},
onError: () => toast({ title: "Error", description: "Failed to update branch", variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBranch(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["lms-branches"] });
toast({ title: "Branch deleted" });
},
onError: () => toast({ title: "Error", description: "Failed to delete branch", variant: "destructive" }),
});
const isSaving = createMut.isPending || updateMut.isPending;
const rows = useMemo(() => branchesQ.data ?? [], [branchesQ.data]);
const openCreate = () => {
setEditing(null);
setForm(EMPTY_FORM);
setOpen(true);
};
const openEdit = (row: LmsBranch) => {
setEditing(row);
setForm({
name: row.name || "",
code: row.code || "",
notes: row.notes || "",
active: !!row.active,
});
setOpen(true);
};
const onSave = () => {
const payload: Partial<LmsBranch> = {
name: form.name.trim(),
code: form.code.trim(),
notes: form.notes.trim(),
active: form.active,
};
if (!payload.name) return;
if (editing) {
updateMut.mutate({ id: editing.id, payload });
return;
}
createMut.mutate(payload);
};
const onDelete = (row: LmsBranch) => {
if (!window.confirm(`Delete branch "${row.name}"?`)) return;
deleteMut.mutate(row.id);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Branches</h1>
<p className="text-muted-foreground">
Create branches per entity to organize courses, batches, teachers, and students.
</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Branch
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? "Edit Branch" : "Create Branch"}</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div className="space-y-2">
<Label>Branch name</Label>
<Input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g. Riyadh Main Campus"
/>
</div>
<div className="space-y-2">
<Label>Branch code</Label>
<Input
value={form.code}
onChange={(e) => setForm((prev) => ({ ...prev, code: e.target.value }))}
placeholder="e.g. RUH_MAIN"
/>
</div>
<div className="space-y-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => setForm((prev) => ({ ...prev, notes: e.target.value }))}
placeholder="Optional notes for managers"
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<p className="text-sm font-medium">Active</p>
<p className="text-xs text-muted-foreground">Inactive branches stay in history but are hidden from active workflows.</p>
</div>
<Switch
checked={form.active}
onCheckedChange={(checked) => setForm((prev) => ({ ...prev, active: checked }))}
/>
</div>
<Button className="w-full" onClick={onSave} disabled={isSaving || !form.name.trim()}>
{isSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{editing ? "Update Branch" : "Create Branch"}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="flex items-center gap-2">
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search branches by name or code"
/>
</div>
{branchesQ.isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Shared LMS</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell>{row.code || "—"}</TableCell>
<TableCell>{row.entity_name || "—"}</TableCell>
<TableCell>
<div className="text-xs text-muted-foreground">
C:{row.course_count} B:{row.batch_count} S:{row.student_count} T:{row.teacher_count}
</div>
</TableCell>
<TableCell>
{row.active ? (
<span className="inline-flex items-center rounded bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">Active</span>
) : (
<span className="inline-flex items-center rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-600">Inactive</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEdit(row)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => onDelete(row)} disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<Building2 className="h-5 w-5" />
<span>No branches found for this entity.</span>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,12 +13,12 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries"; import { useCourses, useCreateCourse, useStudents, useBulkEnroll, useBatches } from "@/hooks/queries";
import { lmsService, taxonomyService, resourcesService } from "@/services"; import { lmsService, taxonomyService, resourcesService } from "@/services";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap } from "lucide-react"; import { Search, Plus, Trash2, UserPlus, FileEdit, Pencil, BookOpen, Target, Loader2, Users, GraduationCap, Layers, Sparkles, X } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant"; import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner"; import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import type { Course, CourseCreateRequest } from "@/types"; import type { Course, CourseCreateRequest, CourseSection } from "@/types";
import type { ResourceTag } from "@/types/adaptive"; import type { ResourceTag } from "@/types/adaptive";
const DIFFICULTY_OPTIONS = [ const DIFFICULTY_OPTIONS = [
@@ -409,10 +409,335 @@ function courseToFormData(c: Course): CourseFormData {
}; };
} }
function CourseSectionsDialog({
open,
onOpenChange,
course,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
course: Course;
}) {
const { toast } = useToast();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [newCode, setNewCode] = useState("");
const [newSeq, setNewSeq] = useState<number>(10);
const [editing, setEditing] = useState<CourseSection | null>(null);
const [editName, setEditName] = useState("");
const [editCode, setEditCode] = useState("");
const [editSeq, setEditSeq] = useState<number>(10);
const [editActive, setEditActive] = useState(true);
const [busy, setBusy] = useState(false);
const sectionsQ = useQuery({
queryKey: ["course-sections", course.id],
queryFn: () => lmsService.listCourseSections(course.id),
enabled: open,
});
const sections = sectionsQ.data?.items ?? [];
function refreshAll() {
qc.invalidateQueries({ queryKey: ["course-sections", course.id] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: ["lms-classroom-detail"] });
}
async function handleGenerateDefaults() {
setBusy(true);
try {
const res = await lmsService.generateDefaultCourseSections(course.id);
toast({
title: res.created_count > 0 ? `Generated ${res.created_count} section(s)` : "Defaults already exist",
});
refreshAll();
} catch (e: unknown) {
toast({
title: "Generation failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleAdd() {
const code = newCode.trim().toUpperCase();
const name = newName.trim() || `Section ${code}`;
if (!code) {
toast({ title: "Code is required", variant: "destructive" });
return;
}
setBusy(true);
try {
await lmsService.createCourseSection(course.id, {
name,
code,
sequence: newSeq || 10,
active: true,
});
toast({ title: `Section ${code} added` });
setNewName("");
setNewCode("");
setNewSeq(10);
refreshAll();
} catch (e: unknown) {
toast({
title: "Add failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
function startEdit(s: CourseSection) {
setEditing(s);
setEditName(s.name);
setEditCode(s.code);
setEditSeq(s.sequence || 10);
setEditActive(s.active);
}
async function saveEdit() {
if (!editing) return;
setBusy(true);
try {
await lmsService.updateCourseSection(course.id, editing.id, {
name: editName.trim() || editing.name,
code: editCode.trim().toUpperCase() || editing.code,
sequence: editSeq || editing.sequence,
active: editActive,
});
toast({ title: "Section updated" });
setEditing(null);
refreshAll();
} catch (e: unknown) {
toast({
title: "Update failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
async function handleDelete(s: CourseSection) {
if ((s.batch_count ?? 0) > 0) {
toast({
title: "Cannot delete",
description: `Section "${s.code}" has ${s.batch_count} batch(es). Reassign or remove them first.`,
variant: "destructive",
});
return;
}
if (!window.confirm(`Delete section "${s.name}" (${s.code})?`)) return;
setBusy(true);
try {
await lmsService.deleteCourseSection(course.id, s.id);
toast({ title: "Section deleted" });
refreshAll();
} catch (e: unknown) {
toast({
title: "Delete failed",
description: e instanceof Error ? e.message : String(e),
variant: "destructive",
});
}
setBusy(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[640px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Layers className="h-5 w-5" /> Sections {course.title}
</DialogTitle>
<DialogDescription>
Course sections (A, B, C) are templates. Each section can be hosted by a classroom,
which auto-creates a batch and enrolls the classroom roster.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
<div className="text-sm">
<p className="font-medium">Quick start</p>
<p className="text-xs text-muted-foreground">
Create the standard <strong>A · B · C</strong> sections for this course in one click.
</p>
</div>
<Button
size="sm"
onClick={handleGenerateDefaults}
disabled={busy || sectionsQ.isLoading}
variant="secondary"
>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Sparkles className="mr-2 h-4 w-4" />}
Generate A / B / C
</Button>
</div>
<div>
<Label className="text-sm font-medium">Existing sections</Label>
{sectionsQ.isLoading ? (
<div className="flex items-center gap-2 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading
</div>
) : sections.length === 0 ? (
<p className="text-sm text-muted-foreground py-3">
No sections yet. Add one below or click <em>Generate A / B / C</em>.
</p>
) : (
<div className="mt-2 border rounded-md divide-y">
{sections.map((s) => (
<div key={s.id} className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex items-center gap-3 min-w-0">
<Badge variant="outline" className="font-mono">
{s.code}
</Badge>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">
seq {s.sequence}
{s.batch_count != null && (
<span className="ml-2">· {s.batch_count} batch(es)</span>
)}
{!s.active && <span className="ml-2 text-amber-600">· inactive</span>}
</p>
</div>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="icon"
variant="ghost"
title="Edit section"
onClick={() => startEdit(s)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
size="icon"
variant="ghost"
title="Delete section"
onClick={() => handleDelete(s)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border p-3 space-y-3">
<Label className="text-sm font-medium">Add a new section</Label>
<div className="grid grid-cols-12 gap-2">
<div className="col-span-3">
<Label className="text-[11px] text-muted-foreground">Code *</Label>
<Input
placeholder="A"
value={newCode}
onChange={(e) => setNewCode(e.target.value)}
maxLength={8}
/>
</div>
<div className="col-span-7">
<Label className="text-[11px] text-muted-foreground">Name</Label>
<Input
placeholder="Section A"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
</div>
<div className="col-span-2">
<Label className="text-[11px] text-muted-foreground">Seq</Label>
<Input
type="number"
value={newSeq}
onChange={(e) => setNewSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div className="flex justify-end">
<Button size="sm" onClick={handleAdd} disabled={busy || !newCode.trim()}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
Add section
</Button>
</div>
</div>
</div>
{editing && (
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Pencil className="h-4 w-4" /> Edit section
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
<div>
<Label>Code</Label>
<Input
value={editCode}
onChange={(e) => setEditCode(e.target.value)}
maxLength={8}
/>
</div>
<div>
<Label>Sequence</Label>
<Input
type="number"
value={editSeq}
onChange={(e) => setEditSeq(Number(e.target.value) || 10)}
/>
</div>
</div>
<div>
<Label>Name</Label>
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={editActive}
onCheckedChange={(v) => setEditActive(Boolean(v))}
/>
Active
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)}>
<X className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button onClick={saveEdit} disabled={busy}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AdminCourses() { export default function AdminCourses() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null); const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false); const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null); const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]); const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
@@ -539,10 +864,11 @@ export default function AdminCourses() {
<TableHead>Course</TableHead> <TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead> <TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead> <TableHead>Difficulty</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Chapters</TableHead> <TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead> <TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead className="w-[150px]" /> <TableHead className="w-[180px]" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@@ -594,6 +920,36 @@ export default function AdminCourses() {
</Badge> </Badge>
)} )}
</TableCell> </TableCell>
<TableCell>
<button
type="button"
onClick={() => setSectionsCourse(c)}
className="group flex items-center gap-1 text-xs hover:underline"
title="Manage course sections (A/B/C…)"
>
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{c.section_count ?? c.sections?.length ?? 0}</span>
<span className="text-muted-foreground">sections</span>
{c.sections && c.sections.length > 0 && (
<span className="ml-1 flex flex-wrap gap-0.5">
{c.sections.slice(0, 4).map((s) => (
<Badge
key={s.id}
variant="outline"
className="text-[10px] px-1 py-0 leading-none"
>
{s.code || s.name}
</Badge>
))}
{c.sections.length > 4 && (
<span className="text-[10px] text-muted-foreground">
+{c.sections.length - 4}
</span>
)}
</span>
)}
</button>
</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1 text-sm"> <div className="flex items-center gap-1 text-sm">
<BookOpen className="h-3.5 w-3.5 text-muted-foreground" /> <BookOpen className="h-3.5 w-3.5 text-muted-foreground" />
@@ -627,6 +983,14 @@ export default function AdminCourses() {
> >
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
title="Manage sections (A/B/C…)"
onClick={() => setSectionsCourse(c)}
>
<Layers className="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
))} ))}
{filtered.length === 0 && ( {filtered.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8"> <TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No courses found. No courses found.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -679,6 +1043,16 @@ export default function AdminCourses() {
/> />
)} )}
{sectionsCourse && (
<CourseSectionsDialog
open={!!sectionsCourse}
onOpenChange={(open) => {
if (!open) setSectionsCourse(null);
}}
course={sectionsCourse}
/>
)}
<Dialog open={enrollOpen} onOpenChange={setEnrollOpen}> <Dialog open={enrollOpen} onOpenChange={setEnrollOpen}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-lg">
<DialogHeader> <DialogHeader>

View File

@@ -11,13 +11,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
Upload, Search, FileText, Video, Link2, Download, Trash2, Upload, Search, FileText, Video, Link2, Download, Trash2,
CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image, CheckCircle2, Clock, XCircle, Loader2, BookOpen, Music, Image,
Tag, Plus, Pencil, X, CalendarDays, Tag, Plus, Pencil, X, CalendarDays, Eye,
} from "lucide-react"; } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { resourcesService } from "@/services/resources.service"; import { resourcesService } from "@/services/resources.service";
import { coursewareService } from "@/services/courseware.service"; import { coursewareService } from "@/services/courseware.service";
import { taxonomyService } from "@/services/taxonomy.service"; import { taxonomyService } from "@/services/taxonomy.service";
import { describeApiError } from "@/lib/api-client"; import { describeApiError, withAuthQuery } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { TaxonomyCascade } from "@/components/TaxonomyCascade"; import { TaxonomyCascade } from "@/components/TaxonomyCascade";
import type { Resource, ResourceTag } from "@/types"; import type { Resource, ResourceTag } from "@/types";
@@ -165,13 +165,13 @@ function EditResourceDialog({
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="pdf">PDF</SelectItem> <SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="image">Image</SelectItem> <SelectItem value="image">Image</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="video">Video</SelectItem>
<SelectItem value="document">Document</SelectItem>
<SelectItem value="article">Article</SelectItem> <SelectItem value="article">Article</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -351,12 +351,19 @@ export default function ResourceManager() {
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="pdf">PDF</SelectItem> <SelectItem value="pdf">PDF</SelectItem>
<SelectItem value="image">Image</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
<SelectItem value="video">Video</SelectItem> <SelectItem value="video">Video</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="document">Document</SelectItem> <SelectItem value="document">Document</SelectItem>
<SelectItem value="article">Article</SelectItem>
<SelectItem value="link">Link</SelectItem>
<SelectItem value="interactive">Interactive</SelectItem> <SelectItem value="interactive">Interactive</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-[11px] text-muted-foreground">
Tip: leave this on "PDF" the server auto-detects the
actual type from the uploaded file's MIME and corrects it.
</p>
</div> </div>
<TaxonomyCascade <TaxonomyCascade
subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId} subjectId={uploadSubjectId} onSubjectChange={setUploadSubjectId}
@@ -619,12 +626,46 @@ function ContentTable({
<TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell> <TableCell className="text-xs text-muted-foreground whitespace-nowrap">{formatDate(row.createdAt)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{row.source === "resource" && row.resourceId && ( {row.source === "resource" && row.resourceId && row.raw && (
<> <>
{/* Smart preview — opens the file inline (PDF in
iframe, image / audio / video in their native
tag) without forcing a download. ``link`` rows
jump to the external URL directly. */}
{(row.raw.preview_url || row.raw.url) && (
<Button
variant="ghost"
size="icon"
title="Preview"
onClick={() => {
const target =
row.raw?.preview_url
? withAuthQuery(row.raw.preview_url)
: row.raw?.url || "";
if (target) window.open(target, "_blank", "noopener,noreferrer");
}}
>
<Eye className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button> <Button variant="ghost" size="icon" title="Edit" onClick={() => row.raw && onEditResource(row.raw)}><Pencil className="h-4 w-4" /></Button>
{row.raw.has_file && (
<Button variant="ghost" size="icon" title="Download" onClick={async () => { <Button variant="ghost" size="icon" title="Download" onClick={async () => {
try { const blob = await resourcesService.download(row.resourceId!); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); } catch { toast({ title: "Download failed", variant: "destructive" }); } try {
const { blob, filename } = await resourcesService.download(row.resourceId!);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
// Prefer the server-provided filename (which always
// carries the extension) over ``row.name``, which
// is often just "test" — the previous behaviour
// saved files with no extension on disk.
a.download = filename || row.raw?.original_filename || row.raw?.file_name || row.name || "resource";
a.click();
URL.revokeObjectURL(url);
} catch { toast({ title: "Download failed", variant: "destructive" }); }
}}><Download className="h-4 w-4" /></Button> }}><Download className="h-4 w-4" /></Button>
)}
<Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button> <Button variant="ghost" size="icon" title="Delete" onClick={() => onDeleteResource(row.resourceId!)} disabled={deletePending}><Trash2 className="h-4 w-4 text-destructive" /></Button>
</> </>
)} )}

View File

@@ -1,9 +1,20 @@
import { useState } from "react"; import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { X } from "lucide-react"; import {
FileText,
Image as ImageIcon,
Library,
Link2,
Mic,
Music,
Trash2,
Upload,
Video,
X,
} from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard"; import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -18,9 +29,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog";
import { coursePlanService } from "@/services/coursePlan.service"; import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client"; import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief } from "@/types"; import type { CoursePlanGenerateBrief, Resource } from "@/types";
/** /**
* AI course-plan generation wizard. * AI course-plan generation wizard.
@@ -38,6 +50,37 @@ import type { CoursePlanGenerateBrief } from "@/types";
* materials immediately. * materials immediately.
*/ */
type DraftSourceKind = "file" | "url" | "text" | "resource";
interface DraftSource {
/** Stable client-side id; not persisted. */
uid: string;
kind: DraftSourceKind;
name: string;
/** Only set when kind === "file". */
file?: File;
/** Only set when kind === "url". */
url?: string;
/** Only set when kind === "text". */
text?: string;
/** Only set when kind === "resource" — pointer into the central library. */
resourceId?: number;
/** Display-only metadata so we can render the row without a refetch. */
resourceType?: string;
}
interface MediaToggleState {
/** Generate narration audio (Polly / ElevenLabs) for listening +
* speaking materials right after each week's materials are produced. */
audio: boolean;
/** Generate DALL-E images for reading texts, listening scripts, and
* vocabulary lists. Bound to the per-plan image budget. */
image: boolean;
/** Compose audio + image into a slideshow MP4 with ffmpeg. Slowest
* option; off by default. */
video: boolean;
}
interface CoursePlanWizardState { interface CoursePlanWizardState {
title: string; title: string;
cefr_level: string; cefr_level: string;
@@ -48,6 +91,8 @@ interface CoursePlanWizardState {
grammar_focus: string[]; grammar_focus: string[];
resources: string[]; resources: string[];
notes: string; notes: string;
sources: DraftSource[];
media: MediaToggleState;
} }
const CEFR_OPTIONS = [ const CEFR_OPTIONS = [
@@ -183,6 +228,17 @@ export default function CoursePlanWizard() {
</div> </div>
), ),
}, },
{
id: "sources",
titleKey: "coursePlan.wizard.steps.sources",
descriptionKey: "coursePlan.wizard.steps.sourcesDesc",
render: ({ state, update }) => (
<SourcesStep
sources={state.sources}
onChange={(sources) => update({ sources })}
/>
),
},
{ {
id: "scope", id: "scope",
titleKey: "coursePlan.wizard.steps.scope", titleKey: "coursePlan.wizard.steps.scope",
@@ -214,6 +270,17 @@ export default function CoursePlanWizard() {
</div> </div>
), ),
}, },
{
id: "media",
titleKey: "coursePlan.wizard.steps.media",
descriptionKey: "coursePlan.wizard.steps.mediaDesc",
render: ({ state, update }) => (
<MediaStep
media={state.media}
onChange={(media) => update({ media })}
/>
),
},
{ {
id: "review", id: "review",
titleKey: "coursePlan.wizard.steps.review", titleKey: "coursePlan.wizard.steps.review",
@@ -257,6 +324,26 @@ export default function CoursePlanWizard() {
label={t("coursePlan.wizard.fields.notes")} label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"} value={state.notes || "—"}
/> />
<ReviewRow
label={t("coursePlan.wizard.steps.sources")}
value={
state.sources.length
? t("coursePlan.wizard.review.sourcesCount", {
count: state.sources.length,
})
: t("coursePlan.wizard.review.noSources")
}
/>
<ReviewRow
label={t("coursePlan.wizard.steps.media")}
value={[
state.media.audio ? t("coursePlan.media.audio") : null,
state.media.image ? t("coursePlan.media.image") : null,
state.media.video ? t("coursePlan.media.video") : null,
]
.filter(Boolean)
.join(", ") || t("coursePlan.wizard.review.mediaNone")}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground"> <div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")} {t("coursePlan.wizard.reviewHint")}
</div> </div>
@@ -283,9 +370,11 @@ export default function CoursePlanWizard() {
grammar_focus: [], grammar_focus: [],
resources: [], resources: [],
notes: "", notes: "",
sources: [],
media: { audio: true, image: true, video: false },
}} }}
onFinish={async (state) => { onFinish={async (state) => {
await mutation.mutateAsync({ const resp = await mutation.mutateAsync({
title: state.title.trim(), title: state.title.trim(),
cefr_level: state.cefr_level, cefr_level: state.cefr_level,
total_weeks: state.total_weeks, total_weeks: state.total_weeks,
@@ -298,11 +387,368 @@ export default function CoursePlanWizard() {
resources: state.resources.length ? state.resources : undefined, resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined, notes: state.notes.trim() || undefined,
}); });
const planId = resp?.data?.id;
if (planId && state.sources.length) {
// Library picks go through the dedicated bulk endpoint — one
// round-trip for the whole set, with server-side dedupe — so we
// collect their ids first and post them together. The remaining
// file/url/text drafts are posted individually as before.
const resourceIds = state.sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number);
if (resourceIds.length) {
try {
await coursePlanService.attachResources(planId, resourceIds);
} catch (err) {
toast.error(
describeApiError(
err,
t("coursePlan.sources.libraryAttachFailed"),
),
);
}
}
// Best-effort upload — we keep going even if a single one fails so
// the user lands on the detail page with whatever did make it in.
for (const src of state.sources) {
try {
if (src.kind === "file" && src.file) {
await coursePlanService.uploadSource(planId, src.file, {
name: src.name || src.file.name,
});
} else if (src.kind === "url" && src.url) {
await coursePlanService.createSource(planId, {
kind: "url",
url: src.url,
name: src.name || src.url,
});
} else if (src.kind === "text" && src.text) {
await coursePlanService.createSource(planId, {
kind: "text",
inline_text: src.text,
name: src.name || t("coursePlan.sourceKind.text"),
});
}
// src.kind === "resource" was already handled in the bulk
// attach above.
} catch (err) {
toast.error(
describeApiError(err, t("coursePlan.sources.uploadFailed", {
name: src.name || src.kind,
})),
);
}
}
}
}} }}
/> />
); );
} }
function genUid() {
return Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
}
function SourcesStep({
sources,
onChange,
}: {
sources: DraftSource[];
onChange: (next: DraftSource[]) => void;
}) {
const { t } = useTranslation();
const fileRef = useRef<HTMLInputElement>(null);
const [draftUrl, setDraftUrl] = useState("");
const [draftText, setDraftText] = useState("");
const [libraryOpen, setLibraryOpen] = useState(false);
// Resource ids already queued in this wizard session; passed to the
// picker so the same resource can't be added twice.
const queuedResourceIds = new Set(
sources
.filter((s) => s.kind === "resource" && s.resourceId)
.map((s) => s.resourceId as number),
);
const addFiles = (files: FileList | null) => {
if (!files || !files.length) return;
const additions: DraftSource[] = [];
for (const f of Array.from(files)) {
additions.push({
uid: genUid(),
kind: "file",
name: f.name,
file: f,
});
}
onChange([...sources, ...additions]);
};
const addUrl = () => {
const url = draftUrl.trim();
if (!url) return;
onChange([...sources, { uid: genUid(), kind: "url", name: url, url }]);
setDraftUrl("");
};
const addText = () => {
const text = draftText.trim();
if (!text) return;
onChange([
...sources,
{
uid: genUid(),
kind: "text",
name: text.slice(0, 60).replace(/\s+/g, " "),
text,
},
]);
setDraftText("");
};
const addLibraryResources = (picked: Resource[]) => {
if (!picked.length) return;
const additions: DraftSource[] = picked.map((r) => ({
uid: genUid(),
kind: "resource",
name: r.name,
resourceId: r.id,
resourceType: r.resource_type || r.type || "resource",
}));
onChange([...sources, ...additions]);
setLibraryOpen(false);
};
const remove = (uid: string) => onChange(sources.filter((s) => s.uid !== uid));
return (
<div className="space-y-5">
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<FileText className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.dropTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.dropHint")}
</p>
<div className="mt-3">
<input
ref={fileRef}
type="file"
multiple
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.html,.htm,.rtf"
onChange={(e) => {
addFiles(e.currentTarget.files);
if (fileRef.current) fileRef.current.value = "";
}}
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.sources.uploadFiles")}
</Button>
</div>
</div>
<div className="rounded-md border-dashed border-2 px-4 py-6 text-center bg-muted/20">
<Library className="mx-auto h-6 w-6 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
{t("coursePlan.sources.libraryPickTitle")}
</p>
<p className="text-xs text-muted-foreground">
{t("coursePlan.sources.libraryPickHint")}
</p>
<div className="mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setLibraryOpen(true)}
>
<Library className="mr-1 h-4 w-4" />
{t("coursePlan.sources.libraryPickButton")}
</Button>
</div>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label>{t("coursePlan.sources.urlLabel")}</Label>
<div className="mt-1 flex gap-2">
<Input
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
placeholder="https://example.com/article"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
addUrl();
}
}}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addUrl}
disabled={!draftUrl.trim()}
>
<Link2 className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<Label>{t("coursePlan.sources.textLabel")}</Label>
<div className="mt-1 flex gap-2">
<Textarea
rows={3}
value={draftText}
onChange={(e) => setDraftText(e.target.value)}
placeholder={t("coursePlan.sources.textPlaceholder")}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={addText}
disabled={!draftText.trim()}
className="self-start"
>
<FileText className="h-4 w-4" />
</Button>
</div>
</div>
</div>
{sources.length > 0 && (
<div className="rounded-md border">
<div className="px-3 py-2 border-b bg-muted/30 text-xs uppercase tracking-wide text-muted-foreground">
{t("coursePlan.sources.collected", { count: sources.length })}
</div>
<ul className="divide-y">
{sources.map((s) => (
<li
key={s.uid}
className="flex items-center gap-2 px-3 py-2 text-sm"
>
<Badge variant="outline" className="capitalize text-[10px]">
{s.kind === "resource"
? s.resourceType || "library"
: s.kind}
</Badge>
{s.kind === "resource" && (
<Badge
variant="secondary"
className="gap-1 text-[10px] flex items-center"
>
<Library className="h-3 w-3" />
{t("coursePlan.sources.fromLibrary")}
</Badge>
)}
<span className="flex-1 truncate">{s.name}</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => remove(s.uid)}
aria-label={t("common.remove", "Remove")}
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
</div>
)}
<LibraryPickerDialog
open={libraryOpen}
onOpenChange={setLibraryOpen}
alreadyLinkedIds={queuedResourceIds}
onConfirm={addLibraryResources}
/>
</div>
);
}
function MediaStep({
media,
onChange,
}: {
media: MediaToggleState;
onChange: (next: MediaToggleState) => void;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-3 sm:grid-cols-3">
<MediaToggleCard
icon={Music}
title={t("coursePlan.media.audioTitle")}
description={t("coursePlan.media.audioDesc")}
checked={media.audio}
onToggle={(v) => onChange({ ...media, audio: v })}
/>
<MediaToggleCard
icon={ImageIcon}
title={t("coursePlan.media.imageTitle")}
description={t("coursePlan.media.imageDesc")}
checked={media.image}
onToggle={(v) => onChange({ ...media, image: v })}
/>
<MediaToggleCard
icon={Video}
title={t("coursePlan.media.videoTitle")}
description={t("coursePlan.media.videoDesc")}
checked={media.video}
onToggle={(v) => onChange({ ...media, video: v })}
/>
<div className="sm:col-span-3 text-xs text-muted-foreground flex items-center gap-2">
<Mic className="h-3.5 w-3.5" />
{t("coursePlan.media.hint")}
</div>
</div>
);
}
function MediaToggleCard({
icon: Icon,
title,
description,
checked,
onToggle,
}: {
icon: typeof Music;
title: string;
description: string;
checked: boolean;
onToggle: (v: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onToggle(!checked)}
className={[
"rounded-md border p-3 text-left transition-colors",
checked
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
>
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary" />
<div className="font-medium text-sm">{title}</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{description}</p>
</button>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) { function ReviewRow({ label, value }: { label: string; value: string }) {
return ( return (
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">

View File

@@ -40,10 +40,23 @@ function buildAnswerMap(sections: ExamSessionSection[]) {
const map = new Map<number, ExamAnswer>(); const map = new Map<number, ExamAnswer>();
for (const sec of sections) { for (const sec of sections) {
for (const q of sec.questions) { for (const q of sec.questions) {
// The backend `/api/exam/<id>/session` endpoint returns a
// `saved_answer` per question whenever a previous attempt is
// resumed (browser refresh, network drop, accidental tab-close).
// Seed the local map from that value so the student picks up
// exactly where they left off — without this, autosaved answers
// were silently dropped on every reload.
const saved = (q as unknown as { saved_answer?: unknown }).saved_answer;
const flagged = Boolean(
(q as unknown as { flagged?: boolean }).flagged,
);
map.set(q.id, { map.set(q.id, {
question_id: q.id, question_id: q.id,
answer: null, answer:
flagged: false, saved === undefined || saved === null
? null
: (saved as ExamAnswer["answer"]),
flagged,
time_spent_ms: 0, time_spent_ms: 0,
}); });
} }

View File

@@ -0,0 +1,67 @@
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import PlanReader from "@/components/coursePlan/PlanReader";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student detail view for an assigned AI course plan.
*
* Server-side authorisation: the `/api/student/course-plans/:id` endpoint
* returns 403 unless the current user is in the plan's assignment list,
* so we can render unconditionally as soon as the query resolves.
*
* Rendering is delegated to the shared `<PlanReader>` so the admin
* "View as Student" preview matches the real student experience byte-for-byte.
*/
export default function StudentCoursePlanDetail() {
const { t } = useTranslation();
const { planId: planIdRaw } = useParams<{ planId: string }>();
const planId = Number(planIdRaw);
const { data, isLoading, isError, error } = useQuery({
queryKey: ["student-course-plan", planId],
queryFn: () => coursePlanService.studentGet(planId),
enabled: Number.isFinite(planId) && planId > 0,
});
const plan = data?.data as CoursePlan | undefined;
return (
<div className="space-y-6">
<Button
variant="ghost"
size="sm"
asChild
className="-ml-2 text-muted-foreground"
>
<Link to="/student/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.student.backToList")}
</Link>
</Button>
{isLoading && (
<div className="space-y-3">
<Skeleton className="h-20" />
<Skeleton className="h-40" />
<Skeleton className="h-40" />
</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>
)}
{plan && <PlanReader plan={plan} mode="student" />}
</div>
);
}

View File

@@ -0,0 +1,147 @@
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>
);
}

View File

@@ -2,22 +2,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react"; import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play, Sparkles } from "lucide-react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries"; import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach"; import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner"; import AiTipBanner from "@/components/ai/AiTipBanner";
import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() { export default function StudentDashboard() {
const { user } = useAuth(); const { user } = useAuth();
const { t } = useTranslation(); const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses(); const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades(); const { data: gradesData, isLoading: lg } = useGrades();
const { data: planData, isLoading: lp } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const myCourses = enrolledData?.items ?? []; const myCourses = enrolledData?.items ?? [];
const myPlans = planData?.items ?? [];
const gradeRecords = gradesData ?? []; const gradeRecords = gradesData ?? [];
if (lc || lg) 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 (lc || lg || lp) 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 recentGrades = gradeRecords.slice(0, 3); const recentGrades = gradeRecords.slice(0, 3);
const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0; const avgProgress = myCourses.length > 0 ? Math.round(myCourses.reduce((s, c) => s + c.progress, 0) / myCourses.length) : 0;
@@ -27,7 +34,7 @@ export default function StudentDashboard() {
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback"); const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
const stats = [ const stats = [
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" }, { label: t("studentDash.enrolledCourses"), value: String(myCourses.length + myPlans.length), icon: BookOpen, color: "text-primary" },
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" }, { label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" }, { label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" }, { label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
@@ -93,6 +100,42 @@ export default function StudentDashboard() {
</Card> </Card>
<div className="space-y-6"> <div className="space-y-6">
<Card>
<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" />
{t("studentDash.myCoursePlans")}
</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/student/course-plans">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{myPlans.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noCoursePlans")}</p>
) : myPlans.slice(0, 4).map((p) => (
<Link to={`/student/course-plans/${p.id}`} key={p.id} className="block">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors gap-3">
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate" dir="auto">{p.name}</p>
<p className="text-xs text-muted-foreground truncate">
{t("coursePlan.weeksCount", { count: p.total_weeks })} ·{" "}
{p.assignment?.due_date
? t("coursePlan.student.due", { date: p.assignment.due_date })
: t("coursePlan.student.noDue")}
</p>
</div>
<Badge variant="secondary" className="uppercase shrink-0">
{p.cefr_level}
</Badge>
</div>
</Link>
))}
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle> <CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>

View File

@@ -440,9 +440,9 @@ function ResourceTable({
<> <>
<Button variant="ghost" size="icon" title="Download" onClick={async () => { <Button variant="ghost" size="icon" title="Download" onClick={async () => {
try { try {
const blob = await resourcesService.download(row.resourceId!); const { blob, filename } = await resourcesService.download(row.resourceId!);
const url = URL.createObjectURL(blob); const a = document.createElement("a"); const url = URL.createObjectURL(blob); const a = document.createElement("a");
a.href = url; a.download = row.name || "resource"; a.click(); URL.revokeObjectURL(url); a.href = url; a.download = filename || row.name || "resource"; a.click(); URL.revokeObjectURL(url);
} catch { toast({ title: "Download failed", variant: "destructive" }); } } catch { toast({ title: "Download failed", variant: "destructive" }); }
}}> }}>
<Download className="h-4 w-4" /> <Download className="h-4 w-4" />

View File

@@ -0,0 +1,82 @@
import { api } from "@/lib/api-client";
/**
* AI provider & API-key settings client.
*
* Backed by `backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py`
* (`/api/ai/settings/providers`). API keys are write-only — the GET response
* only ever exposes `<key>_set: boolean` markers, never the key itself.
*
* Provider switches take effect on the very next request (no caching),
* so the LangGraph runtime instantly picks up the new selection.
*/
export type CapabilityKey = "text" | "image" | "audio" | "video";
export type ProviderKind = "paid" | "free" | "auto";
export interface ProviderOption {
value: string;
label: string;
kind: ProviderKind;
}
export interface CapabilityState {
active: string;
options: ProviderOption[];
paid_with_credentials: string[];
}
export interface AISettingsState {
providers: Record<CapabilityKey, CapabilityState>;
/** Boolean markers — `true` means a value is stored, never the value itself. */
keys_set: Record<string, boolean>;
/** Plain (non-secret) parameters, e.g. region, model name. */
plain: Record<string, string>;
}
export interface ProviderTestResult {
capability: CapabilityKey;
active: string;
chain: { provider: string; ok: boolean; note: string }[];
}
export interface AISettingsPatchPayload {
/** Active provider per capability. */
providers?: Partial<Record<CapabilityKey, string>>;
/**
* API keys keyed by short name (`openai_api_key`, `aws_access_key`,
* `aws_secret_key`, `aws_region`, `elevenlabs_api_key`,
* `gptzero_api_key`, `paymob_api_key`, `paymob_integration_id`,
* `paymob_iframe_id`, `paymob_hmac_secret`).
*
* - Sending an empty string clears the key.
* - Omitting the field leaves it unchanged.
*/
keys?: Record<string, string>;
/** Plain (non-secret) values like `aws_region`, `openai_model`. */
plain?: Record<string, string>;
}
export const aiSettingsService = {
async get(): Promise<AISettingsState> {
const resp = await api.get<{ data: AISettingsState }>(
"/ai/settings/providers",
);
return resp.data;
},
async update(payload: AISettingsPatchPayload): Promise<AISettingsState> {
const resp = await api.patch<{ data: AISettingsState }>(
"/ai/settings/providers",
payload,
);
return resp.data;
},
async test(capability: CapabilityKey): Promise<ProviderTestResult> {
return api.post<ProviderTestResult>(
"/ai/settings/providers/test",
{ capability },
);
},
};

View File

@@ -1,36 +1,127 @@
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { Classroom, ClassroomCreateRequest, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types"; import { asPaginated, asRecordData } from "@/lib/odoo-api";
import type {
Classroom,
ClassroomAssignCourseRequest,
ClassroomBatchSummary,
ClassroomCourse,
ClassroomCreateRequest,
ClassroomMember,
ClassroomSection,
ClassroomStudent,
PaginatedResponse,
PaginationParams,
ApiSuccessResponse,
} from "@/types";
export interface ClassroomListParams extends PaginationParams { export interface ClassroomListParams extends PaginationParams {
entity_id?: number; entity_id?: number;
branch_id?: number;
search?: string;
active?: boolean;
} }
export const classroomsService = { export const classroomsService = {
async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> { async list(params?: ClassroomListParams): Promise<PaginatedResponse<Classroom>> {
return api.get<PaginatedResponse<Classroom>>("/groups", params as Record<string, string | number | boolean | undefined>); const raw = await api.get<unknown>(
"/classrooms",
params as Record<string, string | number | boolean | undefined>,
);
return asPaginated<Classroom>(raw);
}, },
async getById(id: number): Promise<Classroom> { async getById(id: number): Promise<Classroom> {
return api.get<Classroom>(`/groups/${id}`); const raw = await api.get<unknown>(`/classrooms/${id}`);
return asRecordData<Classroom>(raw);
}, },
async create(data: ClassroomCreateRequest): Promise<Classroom> { async create(data: ClassroomCreateRequest): Promise<Classroom> {
return api.post<Classroom>("/groups", data); const raw = await api.post<unknown>("/classrooms", data);
return asRecordData<Classroom>(raw);
},
async update(id: number, data: Partial<ClassroomCreateRequest>): Promise<Classroom> {
const raw = await api.patch<unknown>(`/classrooms/${id}`, data);
return asRecordData<Classroom>(raw);
}, },
async delete(id: number): Promise<ApiSuccessResponse> { async delete(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/groups/${id}`); return api.delete<ApiSuccessResponse>(`/classrooms/${id}`);
}, },
async transfer(data: { student_ids: number[]; from_id: number; to_id: number }): Promise<ApiSuccessResponse> { // ---- Roster ----------------------------------------------------------
return api.post<ApiSuccessResponse>("/groups/transfer", data); async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
return api.get(`/classrooms/${id}/students`);
}, },
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> { /** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds }); async setStudents(
id: number,
studentIds: number[],
mode: "set" | "add" = "set",
): Promise<Classroom> {
const raw = await api.post<unknown>(`/classrooms/${id}/students`, {
student_ids: studentIds,
mode,
});
return asRecordData<Classroom>(raw);
}, },
async removeMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> { async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds }); const raw = await api.delete<unknown>(`/classrooms/${id}/students`, {
student_ids: studentIds,
});
return asRecordData<Classroom>(raw);
},
// ---- Homeroom teachers ----------------------------------------------
async listTeachers(id: number): Promise<{ items: ClassroomMember[]; total: number }> {
return api.get(`/classrooms/${id}/teachers`);
},
async setTeachers(
id: number,
teacherIds: number[],
mode: "set" | "add" = "set",
): Promise<Classroom> {
const raw = await api.post<unknown>(`/classrooms/${id}/teachers`, {
teacher_ids: teacherIds,
mode,
});
return asRecordData<Classroom>(raw);
},
// ---- Courses + cascade ----------------------------------------------
async listCourses(id: number): Promise<{ items: ClassroomCourse[]; total: number }> {
return api.get(`/classrooms/${id}/courses`);
},
async listSections(
id: number,
params?: { course_id?: number; active?: boolean },
): Promise<{ items: ClassroomSection[]; total: number }> {
return api.get(
`/classrooms/${id}/sections`,
params as Record<string, string | number | boolean | undefined>,
);
},
/** Assign a course → server creates (or reuses) the canonical batch and
* enrolls every classroom student into it. */
async assignCourse(
id: number,
payload: ClassroomAssignCourseRequest,
): Promise<{ data: Classroom; batch: ClassroomBatchSummary }> {
return api.post(`/classrooms/${id}/assign-course`, payload);
},
async detachCourse(id: number, courseId: number): Promise<Classroom> {
const raw = await api.delete<unknown>(`/classrooms/${id}/courses/${courseId}`);
return asRecordData<Classroom>(raw);
},
// ---- Batches --------------------------------------------------------
async listBatches(id: number): Promise<{ items: ClassroomBatchSummary[]; total: number }> {
return api.get(`/classrooms/${id}/batches`);
}, },
}; };

View File

@@ -1,8 +1,16 @@
import { api } from "@/lib/api-client"; import { api } from "@/lib/api-client";
import type { import type {
CoursePlan, CoursePlan,
CoursePlanAssignment,
CoursePlanDeliverables,
CoursePlanGenerateBrief, CoursePlanGenerateBrief,
CoursePlanMaterial, CoursePlanMaterial,
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
WorkbookAttempt,
WorkbookExtractedFrom,
WorkbookGroundedSource,
} from "@/types"; } from "@/types";
/** /**
@@ -12,6 +20,9 @@ import type {
* `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`. * `backend/custom_addons/encoach_ai_course/controllers/course_plan.py`.
*/ */
export const coursePlanService = { export const coursePlanService = {
// ---------------------------------------------------------------------
// Plan lifecycle
// ---------------------------------------------------------------------
async list(params?: { async list(params?: {
page?: number; page?: number;
size?: number; size?: number;
@@ -45,4 +56,318 @@ export const coursePlanService = {
): Promise<{ items: CoursePlanMaterial[]; count: number }> { ): Promise<{ items: CoursePlanMaterial[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`); return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
}, },
/**
* RAG-grounded richer-schema generator. Requires at least one
* indexed source on the plan; backend returns 409 otherwise so the
* UI can prompt for upload.
*/
async generateWeekMaterialsV2(
planId: number,
weekNumber: number,
): Promise<{
items: CoursePlanMaterial[];
count: number;
rag: { enabled: boolean; sources_used: number[] };
}> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/materials/v2`,
);
},
/** Mine indexed PDFs for original printed exercises. */
async extractWorkbooks(
planId: number,
payload?: { max_batches?: number },
): Promise<{
data: {
materials_created: number;
exercises_total: number;
batches_run: number;
skipped: number;
reason?: string;
};
plan_id: number;
}> {
return api.post(
`/ai/course-plan/${planId}/extract-workbooks`,
payload ?? {},
);
},
/** Source citations for one material — shown as the
* "Grounded on N references" badge popover. */
async materialGrounding(materialId: number): Promise<{
material_id: number;
grounded_on: WorkbookGroundedSource[];
extracted_from: WorkbookExtractedFrom | null;
sources: CoursePlanSource[];
}> {
return api.get(`/ai/course-plan/material/${materialId}/grounding`);
},
async updateMaterial(
materialId: number,
payload: {
title?: string;
summary?: string;
body?: Record<string, unknown>;
body_text?: string;
share_date?: string | null;
is_static?: boolean;
},
): Promise<{ data: CoursePlanMaterial }> {
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
},
// ---------------------------------------------------------------------
// Phase A — Sources
// ---------------------------------------------------------------------
async listSources(planId: number): Promise<{ items: CoursePlanSource[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/sources`);
},
async createSource(
planId: number,
payload:
| { kind: "url"; url: string; name?: string; auto_index?: boolean }
| { kind: "text"; inline_text: string; name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
return api.post(`/ai/course-plan/${planId}/sources`, payload);
},
async uploadSource(
planId: number,
file: File,
opts?: { name?: string; auto_index?: boolean },
): Promise<{ data: CoursePlanSource }> {
const fd = new FormData();
fd.append("file", file);
fd.append("kind", "file" satisfies CoursePlanSourceKind);
if (opts?.name) fd.append("name", opts.name);
if (opts?.auto_index !== undefined) {
fd.append("auto_index", opts.auto_index ? "1" : "0");
}
return api.upload(`/ai/course-plan/${planId}/sources`, fd);
},
async reindexSource(
planId: number,
sourceId: number,
): Promise<{ data: CoursePlanSource }> {
return api.post(
`/ai/course-plan/${planId}/sources/${sourceId}/index`,
);
},
async deleteSource(
planId: number,
sourceId: number,
): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
},
/**
* Attach existing items from /admin/resources to a course plan as RAG
* sources. Returns the rows that were newly attached plus the ids
* that were skipped (already linked) or missing (deleted from the
* library) so the caller can show a clear toast — e.g. "Attached 2,
* skipped 1 already-linked".
*/
async attachResources(
planId: number,
resourceIds: number[],
): Promise<{
attached: CoursePlanSource[];
skipped_existing: number[];
missing: number[];
count: number;
}> {
return api.post(
`/ai/course-plan/${planId}/sources/from-resources`,
{ resource_ids: resourceIds },
);
},
// ---------------------------------------------------------------------
// Phase B — Deliverables / progress
// ---------------------------------------------------------------------
async deliverables(planId: number): Promise<CoursePlanDeliverables> {
return api.get(`/ai/course-plan/${planId}/deliverables`);
},
// ---------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------
async listMaterialMedia(
materialId: number,
): Promise<{ items: CoursePlanMedia[]; count: number }> {
return api.get(`/ai/course-plan/material/${materialId}/media`);
},
async generateAudio(
materialId: number,
payload?: {
voice?: string;
language?: string;
gender?: "male" | "female";
provider?: "polly" | "elevenlabs";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/audio`,
payload ?? {},
);
},
async generateImage(
materialId: number,
payload?: {
prompt?: string;
size?: "1024x1024" | "1792x1024" | "1024x1792";
style?: "natural" | "vivid";
quality?: "standard" | "hd";
},
): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/image`,
payload ?? {},
);
},
async composeVideo(materialId: number): Promise<{ data: CoursePlanMedia }> {
return api.post(
`/ai/course-plan/material/${materialId}/media/video`,
);
},
async deleteMedia(mediaId: number): Promise<{ success: boolean }> {
return api.delete(`/ai/course-plan/media/${mediaId}`);
},
/**
* Attach an admin-supplied media file (audio / image / video) to a
* course-plan material. The backend tags the resulting row with
* `provider='manual'` so the drawer can distinguish hand-curated
* clips from AI-generated ones — and admins can swap a robotic
* Polly recording for a real human voiceover whenever they want.
*/
async uploadMedia(
materialId: number,
kind: "audio" | "image" | "video",
file: File,
title?: string,
): Promise<{ data: CoursePlanMedia }> {
const fd = new FormData();
fd.append("kind", kind);
fd.append("file", file);
if (title) fd.append("title", title);
return api.upload(
`/ai/course-plan/material/${materialId}/media/upload`,
fd,
);
},
async generateWeekMedia(
planId: number,
weekNumber: number,
payload?: { kinds?: Array<"audio" | "image" | "video"> },
): Promise<{ items: Array<CoursePlanMedia | { error: string; material_id: number }>; count: number }> {
return api.post(
`/ai/course-plan/${planId}/weeks/${weekNumber}/media`,
payload ?? {},
);
},
// ---------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------
async listAssignments(
planId: number,
): Promise<{ items: CoursePlanAssignment[]; count: number }> {
return api.get(`/ai/course-plan/${planId}/assignments`);
},
async createAssignment(
planId: number,
payload:
| {
mode: "batch";
batch_id: number;
due_date?: string | null;
message?: string;
}
| {
mode: "students";
student_user_ids: number[];
due_date?: string | null;
message?: string;
}
| {
mode: "entities";
entity_ids: number[];
due_date?: string | null;
message?: string;
},
): Promise<{ data: CoursePlanAssignment }> {
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
},
async deleteAssignment(
planId: number,
assignmentId: number,
): Promise<{ success: boolean }> {
return api.delete(
`/ai/course-plan/${planId}/assignments/${assignmentId}`,
);
},
// ---------------------------------------------------------------------
// Phase E — Student-side
// ---------------------------------------------------------------------
async studentList(): Promise<{ items: CoursePlan[]; count: number }> {
return api.get("/student/course-plans");
},
async studentGet(planId: number): Promise<{ data: CoursePlan }> {
return api.get(`/student/course-plans/${planId}`);
},
// ---------------------------------------------------------------------
// Phase F — Interactive workbook attempts
// ---------------------------------------------------------------------
/** Save (or finalize) the current student's answers and return the
* freshly-graded score. Used by InteractiveWorkbook.tsx on every
* "Check answers" click (debounced) and on "Submit final". */
async saveWorkbookAttempt(
planId: number,
materialId: number,
payload: { answers: Record<string, unknown>; finalize?: boolean },
): Promise<{ data: WorkbookAttempt }> {
return api.post(
`/student/course-plans/${planId}/materials/${materialId}/attempts`,
payload,
);
},
/** Latest attempt the current user owns on this material — null when
* the student hasn't started yet. */
async myWorkbookAttempt(
planId: number,
materialId: number,
): Promise<{ data: WorkbookAttempt | null }> {
return api.get(
`/student/course-plans/${planId}/materials/${materialId}/attempts/me`,
);
},
/** Teacher-side roster of latest attempts per student. */
async listMaterialAttempts(
planId: number,
materialId: number,
): Promise<{ items: WorkbookAttempt[]; count: number }> {
return api.get(
`/ai/course-plan/${planId}/materials/${materialId}/attempts`,
);
},
}; };

View File

@@ -2,6 +2,14 @@ import { api } from "@/lib/api-client";
import { asPaginated, asRecordData } from "@/lib/odoo-api"; import { asPaginated, asRecordData } from "@/lib/odoo-api";
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types"; import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
export interface EntityUser {
id: number;
name: string;
login: string;
email: string;
active: boolean;
}
export const entitiesService = { export const entitiesService = {
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> { async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>); const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
@@ -42,4 +50,20 @@ export const entitiesService = {
async getPermissions(entityId: number): Promise<string[]> { async getPermissions(entityId: number): Promise<string[]> {
return api.get<string[]>(`/permissions`, { entity_id: entityId }); return api.get<string[]>(`/permissions`, { entity_id: entityId });
}, },
async listEntityUsers(entityId: number): Promise<EntityUser[]> {
const raw = await api.get<unknown>(`/entities/${entityId}/users`);
const out = asPaginated<EntityUser>(raw);
return out.items;
},
async updateEntityUsers(entityId: number, userIds: number[]): Promise<ApiSuccessResponse> {
return api.patch<ApiSuccessResponse>(`/entities/${entityId}/users`, { user_ids: userIds });
},
async listPlatformUsers(params?: PaginationParams): Promise<EntityUser[]> {
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
const out = asPaginated<EntityUser>(raw);
return out.items;
},
}; };

View File

@@ -18,6 +18,8 @@ import type {
MyEnrolledCourse, MyEnrolledCourse,
EnrollStudentRequest, EnrollStudentRequest,
BulkEnrollRequest, BulkEnrollRequest,
LmsBranch,
CourseSection,
} from "@/types"; } from "@/types";
function mapCourseRaw(r: Record<string, unknown>): Course { function mapCourseRaw(r: Record<string, unknown>): Course {
@@ -53,6 +55,12 @@ function mapCourseRaw(r: Record<string, unknown>): Course {
chapter_count: Number(r.chapter_count ?? 0), chapter_count: Number(r.chapter_count ?? 0),
resource_count: Number(r.resource_count ?? 0), resource_count: Number(r.resource_count ?? 0),
objective_count: Number(r.objective_count ?? 0), objective_count: Number(r.objective_count ?? 0),
section_count: Number(r.section_count ?? 0),
sections: Array.isArray(r.sections)
? (r.sections as Record<string, unknown>[]).map(mapSectionRaw)
: [],
branch_id: (r.branch_id as number | null) ?? null,
branch_name: (r.branch_name as string) || "",
}; };
} }
@@ -63,13 +71,17 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
active: "active", active: "active",
completed: "completed", completed: "completed",
}; };
const teacherIds = (r.teacher_ids as number[]) || [];
const teacherNames = (r.teacher_names as string[]) || [];
return { return {
id: r.id as number, id: r.id as number,
name: String(r.name || ""), name: String(r.name || ""),
course_id: Number(r.course_id || 0), course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""), course_name: String(r.course_name || ""),
teacher_id: 0, teacher_id: teacherIds[0] || 0,
teacher_name: "", teacher_name: teacherNames[0] || "",
teacher_ids: teacherIds,
teacher_names: teacherNames,
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id), student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
student_count: Number(r.student_count ?? 0), student_count: Number(r.student_count ?? 0),
start_date: String(r.start_date || ""), start_date: String(r.start_date || ""),
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
schedule: "", schedule: "",
status: statusMap[st] || "upcoming", status: statusMap[st] || "upcoming",
capacity: Number(r.max_students ?? 0), capacity: Number(r.max_students ?? 0),
branch_id: (r.branch_id as number | null) ?? null,
branch_name: (r.branch_name as string) || "",
classroom_id: (r.classroom_id as number | null) ?? null,
classroom_name: (r.classroom_name as string) || "",
course_section_id: (r.course_section_id as number | null) ?? null,
course_section_name: (r.course_section_name as string) || "",
course_section_code: (r.course_section_code as string) || "",
term_key: (r.term_key as string) || "",
};
}
function mapSectionRaw(r: Record<string, unknown>): CourseSection {
return {
id: Number(r.id || 0),
name: String(r.name || ""),
code: String(r.code || ""),
sequence: Number(r.sequence || 0),
active: Boolean(r.active ?? true),
notes: String(r.notes || ""),
course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""),
entity_id: (r.entity_id as number | null) ?? null,
entity_name: String(r.entity_name || ""),
branch_id: (r.branch_id as number | null) ?? null,
branch_name: String(r.branch_name || ""),
batch_count: Number(r.batch_count || 0),
}; };
} }
@@ -136,6 +174,25 @@ export const lmsService = {
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages }; return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
}, },
async listBranches(params?: PaginationParams & { search?: string; entity_id?: number; active?: boolean }): Promise<PaginatedResponse<LmsBranch>> {
const raw = await api.get<unknown>("/branches", params as Record<string, string | number | boolean | undefined>);
return asPaginated<LmsBranch>(raw);
},
async createBranch(data: Partial<LmsBranch>): Promise<LmsBranch> {
const raw = await api.post<unknown>("/branches", data);
return asRecordData<LmsBranch>(raw);
},
async updateBranch(id: number, data: Partial<LmsBranch>): Promise<LmsBranch> {
const raw = await api.patch<unknown>(`/branches/${id}`, data);
return asRecordData<LmsBranch>(raw);
},
async deleteBranch(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/branches/${id}`);
},
async getCourse(id: number): Promise<Course> { async getCourse(id: number): Promise<Course> {
const raw = await api.get<unknown>(`/courses/${id}`); const raw = await api.get<unknown>(`/courses/${id}`);
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw)); return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
@@ -179,6 +236,51 @@ export const lmsService = {
return api.delete<ApiSuccessResponse>(`/courses/${id}`); return api.delete<ApiSuccessResponse>(`/courses/${id}`);
}, },
async listCourseSections(courseId: number): Promise<{ items: CourseSection[]; total: number }> {
const raw = await api.get<unknown>(`/courses/${courseId}/sections`);
const p = asPaginated<Record<string, unknown>>(raw);
return { ...p, items: p.items.map(mapSectionRaw) };
},
async createCourseSection(
courseId: number,
data: { name: string; code: string; sequence?: number; active?: boolean; notes?: string; branch_id?: number | null },
): Promise<CourseSection> {
const raw = await api.post<unknown>(`/courses/${courseId}/sections`, data);
return asRecordData<CourseSection>(raw);
},
async updateCourseSection(
courseId: number,
sectionId: number,
data: Partial<{ name: string; code: string; sequence: number; active: boolean; notes: string; branch_id: number | null }>,
): Promise<CourseSection> {
const raw = await api.patch<unknown>(`/courses/${courseId}/sections/${sectionId}`, data);
return asRecordData<CourseSection>(raw);
},
async deleteCourseSection(courseId: number, sectionId: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/courses/${courseId}/sections/${sectionId}`);
},
async generateDefaultCourseSections(
courseId: number,
data?: { codes?: string[]; branch_id?: number | null },
): Promise<{ items: CourseSection[]; created_count: number; created_ids: number[]; total: number }> {
const raw = await api.post<{
items: Record<string, unknown>[];
created_count: number;
created_ids: number[];
total: number;
}>(`/courses/${courseId}/sections/generate-defaults`, data ?? {});
return {
created_count: raw.created_count ?? 0,
created_ids: raw.created_ids ?? [],
total: raw.total ?? 0,
items: (raw.items ?? []).map(mapSectionRaw),
};
},
async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> { async aiGenerateCourse(data: { title: string; subject_id?: number; level?: string }): Promise<{ outline: unknown }> {
return api.post("/courses/ai-generate", data); return api.post("/courses/ai-generate", data);
}, },
@@ -255,6 +357,25 @@ export const lmsService = {
return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds }); return api.post(`/batches/${batchId}/students/remove`, { student_ids: studentIds });
}, },
async getBatchTeachers(batchId: number): Promise<{ items: { id: number; name: string; email: string }[]; total: number }> {
return api.get(`/batches/${batchId}/teachers`);
},
async setBatchTeachers(
batchId: number,
teacherIds: number[],
mode: "set" | "add" = "set",
): Promise<{ success: boolean; teacher_ids: number[] }> {
return api.post(`/batches/${batchId}/teachers`, { teacher_ids: teacherIds, mode });
},
async removeBatchTeachers(
batchId: number,
teacherIds: number[],
): Promise<{ success: boolean; teacher_ids: number[] }> {
return api.post(`/batches/${batchId}/teachers/remove`, { teacher_ids: teacherIds });
},
async deleteStudent(id: number): Promise<ApiSuccessResponse> { async deleteStudent(id: number): Promise<ApiSuccessResponse> {
return api.delete<ApiSuccessResponse>(`/students/${id}`); return api.delete<ApiSuccessResponse>(`/students/${id}`);
}, },

View File

@@ -42,12 +42,27 @@ export const resourcesService = {
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating }); return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
}, },
async download(id: number): Promise<Blob> { /**
* Downloads the binary and returns the blob alongside the filename
* the server suggested via ``Content-Disposition``. Callers should
* prefer that filename over the human ``name`` on the record so the
* extension is preserved (".pdf" / ".mp3" / ".png" etc.) — the
* legacy code dropped the extension because the human name was just
* "test".
*/
async download(id: number): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, { const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` }, headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
}); });
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
return res.blob(); const cd = res.headers.get("content-disposition") || "";
let filename = "";
// Match either ``filename*=UTF-8''…`` (RFC 5987) or ``filename="…"``
const match =
/filename\*\s*=\s*[^']*''([^;]+)/i.exec(cd) ||
/filename\s*=\s*"?([^";]+)"?/i.exec(cd);
if (match) filename = decodeURIComponent(match[1].trim());
return { blob: await res.blob(), filename };
}, },
// Tag management // Tag management

View File

@@ -61,8 +61,24 @@ export interface Resource {
id: number; id: number;
name: string; name: string;
type?: string; type?: string;
resource_type: "pdf" | "video" | "link" | "document" | "interactive"; resource_type:
| "pdf"
| "video"
| "link"
| "document"
| "interactive"
| "audio"
| "image"
| "article";
url?: string; url?: string;
/** Authenticated REST URL for forced download (Content-Disposition: attachment). */
download_url?: string;
/** Authenticated REST URL for inline preview (Content-Disposition: inline). */
preview_url?: string;
/** Cached MIME type from upload — drives the right preview widget. */
mimetype?: string;
/** Filename as uploaded, including extension. */
original_filename?: string;
file_name?: string; file_name?: string;
subject_id?: number | null; subject_id?: number | null;
subject_name?: string; subject_name?: string;

View File

@@ -1,24 +1,98 @@
/** Classroom = homeroom (physical room repurposed as a class group).
*
* Owns:
* - a roster of students (M2M)
* - a set of homeroom teachers (M2M)
* - a list of courses taught here (M2M) — assigning a course cascades
* into a batch + auto-enrollment of every roster student.
* - the resulting batches (one per (classroom × course))
*/
export interface Classroom { export interface Classroom {
id: number; id: number;
name: string; name: string;
admin_id: number; code: string;
admin_name: string; capacity: number;
entity_id: number; active: boolean;
notes?: string;
entity_id: number | null;
entity_name: string; entity_name: string;
participant_count: number; branch_id?: number | null;
participants: ClassroomMember[]; branch_name?: string;
student_count: number;
teacher_count: number;
course_count: number;
batch_count: number;
student_ids: number[];
teacher_ids: number[];
course_ids: number[];
batch_ids?: number[];
/** Only populated by GET /api/classrooms/<id> (full payload). */
students?: ClassroomStudent[];
teachers?: ClassroomMember[];
courses?: ClassroomCourse[];
sections?: ClassroomSection[];
batches?: ClassroomBatchSummary[];
} }
export interface ClassroomMember { export interface ClassroomMember {
id: number; id: number;
name: string; name: string;
email: string; email: string;
user_type: string; }
export interface ClassroomStudent extends ClassroomMember {
gr_no?: string;
}
export interface ClassroomCourse {
id: number;
name: string;
code: string;
}
export interface ClassroomSection {
id: number;
name: string;
code: string;
sequence: number;
course_id: number | null;
course_name: string;
}
export interface ClassroomBatchSummary {
id: number;
name: string;
code: string;
course_id: number | null;
course_name: string;
course_section_id?: number | null;
course_section_name?: string;
course_section_code?: string;
term_key?: string;
start_date: string;
end_date: string;
student_count: number;
teacher_ids: number[];
} }
export interface ClassroomCreateRequest { export interface ClassroomCreateRequest {
name: string; name: string;
entity_id: number; code?: string;
admin_id: number; capacity?: number;
participant_ids?: number[]; notes?: string;
entity_id?: number;
branch_id?: number | null;
student_ids?: number[];
teacher_ids?: number[];
active?: boolean;
}
export interface ClassroomAssignCourseRequest {
course_id: number;
section_id?: number;
teacher_ids?: number[];
term_key?: string;
term_name?: string;
start_date?: string;
end_date?: string;
} }

View File

@@ -20,8 +20,129 @@ export type CoursePlanMaterialType =
| "grammar_lesson" | "grammar_lesson"
| "vocabulary_list" | "vocabulary_list"
| "practice" | "practice"
| "interactive_workbook"
| "other"; | "other";
// ---------------------------------------------------------------------------
// Interactive workbooks — schema mirrors backend `_WEEK_JSON_HINT_V2` and
// `ExerciseExtractor` output. The renderer in `InteractiveWorkbook.tsx`
// is a discriminated-union switch on `type`.
// ---------------------------------------------------------------------------
export type WorkbookExerciseType =
| "gap_fill"
| "multiple_choice"
| "match_pairs"
| "reorder_words"
| "transformation"
| "short_answer";
export interface WorkbookExerciseBase {
id: string;
type: WorkbookExerciseType;
hint?: string;
rationale?: string;
}
export interface GapFillExercise extends WorkbookExerciseBase {
type: "gap_fill";
stem: string;
answer: string;
alt?: string[];
}
export interface MultipleChoiceExercise extends WorkbookExerciseBase {
type: "multiple_choice";
stem: string;
options: string[];
answer: string;
}
export interface MatchPairsExercise extends WorkbookExerciseBase {
type: "match_pairs";
left: string[];
right: string[];
answer: number[][];
}
export interface ReorderWordsExercise extends WorkbookExerciseBase {
type: "reorder_words";
tokens: string[];
answer: string;
}
export interface TransformationExercise extends WorkbookExerciseBase {
type: "transformation";
from: string;
instruction: string;
answer: string;
alt?: string[];
}
export interface ShortAnswerExercise extends WorkbookExerciseBase {
type: "short_answer";
stem: string;
answer: string;
accepted?: string[];
}
export type WorkbookExercise =
| GapFillExercise
| MultipleChoiceExercise
| MatchPairsExercise
| ReorderWordsExercise
| TransformationExercise
| ShortAnswerExercise;
export interface WorkbookLessonPlan {
warmup?: string;
presentation?: string;
controlled_practice?: string;
freer_practice?: string;
exit_ticket?: string;
}
export interface WorkbookBody {
lesson_plan?: WorkbookLessonPlan;
exercises: WorkbookExercise[];
}
export interface WorkbookGroundedSource {
source_id: number;
title: string;
chunks_used: number;
}
export interface WorkbookExtractedFrom {
source_id: number;
source_title?: string;
page_hint?: string;
topic_keywords?: string[];
chunk_indices?: number[];
}
export interface WorkbookAttemptScoreItem {
id: string;
correct: boolean;
expected: unknown;
given: unknown;
}
export interface WorkbookAttemptScore {
items: WorkbookAttemptScoreItem[];
correct: number;
total: number;
percent: number;
}
export interface WorkbookAttempt {
id: number;
material_id: number;
plan_id: number | null;
student_id: number;
student_name: string;
attempt_number: number;
is_final: boolean;
submitted_at: string | null;
last_updated_at: string | null;
correct_count: number;
total_count: number;
percent: number;
answers: Record<string, unknown>;
score: WorkbookAttemptScore | Record<string, never>;
}
export interface CoursePlanOutcome { export interface CoursePlanOutcome {
code: string; code: string;
description: string; description: string;
@@ -77,15 +198,181 @@ export interface CoursePlanMaterial {
skill: string; skill: string;
material_type: CoursePlanMaterialType | string; material_type: CoursePlanMaterialType | string;
title: string; title: string;
is_static?: boolean;
share_date?: string | null;
summary: string; summary: string;
/** Loose shape: depends on material_type. */ /** Loose shape: depends on material_type. */
body: Record<string, unknown>; body: Record<string, unknown>;
body_text: string; body_text: string;
/** Citations for materials produced by the v2/RAG generator. */
grounded_on?: WorkbookGroundedSource[];
/** Provenance for materials mined out of indexed PDFs. */
extracted_from?: WorkbookExtractedFrom | null;
media?: CoursePlanMedia[];
}
// ---------------------------------------------------------------------------
// Phase A — Reference sources
// ---------------------------------------------------------------------------
export type CoursePlanSourceKind = "file" | "url" | "text" | "resource";
export type CoursePlanSourceStatus = "pending" | "indexing" | "indexed" | "failed";
export interface CoursePlanSource {
id: number;
plan_id: number;
name: string;
kind: CoursePlanSourceKind;
file_name: string;
mime_type: string;
url: string;
has_inline_text: boolean;
/** Set when the source is linked to an item from /admin/resources. */
resource_id?: number | null;
resource_name?: string;
resource_type?: string;
status: CoursePlanSourceStatus;
error: string;
chunks_count: number;
extracted_chars: number;
indexed_at: string | null;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase B — Deliverables preview / progress
// ---------------------------------------------------------------------------
export type DeliverableStatus = "planned" | "generated" | "ready";
export interface CoursePlanDeliverable {
skill: string;
material_type: string;
material_id: number | null;
title: string;
status: DeliverableStatus;
media: Array<{
id: number;
kind: "audio" | "image" | "video";
status: string;
provider: string;
}>;
}
export interface CoursePlanDeliverablesWeek {
week_number: number;
date_label: string;
unit: string;
focus: string;
items_total: number;
deliverables: CoursePlanDeliverable[];
}
export interface CoursePlanDeliverables {
summary: {
total: number;
planned: number;
generated: number;
ready: number;
percent_ready: number;
media: {
audio: number;
image: number;
video: number;
audio_ready: number;
image_ready: number;
video_ready: number;
};
};
weeks: CoursePlanDeliverablesWeek[];
}
// ---------------------------------------------------------------------------
// Phase C — Multimedia
// ---------------------------------------------------------------------------
export type CoursePlanMediaKind = "audio" | "image" | "video";
export type CoursePlanMediaStatus = "queued" | "generating" | "ready" | "failed";
export type CoursePlanMediaProvider =
| "polly"
| "elevenlabs"
| "openai_image"
| "ffmpeg"
| "elai"
// Free fallbacks (Phase 24.1)
| "pillow"
| "unsplash"
| "gtts"
| "silent"
| "static"
| "mock"
| "auto"
| "manual";
export interface CoursePlanMedia {
id: number;
plan_id: number;
week_id: number | null;
material_id: number | null;
kind: CoursePlanMediaKind;
provider: CoursePlanMediaProvider | string;
title: string;
voice: string;
language: string;
style: string;
mime_type: string;
size_bytes: number;
duration_seconds: number;
width: number;
height: number;
attachment_id: number | null;
/** REST URL with ``Content-Disposition: attachment`` — used by the
* download buttons. The frontend appends ``?token=<jwt>`` so it works
* from plain ``<a download>`` tags. */
download_url: string;
/** REST URL with ``Content-Disposition: inline`` — used as the ``src``
* for ``<img>`` / ``<audio>`` / ``<video>`` tags. The frontend appends
* ``?token=<jwt>`` because browsers can't attach custom headers to
* these element fetches. */
preview_url?: string;
status: CoursePlanMediaStatus;
error: string;
cost_cents: number;
created_at: string | null;
}
// ---------------------------------------------------------------------------
// Phase D — Assignments
// ---------------------------------------------------------------------------
export type CoursePlanAssignmentMode = "batch" | "students" | "entities";
export type CoursePlanAssignmentState = "active" | "archived";
export interface CoursePlanAssignment {
id: number;
plan_id: number;
plan_name: string;
mode: CoursePlanAssignmentMode;
batch_id: number | null;
batch_name: string;
student_user_ids: number[];
student_user_names: string[];
entity_ids?: number[];
entity_names?: string[];
student_count: number;
assigned_by_id: number | null;
assigned_by_name: string;
due_date: string | null;
message: string;
state: CoursePlanAssignmentState;
created_at: string | null;
} }
export interface CoursePlan { export interface CoursePlan {
id: number; id: number;
name: string; name: string;
entity_id?: number | null;
entity_name?: string;
course_id: number | null; course_id: number | null;
course_name: string; course_name: string;
cefr_level: string; cefr_level: string;
@@ -101,13 +388,22 @@ export interface CoursePlan {
resources: CoursePlanResource[]; resources: CoursePlanResource[];
week_count: number; week_count: number;
material_count: number; material_count: number;
source_count?: number;
media_count?: number;
assignment_count?: number;
created_at: string | null; created_at: string | null;
weeks?: CoursePlanWeek[]; weeks?: CoursePlanWeek[];
materials?: CoursePlanMaterial[]; materials?: CoursePlanMaterial[];
sources?: CoursePlanSource[];
media?: CoursePlanMedia[];
assignments?: CoursePlanAssignment[];
/** Present on the student-list endpoint payload. */
assignment?: CoursePlanAssignment;
} }
export interface CoursePlanGenerateBrief { export interface CoursePlanGenerateBrief {
title: string; title: string;
entity_id?: number;
cefr_level?: string; cefr_level?: string;
total_weeks?: number; total_weeks?: number;
contact_hours_per_week?: number; contact_hours_per_week?: number;

View File

@@ -2,6 +2,8 @@ export interface Course {
id: number; id: number;
title: string; title: string;
code: string; code: string;
branch_id?: number | null;
branch_name?: string;
subject_id?: number; subject_id?: number;
subject_name?: string; subject_name?: string;
encoach_subject_id?: number | null; encoach_subject_id?: number | null;
@@ -28,6 +30,24 @@ export interface Course {
chapter_count?: number; chapter_count?: number;
resource_count?: number; resource_count?: number;
objective_count?: number; objective_count?: number;
section_count?: number;
sections?: CourseSection[];
}
export interface CourseSection {
id: number;
name: string;
code: string;
sequence: number;
active: boolean;
notes?: string;
course_id: number;
course_name: string;
entity_id?: number | null;
entity_name?: string;
branch_id?: number | null;
branch_name?: string;
batch_count?: number;
} }
export interface CourseModule { export interface CourseModule {
@@ -48,10 +68,22 @@ export interface CourseLesson {
export interface Batch { export interface Batch {
id: number; id: number;
name: string; name: string;
branch_id?: number | null;
branch_name?: string;
classroom_id?: number | null;
classroom_name?: string;
course_section_id?: number | null;
course_section_name?: string;
course_section_code?: string;
term_key?: string;
course_id: number; course_id: number;
course_name: string; course_name: string;
/** @deprecated single teacher kept for back-compat. Use `teacher_ids`. */
teacher_id: number; teacher_id: number;
/** @deprecated single teacher kept for back-compat. */
teacher_name: string; teacher_name: string;
teacher_ids?: number[];
teacher_names?: string[];
student_ids: number[]; student_ids: number[];
student_count: number; student_count: number;
start_date: string; start_date: string;
@@ -132,6 +164,8 @@ export interface LmsStudentRecord {
batch_name: string; batch_name: string;
partner_id: number; partner_id: number;
user_id: number | null; user_id: number | null;
branch_id?: number | null;
branch_name?: string;
} }
/** OpenEduCat `op.faculty` row from `/api/teachers` */ /** OpenEduCat `op.faculty` row from `/api/teachers` */
@@ -149,8 +183,25 @@ export interface LmsTeacherRecord {
department_name: string; department_name: string;
specialization: string; specialization: string;
subject_names: string[]; subject_names: string[];
branch_id?: number | null;
branch_name?: string;
} }
export interface LmsBranch {
id: number;
name: string;
code: string;
active: boolean;
notes: string;
entity_id: number | null;
entity_name: string;
course_count: number;
batch_count: number;
student_count: number;
teacher_count: number;
}
/** Enrolled course returned by GET /api/student/my-courses with progress data. */ /** Enrolled course returned by GET /api/student/my-courses with progress data. */
export interface MyEnrolledCourse { export interface MyEnrolledCourse {
id: number; id: number;
@@ -194,6 +245,7 @@ export interface LmsStudentCreateRequest {
password?: string; password?: string;
/** Default true: create portal user linked to `op.student`. */ /** Default true: create portal user linked to `op.student`. */
create_portal_user?: boolean; create_portal_user?: boolean;
branch_id?: number;
} }
export interface LmsTeacherCreateRequest { export interface LmsTeacherCreateRequest {
@@ -205,4 +257,5 @@ export interface LmsTeacherCreateRequest {
birth_date?: string; birth_date?: string;
department_id?: number; department_id?: number;
specialization?: string; specialization?: string;
branch_id?: number;
} }