23 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
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
Yamen Ahmad
45408acd5c feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Core AI runtime
- New encoach.ai.agent + encoach.ai.tool models with M2M tool binding,
  graph topology (simple|plan_review_revise|rag|react), model + fallback,
  temperature, max_tokens, response_format, max_revisions, quality checks
  and system prompt fields.
- services/agent_runtime.py compiles a langgraph.StateGraph per agent
  and caches the build per (key, write_date). Emits a structured trace
  (output, tool_calls, retrieval_hits, revisions, quality_issues,
  ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx.
- services/agent_tools.py registers 11 tool handlers wrapping existing
  services: resources.search, rubric.fetch, outcomes.fetch,
  student.profile, quality.cefr_check, quality.ai_detect,
  quality.content_gate, course_plan.save (mutates),
  course_plan.save_materials (mutates), scoring.grade_writing,
  scoring.grade_speaking.
- 7 default agents seeded via data/agents_defaults.xml: course_planner,
  course_week_materials, exam_generator, exercise_generator, lms_tutor,
  writing_grader, speaking_grader.
- Feature flag encoach_ai.use_langgraph_runtime (default True).
- encoach_ai_course pipeline now routes through AgentRuntime when on,
  legacy SDK path kept as fallback.

Admin UI
- /admin/ai/prompts is now a tabbed Agents | Tools | Prompts console.
- AIAgentsPanel: card grid + config dialog (model/temp/graph/tools/
  system prompt) + built-in Test Runner showing live trace.
- AIToolsPanel: registry table with category badges, mutates flag,
  schema viewer, edit dialog.
- New /api/ai/agents* and /api/ai/tools* controller (list/get/update/
  test, list-tools, toggle-tool).
- Sidebar label nav.aiPrompts -> nav.aiAgents (AI Agents and Tools).
- EN + AR (RTL) translations for ~80 new keys.

Smart Wizard pages
- /admin/quick-setup hub + CourseWizard, CoursePlanWizard,
  RubricWizard, ExamStructureWizard step-by-step flows.
- /admin/course-plans list + detail pages.
- /teacher/quick-setup mirror.

Full demo seed + 8-role E2E
- seed_full_demo.py adds the 5 missing user_types (approver, corporate,
  mastercorporate, agent, developer), activates a 2-stage exam-approval
  workflow with one pending request, creates a GE1-aligned 12-week B1
  course plan with 6 detailed Week-1 materials (reading 400w, writing,
  listening 4-min script, speaking, grammar present simple vs continuous,
  vocabulary), and inserts sample ai.log + ai.feedback rows.
- reset_demo_passwords.py forces every demo login back to canonical
  passwords (admin123/teacher123/student123/approver123/corporate123/
  master123/agent123/dev123).
- e2e_full_scenario.py: 46/46 PASS read-only API smoke across all
  8 roles, including a live LangGraph round-trip on writing_grader.
- e2e_approval_chain.py: 6/6 PASS mutation E2E - approver approves
  stage 1, admin approves stage 2, linked encoach.exam.custom flips
  to status=published, verified via psql.

Docs
- docs/PROJECT_SUMMARY.md updated to 2026-04-25: new Latest events
  bullets, refreshed credentials table, full sections 22 (LangGraph
  runtime) and 23 (full demo seed + 8-role E2E).
- docs/ENCOACH_FULL_DEMO_QA_REPORT.md added with credentials,
  per-endpoint PASS/FAIL, mutation chain proof, LangGraph live output.
- backend/GE1 Course Outline_ Fall AY25-26.pdf vendored as the
  reference outline the GE1 plan/materials are aligned to.

Dependencies
- requirements.txt: langgraph>=0.2.0, langchain-core>=0.3.0.
- encoach_ai/__manifest__.py: external_dependencies updated.

Made-with: Cursor
2026-04-25 03:14:22 +04:00
Yamen Ahmad
bbb2b44ae0 fix(ui): actionable 413/504/401 toasts + VPS nginx deploy template
Production QA reported "Submit failed 413" on /generation and plain
"Upload failed" (no detail) on resource upload. Root cause is the
outer reverse-proxy nginx on the VPS — still on default
`client_max_body_size 1m` — rejecting requests before they reach
Odoo. The inner docker-nginx and Odoo limits were already raised in
earlier commits; only the VPS proxy was left unpatched.

UI side
- Add `describeApiError(err, fallback, context)` helper in
  lib/api-client.ts. Maps common HTTP codes to human-readable,
  actionable toast descriptions (413 → "ask ops to raise nginx
  client_max_body_size", 504 → "server took too long, retry", 502
  → "Odoo is restarting", 401 → "sign in again", 4xx/5xx → server
  error body + status code).
- Use it in GenerationPage submit, ResourceManager upload,
  TeacherLibrary upload, CustomExamCreate save/publish. Replaces
  four slightly-different ad-hoc mappings with one source of truth.

Deploy side
- Add deploy/nginx-vps.conf.example: full TLS reverse-proxy template
  with the body/timeout knobs that must match Odoo's limit_request
  (128 MB) and limit_time_real (900 s).
- Add docs/DEPLOY_413_504_FIX.md: step-by-step remediation guide for
  the team lead covering the 3 layers that can block large bodies
  (outer nginx, Cloudflare, Odoo worker) and how to verify each.

Made-with: Cursor
2026-04-21 09:09:23 +04:00
Yamen Ahmad
dcb7727a4b fix(assignments): resolve op.student → res.users + student search
E2E QA surfaced a silent-drop bug in the exam-schedule endpoint: the
frontend's student picker sends `op.student.id` values (that's what
/api/students returns), but `encoach.exam.schedule.student_ids` is a
Many2many → res.users. The controller was writing the op.student id
straight through, so schedules linked to whichever res.users row
happened to share that integer id (OdooBot, other staff, etc.) — the
target student got 0 assignments and never saw the exam.

- exam_schedules.py: add `_resolve_student_user_ids` and use it in the
  create and update endpoints. Falls back to treating unmatched ids as
  direct res.users ids for back-office callers.
- AssignmentsPage.tsx: add a search input + larger list to the
  Individual Students picker; long lists were leaving Sarah unreachable
  inside the inner scroll container during QA.

Made-with: Cursor
2026-04-20 18:29:16 +04:00
Yamen Ahmad
96a2945512 fix(i18n): default startup language to English
First-visit users now always land on the English UI regardless of
navigator.language. Arabic remains one click away via the toggle, and
the user's explicit pick is persisted to localStorage (encoach-lang)
and honoured on every subsequent load.

- src/i18n/index.ts: set lng: "en", drop navigator/htmlTag from the
  detector order so an Arabic-locale browser no longer silently boots
  the UI in Arabic before the user has chosen.
- src/lib/api-client.ts: mirror the same policy for the Accept-Language
  header sent to the backend, so AI-generated content stays English on
  first visit instead of echoing the browser locale.

Made-with: Cursor
2026-04-20 17:19:49 +04:00
Yamen Ahmad
a4e92d1f40 fix(qa): approval queue, rubric UX, structure enforcement, upload/publish limits
Addresses the QA notes on the end-to-end approval flow. Highlights:

Backend
- encoach_ai/generation_submit: create encoach.approval.request on submit so
  submitted exams actually reach the approver queue (previously only the
  workflow id was stored on the exam, leaving approvers with an empty inbox).
  Initial exam status flips to pending_approval instead of draft.
- encoach_exam_template/approval_workflows:
  * /api/approval-users filters out students (user_type='student',
    op_student_id set, share=True) so the approver dropdown only lists staff.
  * _ser_request enriches requests with target_name / target_status and the
    current stage approver for UI badges.
  * list_requests supports mine=1 / requester=1 so approvers only see queue
    items awaiting their action.
  * approve_request / reject_request now transition the underlying
    encoach.exam.custom to published / rejected on final approval.
- encoach.exam.custom.status: add pending_approval and rejected states to
  match the approval workflow transitions.

Frontend UX
- GenerationPage: rubric field shows "Auto-graded — no rubric" for Listening
  and Reading (rubrics only apply to Writing / Speaking). Divider dropdowns
  now carry explicit help text explaining None / Line / Space / Page Break
  and where to write prompts. Selecting an official exam structure
  auto-populates the required tasks/sections/parts, the delete button is
  hidden for essential tasks, and submit is blocked if the user dropped
  below the structure's required count.
- RubricsPage: "Add Criterion" is now a dropdown with IELTS-specific
  presets (Task Achievement, Coherence & Cohesion, Fluency & Coherence, …)
  keyed off the selected skill, plus a "Custom (blank row)" fallback.
- AdminCourses: added tooltips and helper copy clarifying Difficulty vs
  CEFR Level in the Create Course dialog.
- CustomExamCreate: validate the whole form before Save/Publish, surface
  backend error messages (413/504/401) instead of a generic toast, read the
  exam id from the correct response field, and avoid marking Publish as
  failed when only the optional Save-as-Template step errors.
- ResourceManager / TeacherLibrary: upload toast now shows a concrete
  reason (HTTP 413 / 504 / 401) instead of a generic "Upload failed".

Config (504 / 413 / resource upload fixes)
- odoo.conf + odoo-docker.conf: raise limit_time_cpu to 600s,
  limit_time_real to 900s, limit_request to 128 MB, and memory caps so
  /api/exam/generation/submit and multipart resource uploads stop hitting
  504 / 413 during QA.
- frontend/Dockerfile (nginx): add client_max_body_size 128m, bump
  proxy_read_timeout / proxy_send_timeout to 900s, and disable request
  buffering so large AI/resource payloads stream through the proxy.

Made-with: Cursor
2026-04-20 17:14:39 +04:00
Yamen Ahmad
fbd58fa5a6 feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Frontend
- i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css.
- Language toggle: localize aria-label / menu label, persist choice, update
  document dir synchronously.
- Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up
  AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass
  side = i18n.dir() === 'rtl' ? 'right' : 'left'.
- AdminLmsLayout: convert every nav item from hard-coded title to titleKey,
  translate group labels (incl. the collapsible Training), breadcrumbs,
  user menu (Profile / Settings / Logout), help button and toggle aria
  labels; replace physical mr-/right- utilities with logical me-/end-.
- AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar,
  AiAssistantDrawer): apply dir="auto" at the container level, localize
  titles, loading / error / empty states.
- Dashboards (admin / student / teacher): wrap numeric values in <bdi>,
  localize dates via ar-EG, fix flex direction for KPI and assignment cards.
- UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar,
  context-menu, pagination, sidebar): flip chevrons in RTL via a scoped
  CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-.
- Add logical-direction helpers and bidirectional isolation classes.

Locales
- Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`,
  `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity.

API client
- `api-client.ts` reads the active language from localStorage/i18n and sends
  `Accept-Language` on every request so the backend can localize AI output.

Backend (encoach_ai)
- openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware
  system prompt injection for every OpenAI call.
- coach_service + controllers (coach_controller, ai_controller): thread
  the requested language from headers / user locale down to OpenAIService.
- ai_feedback: fix latent registry error by pointing course_id at op.course
  instead of the non-existent encoach.course.

Other
- .gitignore: ignore runtime odoo logs and local caches.

Made-with: Cursor
2026-04-19 18:13:16 +04:00
Yamen Ahmad
b02c2e7526 feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
  the project-manager PNG in a separate commit).

Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
  and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.

Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
  from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.

Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
  LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
  by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
  pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
  AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
  services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
  and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
  smoke tests, npm scripts test:e2e / test:e2e:install.

Made-with: Cursor
2026-04-19 14:16:32 +04:00
Yamen Ahmad
9b20d6ce66 chore(branding): adopt project-manager EnCoach logo across login and sidebars
- Replace public/logo-icon.png with the approved EnCoach wordmark
  (icon + "EnCoach" + tagline "Unlock your potential with AI powered platform").
- Login page shows the full branded logo at h-36 and drops the duplicate
  text heading since the wordmark is now baked into the asset.
- Student/Teacher/Admin sidebars show a small rounded icon when collapsed
  and the full wordmark at h-14 when expanded, removing the previous
  duplicated "EnCoach" text span.
- og:image in index.html already points at /logo-icon.png so social cards
  pick up the new branding automatically.

Made-with: Cursor
2026-04-19 14:15:41 +04:00
Yamen Ahmad
000969b0b9 feat(reports): replace mock Reports pages with real backend aggregates
Wire the three admin Reports pages (/admin/student-performance,
/admin/stats-corporate, /admin/record) to a new
encoach_lms_api/controllers/reports.py that aggregates from
encoach.student.attempt.

* /api/reports/student-performance: per-student band averages + CEFR,
  with entity / level / search filters.
* /api/reports/stats-corporate: by_module bar, N-month trend line,
  CEFR distribution pie, entity comparison bar, threshold + entity
  filters and meta.attempts_considered for UI.
* /api/reports/record: paginated attempt history with entity / user /
  period filters, EX-### exam codes, duration derived from start/end.
* /api/reports/filters: shared picker returning only entities /
  students that actually have attempts.

Frontend: new reports.service.ts, all three pages rewritten to hit
these endpoints; Recharts graphs now read live data, CSV export added
on Student Performance and Record.

Seeding: seed_reports.py completes any in_progress attempts and
backfills 6 months of historical attempts across 3 entities so the
trend / distribution / KPI panels have meaningful data. Idempotent.

Tests: test_reports_flows.py (25/25 PASS) covers shape, filters,
pagination. Regressions still green: Configuration 24/24, Support
29/29, Training 26/26. Browser-verified on localhost:8080 with admin
login — all 4 tabs in Stats Corporate render, Student Performance
shows real students + KPIs, Record shows 28 attempts with filter +
CSV export working.

Docs: new §20 in PROJECT_SUMMARY.md documenting scope, artifacts,
seeding, test results, and gotchas (encoach.exam.custom uses `title`
not `name`; encoach.entity requires `code`; in_progress attempts are
excluded from aggregates).

Made-with: Cursor
2026-04-19 11:28:26 +04:00
Yamen Ahmad
95938c0079 fix(config): align Configuration pages with platform logic
Audit of the admin Configuration section (FAQ Manager, Notification Rules,
Approval Config) surfaced three contract mismatches and one missing schema.

FAQ
  * Backend `encoach.faq.category.audience` / `encoach.faq.item.audience`
    rejected the `both` and `entity` values emitted by the FAQ Manager
    UI. Widened the Selection so the UI's full vocabulary round-trips.
  * FAQ item `video_url` was already accepted by the UI but silently
    dropped by the controller; now persisted via a new `video_url`
    field on `encoach.faq.item` and serialized on list/get responses.

Notification Rules
  * Added `days_before`, `frequency`, `channel`, `entity_id` to
    `encoach.notification.rule`. These are the exact fields the admin
    form collects; prior to this change they were serialized into the
    JSON body, ignored by the controller, and omitted from responses,
    leaving the Active switch permanently off and the table columns
    blank.
  * Controller now translates `active` <-> `is_active` both ways so the
    new frontend contract and legacy callers coexist. Missing required
    fields return HTTP 400 instead of 500.

Approval Workflows
  * The controller had been hand-rolling raw SQL against three tables
    (`encoach_approval_workflow`, `_stage`, `_request`) that no Odoo
    model declared, so the tables never existed. List() was guarded and
    returned empty; create() would 500 with "relation does not exist".
  * Introduced real ORM models in `encoach_exam_template/models/approval.py`
    plus access rights, which auto-provision the tables on -u.
  * Rewrote the controller to use ORM, added PATCH, and emitted both
    `items` and `results` in the list envelope so the frontend's
    PaginatedResponse reader and legacy callers both work. Step
    payloads now carry `max_days`, `auto_escalate`, and
    `notification_email` end to end.
  * Frontend `approvalsService` and `ApprovalWorkflowConfig` updated to
    send the full stage shape + `allow_bypass`, tolerate both envelope
    keys, and validate at least one approver before submit.

Schema delta applied via `./run.sh -u encoach_exam_template,encoach_lms_api`.
Verified with new `test_config_flows.py`: 24/24 passing. Regression runs
on `test_support_flows.py` (29/29) and `test_training_flows.py` (26/26)
remain green.

Made-with: Cursor
2026-04-19 10:58:43 +04:00
Yamen Ahmad
435930a827 feat: institutional + support + training admin sections (backend + frontend)
Ship three fully-wired admin areas end-to-end with APIs, seeds, tests and docs.

Backend (new `encoach_lms_api` addon + existing addons):
- Institutional: academic years/terms, departments, admission registers & admissions,
  courses/batches, lessons, fees (terms + student fees + invoicing with income-account
  auto-wiring), gradebook (assignments/grades), library, facilities (encoach.asset),
  student leave, result templates + marksheets (incl. delete-with-cascade).
- Support: `encoach.ticket` model + CRUD/assignee routes; payment records derived
  from `op.student.fees.details` and `account.move`; platform settings backed by
  `encoach.code` and `ir.config_parameter` (packages + grading config).
- Training: `encoach.vocab.item` + `encoach.grammar.rule` (plus progress models)
  with CRUD, pagination, search/level filters, and upsert-style progress endpoints.
  Odoo 19 compatibility: `_sql_constraints` replaced with `@api.constrains`;
  `ValidationError`/`UserError` mapped to HTTP 400.

Frontend:
- Rewire institutional admin pages (Academic Year Manager, Admissions, Courses,
  Lessons, Fees, Gradebook, Library, Facilities, Student Leave, Marksheets,
  Taxonomy, Resources) to real APIs with React Query invalidation and dialogs.
- New typed services: `payments.service.ts`, `platformSettings.service.ts`,
  `training.service.ts`. Updated `fees/gradebook/lms/courseware/taxonomy/
  resources/student-progress/generation` services + related types.
- Rewrite `VocabularyPage`, `GrammarPage`, `PaymentRecordPage`, `SettingsPage`,
  `TicketsPage` to consume live data with search/filter/progress/CRUD flows.
- New shared components: `TaxonomyCascade`, `MaterialViewer`, `teacher/TeacherLibrary`.
- Favicons/branding assets and misc. UX polish across teacher/student pages.

Tooling & QA:
- Seeders: `seed_demo.py`, `seed_demo_data.py`, `seed_institutional.py` (idempotent,
  covers institutional + support + training fixtures incl. income-account wiring).
- API write-flow test suites: `test_write_flows.py` (institutional),
  `test_support_flows.py` (support), `test_training_flows.py` (training),
  `test_ai_full.py`. All suites pass end-to-end.
- Docs: add `docs/PROJECT_SUMMARY.md` with per-section scope, artifacts and QA.
- `.gitignore`: ignore `pgdata_bak_*/`, `frontend/.vite/`, `frontend/dist/`,
  `frontend/node_modules/`.

Made-with: Cursor
2026-04-19 03:13:23 +04:00
Yamen Ahmad
b78124bb7b feat: complete exam lifecycle — AI generation, submission, student session, and results
- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
2026-04-16 16:53:09 +04:00
Yamen Ahmad
1d7ebea2af feat(generation): add Listening section selector, exercise wizard, and edit/preview support
- Replace section list with checkbox-based section type selector (Social Conversation, Social Monologue, Academic Discussion, Academic Monologue) matching old platform
- Add Set Up Exercises wizard for Listening sections with listening-specific exercise types
- Add exercise display with edit/delete/clear-all for each listening section
- Add Save/Discard/Edit buttons and read-only mode for listening context textarea
- Generalize exercise wizard and edit dialog to work with both Reading and Listening modules

Made-with: Cursor
2026-04-13 12:27:18 +04:00
Yamen Ahmad
2f450a7f26 feat(generation): add exercise instructions, grouped display, and wizard improvements
- Add per-section instructions field to exercise wizard with sensible defaults
- Group exercises by type with section headers showing instructions
- Pass type_instructions to backend AI prompt for context-aware generation
- Add instructions editing in exercise edit dialog
- Update Exercise interface to include instructions field

Made-with: Cursor
2026-04-13 00:54:19 +04:00
Yamen Ahmad
e066c595b9 feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements
- Fix ELAI video generation (correct render endpoint, script splitting for 60s limit)
- Fix speaking script generation error handling and empty response display
- Add custom exam list API (GET /api/exam/custom/list)
- Add assignments REST API (list, create, get)
- Add rubrics REST API (list, create)
- Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player
- Improve submit feedback with exam ID and status in toast notifications
- Fix ExamsListPage to show both custom exams and exam sessions
- Connect RubricsPage to backend API with fallback data
- Add Dockerfile, docker-compose.yml, requirements.txt for deployment
- Fix placement, grading, scoring, and auth controllers
- Add ErrorBoundary component for frontend resilience
- Add QA report and credentials documentation

Made-with: Cursor
2026-04-12 14:26:39 +04:00
Yamen Ahmad
74c2c9f2d2 feat: Generation Page AI workflows + AI/Vector modules + exam session fixes
Generation Page (complete rebuild):
- Full production-parity exam generation wizard with 4 IELTS modules
- Reading: AI passage gen, 5 exercise types (MCQ, Fill, Write, T/F, Match)
- Listening: 4 section types, AI context gen, TTS audio gen (ElevenLabs)
- Writing: Task 1/2, AI instruction gen, word limits, marks
- Speaking: 3 parts, AI script gen, avatar video gen (7 avatars)
- Per-module config: timer, CEFR difficulty, access, approval, rubrics
- Exam submission workflow (draft/published)

Exam Structures:
- New encoach.exam.structure model + CRUD controller
- ExamStructuresPage wired to real API

AI Module (encoach_ai):
- OpenAI service, ElevenLabs TTS, AWS Polly, ELAI avatars
- AI settings model with Odoo config parameters
- 7 generation endpoints (passage, exercises, instructions, scripts, context)

Vector Module (encoach_vector):
- pgvector integration for RAG-based content search
- Embedding service with sentence-transformers

Exam Session Fixes:
- Fixed ExamSession.tsx field mapping (question_type→type, exam_title→title)
- Fixed submit payload to include attempt_id and answers
- Fixed normalizeType to handle null/undefined

Tested: 12/12 API tests passed, browser-verified with real OpenAI calls
Made-with: Cursor
2026-04-11 14:27:03 +04:00
Yamen Ahmad
5b2ccbfeec feat(generation): rebuild Generation Page with full AI workflows
- Rebuild GenerationPage.tsx from static placeholder to production-parity
  exam generation wizard with all 4 IELTS modules (Reading, Listening,
  Writing, Speaking) plus Level and Industry
- Add per-module config: timer, CEFR difficulty tags, access type,
  entities, approval workflow, rubric, grading system, shuffling
- Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks,
  Write Blanks, True/False, Paragraph Match), categories/types
- Listening: 4 section types, AI context generation, TTS audio generation
- Writing: Task 1/2, AI instruction generation, word limits, marks
- Speaking: 3 parts, AI script generation, avatar video generation
  with 7 avatar options
- Wire ExamStructuresPage to real CRUD API (list/create/delete)
- Add backend exam_structure model and controller (/api/exam-structures)
- Enhance ai_controller with 5 specialized generation handlers
  (passage, exercises, writing instructions, speaking script,
  listening context)
- Add POST /api/exam/generation/submit for exam creation workflow
- Fix media.service avatar video endpoint alignment
- All 12 API tests passed, browser-verified with real OpenAI calls

Made-with: Cursor
2026-04-11 14:21:40 +04:00
Yamen Ahmad
11a7265460 feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/
- Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks)
- Add docs/ with SRS specs, user stories, and workflow documentation
- Update .gitignore for new directory layout

Workflows implemented:
  WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration,
  WF4 General English Exam, WF5 Course Generation,
  WF6 Entity Student Onboarding, AI Course Generation,
  Adaptive Learning Engine UI, White-Label Branding, Score Release

Made-with: Cursor
2026-04-10 17:26:42 +04:00
22 changed files with 4682 additions and 759 deletions

View File

@@ -96,6 +96,7 @@ const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDe
const AdminCourses = lazy(() => import("@/pages/admin/AdminCourses"));
const AdminStudents = lazy(() => import("@/pages/admin/AdminStudents"));
const AdminTeachers = lazy(() => import("@/pages/admin/AdminTeachers"));
const AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
@@ -320,10 +321,11 @@ const App = () => (
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
{/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} />
{/* LMS pages */}
{/* LMS pages (entity-scoped, includes branch management) */}
<Route path="/admin/courses" element={<AdminCourses />} />
<Route path="/admin/students" element={<AdminStudents />} />
<Route path="/admin/teachers" element={<AdminTeachers />} />
<Route path="/admin/branches" element={<AdminBranches />} />
<Route path="/admin/batches" element={<AdminBatches />} />
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
<Route path="/admin/timetable" element={<AdminTimetable />} />

View File

@@ -54,6 +54,8 @@ const lmsItems: NavItem[] = [
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
{ 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.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
@@ -97,7 +99,6 @@ const institutionalItems: NavItem[] = [
const managementItems: NavItem[] = [
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ 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.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
@@ -167,7 +168,7 @@ function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
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",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",

View File

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

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

@@ -1,6 +1,8 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import type { CoursePlanMaterial } from "@/types";
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",
@@ -56,23 +58,82 @@ function renderAny(value: unknown): React.ReactNode {
return null;
}
type MaterialForBook = Pick<
CoursePlanMaterial,
"id" | "plan_id" | "body" | "body_text" | "material_type" | "title" | "summary"
>;
export default function MaterialBookView({
material,
mode = "student",
}: {
material: Pick<CoursePlanMaterial, "body" | "body_text" | "material_type">;
// 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(body)
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

@@ -63,6 +63,7 @@ const ar: Translations = {
myCoursePlans: "خططي الدراسية",
students: "الطلاب",
teachers: "المعلمون",
branches: "الفروع",
batches: "الدفعات",
timetable: "الجدول الدراسي",
reports: "التقارير",
@@ -203,6 +204,8 @@ const ar: Translations = {
averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
myCoursePlans: "خططي الدراسية",
noCoursePlans: "لم يتم إسناد أي خطة دراسية لك بعد.",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة",

View File

@@ -110,6 +110,7 @@ const en: Translations = {
myCoursePlans: "My Course Plans",
students: "Students",
teachers: "Teachers",
branches: "Branches",
batches: "Batches",
timetable: "Timetable",
reports: "Reports",
@@ -250,6 +251,8 @@ const en: Translations = {
averageGrade: "Average Grade",
totalChapters: "Total Chapters",
myCourses: "My Courses",
myCoursePlans: "My Course Plans",
noCoursePlans: "No course plans assigned yet.",
noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions",

View File

@@ -283,9 +283,9 @@ export type QueryParamValue =
export type QueryParams = object;
const ENTITY_QUERY_SCOPE_RE =
/^\/(courses|students|teachers|batches|student\/my-courses|ai\/course-plan)(\/|$)/;
/^\/(courses|students|teachers|batches|branches|classrooms|student\/my-courses|ai\/course-plan)(\/|$)/;
const ENTITY_BODY_SCOPE_RE =
/^\/(courses|students|teachers|batches|ai\/course-plan)(\/|$)/;
/^\/(courses|students|teachers|batches|branches|classrooms|ai\/course-plan)(\/|$)/;
function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
@@ -409,10 +409,11 @@ export const api = {
});
},
async delete<T>(path: string): Promise<T> {
async delete<T>(path: string, payload?: Record<string, unknown>): Promise<T> {
return performRequest<T>(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
body: payload ? JSON.stringify(payload) : undefined,
});
},

File diff suppressed because it is too large Load Diff

View File

@@ -4,34 +4,106 @@ 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 { Checkbox } from "@/components/ui/checkbox";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
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 { classroomsService } from "@/services/classrooms.service";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
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 { useToast } from "@/hooks/use-toast";
import AiBatchOptimizer from "@/components/ai/AiBatchOptimizer";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
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() {
const [search, setSearch] = useState("");
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 qc = useQueryClient();
const { data: batchesData, isLoading } = useBatches();
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 courses = coursesData?.items ?? [];
const teachers = teachersData?.items ?? [];
const classrooms = classroomsQ.data ?? [];
const createSections = createSectionsQ.data?.items ?? [];
const editSections = editSectionsQ.data?.items ?? [];
const createMutation = useMutation({
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" }),
});
@@ -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>;
const filtered = batches.filter(b => b.name.toLowerCase().includes(search.toLowerCase()));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -62,7 +151,81 @@ export default function AdminBatches() {
</div>
<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>
<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}>
<DialogContent>
<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>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>
<SelectContent>
<SelectItem value="__none__"> Select </SelectItem>
@@ -78,15 +250,233 @@ export default function AdminBatches() {
</SelectContent>
</Select>
</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="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>
<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>
<DialogFooter>
<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>
</DialogContent>
</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>
);
}

View File

@@ -88,6 +88,9 @@ import { LibraryPickerDialog } from "@/components/coursePlan/LibraryPickerDialog
import MaterialBookView, {
SkillBadge,
} from "@/components/coursePlan/MaterialBookView";
import PlanReader, {
GroundingBadge,
} from "@/components/coursePlan/PlanReader";
import { describeApiError, withAuthQuery } from "@/lib/api-client";
import type {
CoursePlan,
@@ -168,6 +171,36 @@ export default function AdminCoursePlanDetail() {
const plan = planQ.data?.data as CoursePlan | undefined;
const [studentPreview, setStudentPreview] = useState(false);
const [gateWeek, setGateWeek] = useState<number | null>(null);
// True as soon as at least one uploaded source is fully indexed in
// pgvector. Drives the v2 (RAG-grounded) path on Generate and gates
// the soft "upload your book first" modal.
const indexedSources = useMemo(
() => (sourcesQ.data?.items ?? []).filter((s) => s.status === "indexed"),
[sourcesQ.data?.items],
);
const hasIndexedSources = indexedSources.length > 0;
const generateV2Mut = useMutation({
mutationFn: (weekNumber: number) =>
coursePlanService.generateWeekMaterialsV2(planId, weekNumber),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
toast.success(
t("coursePlan.weekMaterialsGenerated") +
(res.rag?.sources_used?.length
? ` (${t("coursePlan.rag.groundedOn", {
count: res.rag.sources_used.length,
defaultValue: "grounded on {{count}} source(s)",
})})`
: ""),
);
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
const generateMut = useMutation({
mutationFn: (weekNumber: number) =>
@@ -181,6 +214,24 @@ export default function AdminCoursePlanDetail() {
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
/**
* Single entry point for the per-week Generate button:
* - if any source is indexed → call the RAG-grounded v2 endpoint
* - otherwise → open the upload gate; the admin can still choose
* "Generate without RAG" to fall back to the legacy generator
*/
const requestGenerate = (weekNumber: number) => {
if (hasIndexedSources) {
generateV2Mut.mutate(weekNumber);
} else {
setGateWeek(weekNumber);
}
};
const generateBusy = (weekNumber: number) =>
(generateV2Mut.isPending && generateV2Mut.variables === weekNumber) ||
(generateMut.isPending && generateMut.variables === weekNumber);
const bulkMediaMut = useMutation({
mutationFn: (vars: {
week: number;
@@ -273,7 +324,7 @@ export default function AdminCoursePlanDetail() {
</Badge>
</div>
</div>
{plan.skills_division && (
{plan.skills_division && !studentPreview && (
<p className="text-sm text-muted-foreground">
{plan.skills_division}
</p>
@@ -281,6 +332,19 @@ export default function AdminCoursePlanDetail() {
</CardHeader>
</Card>
{studentPreview ? (
<>
<div className="rounded-md border border-amber-300/60 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm text-amber-900 dark:text-amber-100 flex items-center gap-2">
<Eye className="h-4 w-4" />
{t(
"coursePlan.preview.banner",
"Preview — answers won't be saved. Toggle Student view to exit.",
)}
</div>
<PlanReader plan={plan} mode="preview" />
</>
) : (
<>
<DeliverablesStrip data={deliverablesQ.data} />
<SourcesCard
@@ -407,11 +471,8 @@ export default function AdminCoursePlanDetail() {
key={week.id}
week={week}
materials={materialsByWeek.get(week.week_number) ?? []}
generating={
generateMut.isPending &&
generateMut.variables === week.week_number
}
onGenerate={() => generateMut.mutate(week.week_number)}
generating={generateBusy(week.week_number)}
onGenerate={() => requestGenerate(week.week_number)}
onBulkMedia={(kinds) =>
bulkMediaMut.mutate({ week: week.week_number, kinds })
}
@@ -420,6 +481,7 @@ export default function AdminCoursePlanDetail() {
bulkMediaMut.variables?.week === week.week_number
}
studentPreview={studentPreview}
hasIndexedSources={hasIndexedSources}
/>
))}
</Accordion>
@@ -428,10 +490,97 @@ export default function AdminCoursePlanDetail() {
)}
</>
)}
</>
)}
<PreGenerationGate
open={gateWeek !== null}
onOpenChange={(v) => {
if (!v) setGateWeek(null);
}}
weekNumber={gateWeek}
onUpload={() => {
setGateWeek(null);
// Scroll to the SourcesCard upload area; the admin uploads
// the PDF, watches it index, then clicks Generate again.
document
.querySelector<HTMLElement>(`[data-section="sources-card"]`)
?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
onContinueWithoutRag={() => {
if (gateWeek !== null) {
generateMut.mutate(gateWeek);
setGateWeek(null);
}
}}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Pre-generation upload gate
// ---------------------------------------------------------------------------
/**
* Soft gate shown before Generate when the plan has no indexed sources.
* Encourages the admin to upload reference material first so the v2
* generator can ground exercises on real text. Dismissable: clicking
* "Generate without RAG" falls back to the legacy v1 endpoint so we
* never block the workflow on a missing book.
*/
function PreGenerationGate({
open,
onOpenChange,
weekNumber,
onUpload,
onContinueWithoutRag,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
weekNumber: number | null;
onUpload: () => void;
onContinueWithoutRag: () => void;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("coursePlan.gate.title", "Upload your reference book first?")}
</DialogTitle>
<DialogDescription>
{t(
"coursePlan.gate.subtitle",
"Generating week {{n}} works best when grounded on your uploaded PDF / DOCX / link. Upload a reference, wait for indexing, then click Generate again. You can dismiss this and continue without RAG.",
{ n: weekNumber ?? 0 },
)}
</DialogDescription>
</DialogHeader>
<div className="text-xs text-muted-foreground">
{t(
"coursePlan.gate.hint",
"Tip: a typical course book takes 10-30 seconds to index after upload.",
)}
</div>
<DialogFooter className="gap-2 flex-wrap">
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("coursePlan.gate.cancel", "Cancel")}
</Button>
<Button variant="ghost" onClick={onContinueWithoutRag}>
{t("coursePlan.gate.continueWithoutRag", "Generate without RAG")}
</Button>
<Button onClick={onUpload}>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.gate.upload", "Upload now")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ---------------------------------------------------------------------------
// Phase B — Deliverables / progress strip
// ---------------------------------------------------------------------------
@@ -618,14 +767,82 @@ function SourcesCard({
),
});
const extractMut = useMutation({
mutationFn: () => coursePlanService.extractWorkbooks(planId),
onSuccess: (res) => {
if (res.data.materials_created > 0) {
toast.success(
t("coursePlan.sources.extractDone", {
materials: res.data.materials_created,
exercises: res.data.exercises_total,
defaultValue:
"Extracted {{exercises}} exercises into {{materials}} workbooks",
}),
);
} else {
toast.message(
res.data.reason
? `${t("coursePlan.sources.extractEmpty", { defaultValue: "No new exercises extracted" })} (${res.data.reason})`
: t("coursePlan.sources.extractEmpty", {
defaultValue: "No new exercises extracted",
}),
);
}
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
qc.invalidateQueries({ queryKey: ["course-plan", planId, "deliverables"] });
},
onError: (err) =>
toast.error(
describeApiError(
err,
t("coursePlan.sources.extractFailed", {
defaultValue: "Failed to extract workbooks",
}),
),
),
});
// True once at least one source is fully indexed — surfaces the
// "Extract workbooks" action only when running it can actually
// produce results.
const hasIndexed = sources.some((s) => s.status === "indexed");
return (
<Card>
<Card data-section="sources-card">
<CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<CardTitle className="text-base flex items-center gap-2">
<Database className="h-4 w-4 text-primary" />
{t("coursePlan.sources.sectionTitle")}
</CardTitle>
<CardDescription>{t("coursePlan.sources.sectionDesc")}</CardDescription>
<CardDescription>
{t("coursePlan.sources.sectionDesc")}
</CardDescription>
</div>
<Button
size="sm"
variant="outline"
onClick={() => extractMut.mutate()}
disabled={extractMut.isPending || !hasIndexed}
title={
hasIndexed
? undefined
: t("coursePlan.sources.extractGate", {
defaultValue: "Index a source first",
})
}
>
{extractMut.isPending ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<ClipboardList className="mr-1 h-4 w-4" />
)}
{t("coursePlan.sources.extractWorkbooks", {
defaultValue: "Extract workbooks from sources",
})}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
@@ -1241,6 +1458,7 @@ function WeekAccordionItem({
onBulkMedia,
bulkBusy,
studentPreview,
hasIndexedSources,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
@@ -1249,6 +1467,7 @@ function WeekAccordionItem({
onBulkMedia: (kinds: Array<"audio" | "image" | "video">) => void;
bulkBusy: boolean;
studentPreview: boolean;
hasIndexedSources: boolean;
}) {
const { t } = useTranslation();
const [skillFilter, setSkillFilter] = useState<string>("all");
@@ -1344,6 +1563,11 @@ function WeekAccordionItem({
: materials.length > 0
? t("coursePlan.regenerateMaterials")
: t("coursePlan.generateMaterials")}
{hasIndexedSources && (
<Badge variant="secondary" className="ml-2">
RAG
</Badge>
)}
</Button>
{materials.length > 0 && (
<Button
@@ -1463,6 +1687,7 @@ function MaterialCard({
)}
</Badge>
<SkillBadge skill={material.skill} />
<GroundingBadge material={material} />
<Button
variant="outline"
size="sm"
@@ -1630,6 +1855,46 @@ function MediaDrawer({
toast.error(describeApiError(err, t("coursePlan.media.deleteFailed"))),
});
// Admin upload — one mutation handles all three kinds, the file picker
// hidden inputs below decide which kind the file is for. The mutation
// input is the kind+file pair so React Query can serialize multiple
// uploads (e.g. drag two images in a row) without colliding state.
const uploadMut = useMutation({
mutationFn: ({ kind, file }: { kind: "audio" | "image" | "video"; file: File }) =>
coursePlanService.uploadMedia(material.id, kind, file),
onSuccess: (_data, vars) => {
toast.success(
t(`coursePlan.media.uploaded_${vars.kind}`, {
defaultValue: `${vars.kind.charAt(0).toUpperCase() + vars.kind.slice(1)} uploaded`,
}),
);
refresh();
},
onError: (err) =>
toast.error(
describeApiError(
err,
t("coursePlan.media.uploadFailed", { defaultValue: "Upload failed" }),
),
),
});
const audioInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
function handleFilePicked(
kind: "audio" | "image" | "video",
e: React.ChangeEvent<HTMLInputElement>,
) {
const file = e.target.files?.[0];
// Always reset the input value so the same file can be re-selected
// after a failed upload — otherwise onChange won't fire again.
e.target.value = "";
if (!file) return;
uploadMut.mutate({ kind, file });
}
const items = mediaQ.data?.items ?? [];
return (
@@ -1645,6 +1910,7 @@ function MediaDrawer({
</SheetHeader>
{!studentPreview && (
<>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<Button
variant="outline"
@@ -1686,6 +1952,79 @@ function MediaDrawer({
{t("coursePlan.media.generateVideo")}
</Button>
</div>
{/*
Manual upload row — admins can attach a real human voiceover,
a custom illustration, or a recorded scene to override the
AI-generated asset. The hidden inputs are kept off-DOM-flow
and triggered by the visible buttons via refs so the styling
stays consistent with the Generate row above.
*/}
<div className="mt-2 grid gap-2 sm:grid-cols-3">
<input
ref={audioInputRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => handleFilePicked("audio", e)}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => handleFilePicked("image", e)}
/>
<input
ref={videoInputRef}
type="file"
accept="video/*"
className="hidden"
onChange={(e) => handleFilePicked("video", e)}
/>
<Button
variant="ghost"
size="sm"
onClick={() => audioInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadAudio", {
defaultValue: "Upload audio",
})}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => imageInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadImage", {
defaultValue: "Upload image",
})}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => videoInputRef.current?.click()}
disabled={uploadMut.isPending}
>
<Upload className="mr-1 h-4 w-4" />
{t("coursePlan.media.uploadVideo", {
defaultValue: "Upload video",
})}
</Button>
</div>
{uploadMut.isPending && (
<p className="text-muted-foreground mt-2 inline-flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
{t("coursePlan.media.uploading", {
defaultValue: "Uploading…",
})}
</p>
)}
</>
)}
<div className="mt-6 space-y-3">

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 { lmsService, taxonomyService, resourcesService } from "@/services";
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 AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import AiTipBanner from "@/components/ai/AiTipBanner";
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";
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() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [editingCourse, setEditingCourse] = useState<Course | null>(null);
const [sectionsCourse, setSectionsCourse] = useState<Course | null>(null);
const [enrollOpen, setEnrollOpen] = useState(false);
const [enrollCourseId, setEnrollCourseId] = useState<number | null>(null);
const [selectedStudentIds, setSelectedStudentIds] = useState<number[]>([]);
@@ -539,10 +864,11 @@ export default function AdminCourses() {
<TableHead>Course</TableHead>
<TableHead>Subject / Tags</TableHead>
<TableHead>Difficulty</TableHead>
<TableHead>Sections</TableHead>
<TableHead>Chapters</TableHead>
<TableHead>Enrolled / Cap</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[150px]" />
<TableHead className="w-[180px]" />
</TableRow>
</TableHeader>
<TableBody>
@@ -594,6 +920,36 @@ export default function AdminCourses() {
</Badge>
)}
</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>
<div className="flex items-center gap-1 text-sm">
<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" />
</Button>
<Button
variant="ghost"
size="icon"
title="Manage sections (A/B/C…)"
onClick={() => setSectionsCourse(c)}
>
<Layers className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
@@ -653,7 +1017,7 @@ export default function AdminCourses() {
))}
{filtered.length === 0 && (
<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.
</TableCell>
</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}>
<DialogContent className="max-w-lg">
<DialogHeader>

View File

@@ -1,60 +1,14 @@
import { useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Image as ImageIcon,
Library,
MessageSquare,
Mic,
Music,
PenSquare,
Sparkles,
Type,
Video,
type LucideIcon,
} from "lucide-react";
import { ArrowLeft } from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import PlanReader from "@/components/coursePlan/PlanReader";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError, withAuthQuery } from "@/lib/api-client";
import MaterialBookView, { SkillBadge } from "@/components/coursePlan/MaterialBookView";
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,
};
import { describeApiError } from "@/lib/api-client";
import type { CoursePlan } from "@/types";
/**
* Student detail view for an assigned AI course plan.
@@ -63,8 +17,8 @@ const SKILL_ICONS: Record<string, LucideIcon> = {
* returns 403 unless the current user is in the plan's assignment list,
* so we can render unconditionally as soon as the query resolves.
*
* The page is intentionally read-only — students can play audio, view
* images, and watch video, but they can't (re)generate content.
* 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();
@@ -79,20 +33,14 @@ export default function StudentCoursePlanDetail() {
const plan = data?.data as CoursePlan | undefined;
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">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<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")}
@@ -113,235 +61,7 @@ export default function StudentCoursePlanDetail() {
</div>
)}
{plan && (
<>
<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.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) => (
<StudentWeek
key={w.id}
week={w}
materials={materialsByWeek.get(w.week_number) ?? []}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function StudentWeek({
week,
materials,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
}) {
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) => (
<StudentMaterial key={m.id} material={m} />
))}
</AccordionContent>
</AccordionItem>
);
}
function StudentMaterial({ material }: { material: CoursePlanMaterial }) {
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} />
</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) => (
<StudentMediaTile key={m.id} media={m} />
))}
<MaterialBookView material={material} />
</CardContent>
</Card>
);
}
function StudentMediaTile({ 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" />
)}
{plan && <PlanReader plan={plan} mode="student" />}
</div>
);
}

View File

@@ -2,22 +2,29 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
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 { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { coursePlanService } from "@/services/coursePlan.service";
export default function StudentDashboard() {
const { user } = useAuth();
const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
const { data: planData, isLoading: lp } = useQuery({
queryKey: ["student-course-plans"],
queryFn: () => coursePlanService.studentList(),
});
const myCourses = enrolledData?.items ?? [];
const myPlans = planData?.items ?? [];
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 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 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.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" },
@@ -93,6 +100,42 @@ export default function StudentDashboard() {
</Card>
<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>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>

View File

@@ -1,36 +1,127 @@
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 {
entity_id?: number;
branch_id?: number;
search?: string;
active?: boolean;
}
export const classroomsService = {
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> {
return api.get<Classroom>(`/groups/${id}`);
const raw = await api.get<unknown>(`/classrooms/${id}`);
return asRecordData<Classroom>(raw);
},
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> {
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> {
return api.post<ApiSuccessResponse>("/groups/transfer", data);
// ---- Roster ----------------------------------------------------------
async listStudents(id: number): Promise<{ items: ClassroomStudent[]; total: number }> {
return api.get(`/classrooms/${id}/students`);
},
async addMembers(id: number, userIds: number[]): Promise<ApiSuccessResponse> {
return api.post<ApiSuccessResponse>(`/groups/${id}/members`, { user_ids: userIds });
/** Replace or add to roster. ``mode='set'`` overwrites; ``mode='add'`` appends. */
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> {
return api.post<ApiSuccessResponse>(`/groups/${id}/members/remove`, { user_ids: userIds });
async removeStudents(id: number, studentIds: number[]): Promise<Classroom> {
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

@@ -8,6 +8,9 @@ import type {
CoursePlanMedia,
CoursePlanSource,
CoursePlanSourceKind,
WorkbookAttempt,
WorkbookExtractedFrom,
WorkbookGroundedSource,
} from "@/types";
/**
@@ -54,6 +57,55 @@ export const coursePlanService = {
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: {
@@ -193,6 +245,29 @@ export const coursePlanService = {
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,
@@ -257,4 +332,42 @@ export const coursePlanService = {
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

@@ -18,6 +18,8 @@ import type {
MyEnrolledCourse,
EnrollStudentRequest,
BulkEnrollRequest,
LmsBranch,
CourseSection,
} from "@/types";
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),
resource_count: Number(r.resource_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",
completed: "completed",
};
const teacherIds = (r.teacher_ids as number[]) || [];
const teacherNames = (r.teacher_names as string[]) || [];
return {
id: r.id as number,
name: String(r.name || ""),
course_id: Number(r.course_id || 0),
course_name: String(r.course_name || ""),
teacher_id: 0,
teacher_name: "",
teacher_id: teacherIds[0] || 0,
teacher_name: teacherNames[0] || "",
teacher_ids: teacherIds,
teacher_names: teacherNames,
student_ids: ((r.students as { id: number }[]) ?? []).map((s) => s.id),
student_count: Number(r.student_count ?? 0),
start_date: String(r.start_date || ""),
@@ -77,6 +89,32 @@ function mapBatchRaw(r: Record<string, unknown>): Batch {
schedule: "",
status: statusMap[st] || "upcoming",
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 };
},
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> {
const raw = await api.get<unknown>(`/courses/${id}`);
return mapCourseRaw(asRecordData<Record<string, unknown>>(raw));
@@ -179,6 +236,51 @@ export const lmsService = {
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 }> {
return api.post("/courses/ai-generate", data);
},
@@ -255,6 +357,25 @@ export const lmsService = {
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> {
return api.delete<ApiSuccessResponse>(`/students/${id}`);
},

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 {
id: number;
name: string;
admin_id: number;
admin_name: string;
entity_id: number;
code: string;
capacity: number;
active: boolean;
notes?: string;
entity_id: number | null;
entity_name: string;
participant_count: number;
participants: ClassroomMember[];
branch_id?: number | null;
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 {
id: number;
name: 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 {
name: string;
entity_id: number;
admin_id: number;
participant_ids?: number[];
code?: string;
capacity?: 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"
| "vocabulary_list"
| "practice"
| "interactive_workbook"
| "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 {
code: string;
description: string;
@@ -83,6 +204,10 @@ export interface CoursePlanMaterial {
/** Loose shape: depends on material_type. */
body: Record<string, unknown>;
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[];
}

View File

@@ -2,6 +2,8 @@ export interface Course {
id: number;
title: string;
code: string;
branch_id?: number | null;
branch_name?: string;
subject_id?: number;
subject_name?: string;
encoach_subject_id?: number | null;
@@ -28,6 +30,24 @@ export interface Course {
chapter_count?: number;
resource_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 {
@@ -48,10 +68,22 @@ export interface CourseLesson {
export interface Batch {
id: number;
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_name: string;
/** @deprecated single teacher kept for back-compat. Use `teacher_ids`. */
teacher_id: number;
/** @deprecated single teacher kept for back-compat. */
teacher_name: string;
teacher_ids?: number[];
teacher_names?: string[];
student_ids: number[];
student_count: number;
start_date: string;
@@ -132,6 +164,8 @@ export interface LmsStudentRecord {
batch_name: string;
partner_id: number;
user_id: number | null;
branch_id?: number | null;
branch_name?: string;
}
/** OpenEduCat `op.faculty` row from `/api/teachers` */
@@ -149,8 +183,25 @@ export interface LmsTeacherRecord {
department_name: string;
specialization: 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. */
export interface MyEnrolledCourse {
id: number;
@@ -194,6 +245,7 @@ export interface LmsStudentCreateRequest {
password?: string;
/** Default true: create portal user linked to `op.student`. */
create_portal_user?: boolean;
branch_id?: number;
}
export interface LmsTeacherCreateRequest {
@@ -205,4 +257,5 @@ export interface LmsTeacherCreateRequest {
birth_date?: string;
department_id?: number;
specialization?: string;
branch_id?: number;
}