32 Commits

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

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

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

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

Made-with: Cursor
2026-04-26 02:34:52 +04:00
Yamen Ahmad
c2a07a6e5c feat(course-plan): RAG sources + multi-modal media + assignments + student view
Builds the §24 product on top of the LangGraph runtime from §22:

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-01 16:59:11 +04:00
451 changed files with 93999 additions and 21 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
.git
.env
.env.local
.env.production
*.md
docs/
.vscode/
coverage/

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Dev: use /api with Vite proxy (see vite.config.ts). Production: full URL, e.g. https://your-odoo/api
VITE_API_BASE_URL=/api
VITE_APP_NAME=EnCoach

View File

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

28
.gitignore vendored
View File

@@ -1,16 +1,26 @@
# Environment files — never commit secrets # Dependencies
node_modules/
# Build output
dist/
build/
.next/
out/
# Environment / secrets — never commit
.env .env
.env.* .env.*
!.env.example !.env.example
.env.local
.env.production
.env.*.local
# Next.js # IDE
.next/ .vscode/
out/ .idea/
node_modules/ *.swp
*.swo
# Build *~
dist/
build/
# OS # OS
.DS_Store .DS_Store

53
Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE_URL=/api
ARG VITE_APP_NAME=EnCoach
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_APP_NAME=$VITE_APP_NAME
RUN npm run build
FROM nginx:stable-alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY <<'NGINX' /etc/nginx/conf.d/default.conf
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Default nginx body cap is 1 MB which causes HTTP 413 on:
# * resource uploads (PDF / audio / video)
# * /api/exam/generation/submit (whole exam with generated questions)
# * /api/approval-requests (attachments)
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
client_max_body_size 128m;
client_body_buffer_size 1m;
location /api/ {
proxy_pass http://backend:8069/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# OpenAI streaming calls + multi-module AI generation can run >3 min.
# Keep read/send timeouts above Odoo's limit_time_real (900s).
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_request_buffering off;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:css|js|svg|png|jpg|jpeg|gif|ico|woff2?|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
NGINX
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

136
README.md
View File

@@ -8,20 +8,132 @@ This repo is connected to the staging server via a Git post-receive hook.
### How to contribute ### How to contribute
1. Never push directly to `main` — branch protection will block it. 1. Never push directly to `main` — branch protection will block it.
2. Create a feature or fix branch: 2. Push your changes to your feature branch (e.g. `feature/full-frontend-v1`)
```bash 3. Open a Pull Request on Gitea targeting `main`
git checkout -b feature/your-feature-name 4. Request review from **devops (Talal)**
``` 5. Once approved and merged, the staging server rebuilds and redeploys automatically.
3. Develop, commit, and push your branch:
```bash
git push origin feature/your-feature-name
```
4. Open a **Pull Request** on Gitea targeting `main`.
5. Request review from **devops (Talal)**.
6. Once approved and merged, the staging server rebuilds and redeploys automatically.
### Environment ### Environment
The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/frontend-v2/.env`. The `.env` file is **not committed**. It lives only on the staging server at `/opt/encoach/frontend-v2/.env`.
Contact the team lead if you need a local copy of the environment variables.
---
# EnCoach — Adaptive Learning Platform (Frontend)
The frontend application for the EnCoach Adaptive Learning Platform, serving UTAS university students and freelance learners across IELTS, Mathematics, and IT courses.
Built with React 18, TypeScript, and Vite. Connects to an Odoo 19 backend via REST API.
## Tech Stack
| Layer | Technology |
|---|---|
| Framework | React 18 + TypeScript |
| Build | Vite 5 (SWC) |
| Routing | React Router v6 |
| State / Data | TanStack Query v5, React Context |
| UI Components | shadcn/ui + Radix UI |
| Styling | Tailwind CSS 3 |
| Forms | react-hook-form + Zod validation |
| Charts | Recharts |
| Icons | Lucide React |
## Architecture
```
src/
├── components/ # Shared UI, layouts, AI components
│ ├── ai/ # 14 AI-powered components (coaching, grading, insights)
│ └── ui/ # shadcn/ui primitives
├── contexts/ # AuthContext (JWT session management)
├── hooks/
│ ├── queries/ # TanStack Query hooks (exams, assignments, LMS, adaptive)
│ └── usePermissions # Entity-scoped permission checks
├── lib/
│ ├── api-client.ts # Centralized HTTP client with JWT + 401 interception
│ └── query-client.ts # TanStack Query configuration
├── pages/
│ ├── admin/ # Admin & LMS management (courses, batches, taxonomy, resources)
│ ├── student/ # Student portal (dashboard, courses, adaptive learning flow)
│ └── teacher/ # Teacher portal (courses, assignments, attendance, grading)
├── services/ # 21 API service modules mapped to Odoo endpoints
└── types/ # 14 TypeScript type definition files
```
## Key Features
- **7 User Roles** — Student, Teacher, Admin, Corporate, Master Corporate, Agent, Developer
- **Adaptive Learning Engine** — Diagnostic assessment, proficiency profiling, AI-generated learning plans, topic-level content delivery
- **LMS Integration** — Course management, batches, timetables, attendance, grades (OpenEduCat via API)
- **Exam Portal** — IELTS-style exams with AI grading, writing evaluation, speaking assessment
- **14 AI Components** — Study coach, writing helper, grading assistant, risk detection, insights panel, batch optimizer, report narratives
- **Entity-Scoped Permissions** — Fine-grained access control with 77+ permission types
- **Multi-Subject Support** — IELTS (English), Mathematics, IT — all using a universal subject taxonomy
## Getting Started
### Prerequisites
- Node.js 18+ (or Bun)
- Odoo 19 backend running (see `docs/ODOO_BACKEND_SRS_v3.md`)
### Setup
```bash
# Clone the repository
git clone https://git.albousalh.com/devops/encoach_frontend_new_v1.git
cd encoach_frontend_new_v1
# Install dependencies
npm install
# Configure environment
cp .env.example .env.development
# Start development server
npm run dev
```
The app runs on `http://localhost:8080` by default.
### Environment Variables
| Variable | Description | Default |
|---|---|---|
| `VITE_API_BASE_URL` | Odoo backend API base URL | `http://localhost:8069/api` |
| `VITE_APP_NAME` | Application display name | `EnCoach` |
### Scripts
| Command | Description |
|---|---|
| `npm run dev` | Start dev server with HMR |
| `npm run build` | Production build |
| `npm run build:dev` | Development build |
| `npm run preview` | Preview production build |
| `npm run lint` | Run ESLint |
| `npm run test` | Run tests |
| `npm run test:watch` | Run tests in watch mode |
## Documentation
All project documentation is in the `docs/` folder:
| Document | Purpose |
|---|---|
| `ENCOACH_UNIFIED_SRS.md` | Master Software Requirements Specification |
| `ODOO_BACKEND_SRS_v3.md` | Backend developer handoff (Odoo modules + API contract) |
| `ENCOACH_PRODUCT_DESCRIPTION.md` | Non-technical product overview |
| `UTAS_MASTER_PLAN.md` | Project timeline and milestones |
| `MATH_IT_ADAPTIVE_LEARNING_SRS.md` | Adaptive learning engine specification |
## Backend API
The frontend expects an Odoo 19 backend exposing REST endpoints under `/api/`. All service modules in `src/services/` map directly to the API contract defined in `docs/ODOO_BACKEND_SRS_v3.md`.
Authentication uses JWT tokens stored in `localStorage`, with automatic 401 interception and redirect to login.
## License
Proprietary — EnCoach Platform. All rights reserved.

1222
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb Executable file

Binary file not shown.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

633
docs/04-AI-Stack-Report.md Normal file
View File

@@ -0,0 +1,633 @@
# EnCoach - AI Stack Technical Report
**Analysis Date:** March 8, 2026
---
## Table of Contents
1. [AI Stack Overview](#1-ai-stack-overview)
2. [OpenAI GPT (Content Generation & Grading)](#2-openai-gpt--content-generation--grading)
3. [OpenAI Whisper (Speech-to-Text)](#3-openai-whisper--speech-to-text)
4. [AWS Polly (Text-to-Speech)](#4-aws-polly--text-to-speech)
5. [FAISS + Sentence Transformers (RAG Training Tips)](#5-faiss--sentence-transformers--rag-training-tips)
6. [ELAI (AI Avatar Video Generation)](#6-elai--ai-avatar-video-generation)
7. [GPTZero (AI Writing Detection)](#7-gptzero--ai-writing-detection)
8. [End-to-End Data Flows](#8-end-to-end-data-flows)
9. [Frontend AI Integration Points](#9-frontend-ai-integration-points)
10. [Environment Variables & Configuration](#10-environment-variables--configuration)
---
## 1. AI Stack Overview
The EnCoach platform uses **6 AI/ML services** working together to power automated IELTS exam generation, grading, and personalized training.
```
┌─────────────────────────────────────────────────────────┐
│ Frontend (Next.js) │
│ ExamEditor, ExamPage, Training, AIDetection components │
└────────────────────────┬────────────────────────────────┘
│ HTTP (JWT)
┌─────────────────────────────────────────────────────────┐
│ Backend (FastAPI) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ OpenAI │ │ Whisper │ │ AWS Polly│ │
│ │ GPT-4o │ │ (local) │ │ (cloud) │ │
│ │ GPT-3.5 │ │ base │ │ neural │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ Content Gen Transcription TTS Audio │
│ Grading Speaking eval Listening MP3s │
│ Evaluation │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ FAISS + │ │ ELAI │ │ GPTZero │ │
│ │ SentTrans│ │ (cloud) │ │ (cloud) │ │
│ │ (local) │ │ avatars │ │ detect │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │ │
│ RAG Tips Avatar Videos AI Detection │
│ Training Speaking Writing eval │
│ │
└─────────────────────────────────────────────────────────┘
```
| Service | Type | Purpose | Model/Version |
|---|---|---|---|
| **OpenAI GPT** | Cloud API | Content generation, grading, evaluation | `gpt-4o`, `gpt-3.5-turbo` |
| **OpenAI Whisper** | Local (self-hosted) | Speech-to-text transcription | `base` (~1 GB, 4 instances) |
| **AWS Polly** | Cloud API | Text-to-speech for listening | Neural engine, 11 voices |
| **FAISS + Sentence Transformers** | Local (self-hosted) | RAG-based training tips | `all-MiniLM-L6-v2` + `IndexFlatL2` |
| **ELAI** | Cloud API | AI avatar video generation | ElevenLabs/Azure voices |
| **GPTZero** | Cloud API | AI-generated text detection | v2/predict/text |
---
## 2. OpenAI GPT — Content Generation & Grading
### 2.1 Configuration
| Setting | Value |
|---|---|
| **Default Model** | `gpt-4o` |
| **Secondary Model** | `gpt-3.5-turbo` |
| **Max Tokens** | 4,097 (300 reserved) |
| **Response Format** | `json_object` |
| **Retry Limit** | 2 (on blacklist or missing fields) |
| **Content Filter** | Blacklisted words (religious, sexual, political terms) |
**Temperature settings:**
| Context | Temperature | Behavior |
|---|---|---|
| Grading | 0.1 | Near-deterministic, consistent evaluation |
| Tips / Summaries | 0.2 | Low creativity, factual output |
| Content Generation | 0.7 | Higher creativity for passages and tasks |
### 2.2 Content Generation
GPT generates all IELTS exam content. Each module has specific prompt templates:
#### Reading Module
- **Model:** `gpt-4o`, temperature 0.7
- **Generates:** Passages with title and text body
- **Difficulty scaling:**
- Passage 1: easy
- Passage 2: hard
- Passage 3: very hard
- **Exercise types generated:** Fill in the blanks, True/False/Not Given, Matching headings, Multiple choice
- **Prompt pattern:** System prompt defines JSON schema → User prompt specifies passage difficulty, topic, and word count
#### Listening Module
- **Model:** `gpt-4o`, temperature 0.7
- **Generates:** Conversation scripts and monologues
- **Output format:**
- Conversations: `{"conversation": [{"name", "gender", "text"}]}` — 2 or 4 speakers
- Monologues: `{"monologue": "..."}` — social or academic context
- **Exercise types generated:** Same as reading (fill blanks, T/F/NG, matching, MC)
#### Writing Module
- **Task 1 (General):** Letter prompts — `gpt-3.5-turbo`
- **Task 1 (Academic):** Image-based prompts — `gpt-4o`
- **Task 2 (Essay):** Essay prompts — `gpt-4o`
#### Speaking Module
- **Model:** `gpt-4o`, temperature 0.7
- **Part 1:** 5 questions across 2 topics
- **Part 2:** 1 question + 3 follow-up prompts
- **Part 3:** 5 discussion questions
#### Level Test
- **Generates:** Multiple choice questions at varying difficulty levels
- **Supports:** Standard level, UTAS format, custom levels
### 2.3 Grading / Evaluation
GPT evaluates student answers using IELTS band scoring criteria:
#### Writing Grading
- **Model:** `gpt-4o`, temperature 0.1 (Task 1) / 0.7 (Task 2)
- **Runs in parallel:** Evaluation + Perfect Answer + Spelling Fix + GPTZero
- **Output JSON:**
```json
{
"comment": "Detailed commentary...",
"overall": 6.5,
"task_response": {
"Task Achievement": { "score": 7, "comment": "..." },
"Coherence and Cohesion": { "score": 6, "comment": "..." },
"Lexical Resource": { "score": 7, "comment": "..." },
"Grammatical Range and Accuracy": { "score": 6, "comment": "..." }
}
}
```
- **Perfect Answer:** GPT generates an ideal answer for comparison
- **Spelling Fix:** `gpt-3.5-turbo` at temperature 0.2 corrects transcription errors
#### Speaking Grading
- **Flow:** Audio → Whisper transcription → GPT-4o grading
- **Criteria:** Fluency & Coherence, Lexical Resource, Grammar, Pronunciation
- **Perfect Answers:** `gpt-4o` (Part 1), `gpt-3.5-turbo` (Parts 2/3)
#### Exam Summary
- **Model:** `gpt-3.5-turbo`, temperature 0.2
- **Uses:** OpenAI function calling (`save_evaluation_and_suggestions`)
- **Output:** Overall evaluation, suggestions, bullet points
### 2.4 Other GPT Uses
- **Training tips selection:** GPT selects relevant tips from FAISS results
- **Whisper overlap fix:** GPT-4o merges overlapping transcription segments
- **Short answer grading:** Grades fill-in-the-blank and short text answers
---
## 3. OpenAI Whisper — Speech-to-Text
### 3.1 Configuration
| Setting | Value |
|---|---|
| **Model** | `base` (~1 GB) |
| **Instances** | 4 (round-robin) |
| **Loading** | `in_memory=True` |
| **Concurrency** | `ThreadPoolExecutor` with 4 workers |
| **Language** | English |
| **Precision** | `fp16=False` |
### 3.2 How It Works
Whisper runs **locally** on the Cloud Run container (not via OpenAI's API). Four model instances are loaded into memory at startup and assigned to workers via round-robin.
**Processing pipeline:**
```
Audio Input
Resample to 16 kHz mono float32 (librosa)
Split into 30-second chunks (1/4 overlap)
Transcribe each chunk (Whisper base model)
Concatenate transcriptions
Fix overlapping text (GPT-4o removes duplicated words)
Final transcript
```
**Retry:** 3 attempts via `tenacity` library.
### 3.3 Where It's Connected
| Feature | Connection |
|---|---|
| **Speaking Grading** | Student audio → Whisper → transcript → GPT grading |
| **Audio Transcription** | Uploaded audio → Whisper → listening script for exam editor |
---
## 4. AWS Polly — Text-to-Speech
### 4.1 Configuration
| Setting | Value |
|---|---|
| **Engine** | Neural |
| **Output Format** | MP3 |
| **Region** | `eu-west-1` |
| **Max Chunk Size** | 3,000 characters (split at sentence boundaries) |
### 4.2 Available Voices
| Voice | Language/Accent |
|---|---|
| Danielle | American English |
| Gregory | American English |
| Kevin | American English |
| Ruth | American English |
| Stephen | American English |
| Arthur | British English |
| Olivia | Australian English |
| Ayanda | South African English |
| Aria | New Zealand English |
| Kajal | Indian English |
| Niamh | Irish English |
### 4.3 How It Works
**Listening exam audio generation:**
```
Generated Dialog/Monologue (from GPT)
Assign voices to speakers (random for monologue, per-speaker for dialog)
Split text into ≤3000 char chunks at sentence boundaries
AWS Polly Neural TTS → MP3 audio bytes
Concatenate audio segments
Upload to Firebase Storage
Return download URL
```
### 4.4 Where It's Connected
| Feature | Connection |
|---|---|
| **Listening Exam Audio** | GPT script → Polly TTS → MP3 → Firebase Storage → student playback |
| **Listening Instructions** | "Recording has now finished" scripts → Stephen voice → MP3 |
---
## 5. FAISS + Sentence Transformers — RAG Training Tips
### 5.1 Configuration
| Setting | Value |
|---|---|
| **Embeddings Model** | `all-MiniLM-L6-v2` (Sentence Transformers) |
| **Index Type** | `faiss.IndexFlatL2` (exact L2 search) |
| **Top-K** | 5 results per query |
| **Data Source** | `pathways_2_rw_with_ids.json` |
### 5.2 Knowledge Base Categories
| Category | Index File |
|---|---|
| `ct_focus` | `./faiss/ct_focus_tips_index.faiss` |
| `language_for_writing` | `./faiss/language_for_writing_tips_index.faiss` |
| `reading_skill` | `./faiss/reading_skill_tips_index.faiss` |
| `strategy` | `./faiss/strategy_tips_index.faiss` |
| `word_link` | `./faiss/word_link_tips_index.faiss` |
| `word_partners` | `./faiss/word_partners_tips_index.faiss` |
| `writing_skill` | `./faiss/writing_skill_tips_index.faiss` |
**Metadata:** `./faiss/tips_metadata.pkl` (pickle file with tip IDs and text)
### 5.3 How RAG Works
```
Student Exam Performance
GPT analyzes performance → generates queries (text + category)
Sentence Transformers encodes query → embedding vector
FAISS L2 search → top 5 matching tips per category
GPT selects most relevant tips from retrieved results
Tips stored in MongoDB and displayed to student
```
### 5.4 Where It's Connected
| Feature | Connection |
|---|---|
| **Training Module** | After exam → analyze weak areas → retrieve personalized tips → display training content |
| **Walkthrough** | Tips linked to specific reading/writing skills for guided learning |
---
## 6. ELAI — AI Avatar Video Generation
### 6.1 Configuration
| Setting | Value |
|---|---|
| **API Endpoint** | `https://apis.elai.io/api/v1/videos` |
| **Auth** | Bearer token (`ELAI_TOKEN`) |
| **Animation** | `fade_in` |
| **Language** | English |
### 6.2 Available Avatars
| Avatar | Gender | Voice Provider |
|---|---|---|
| Gia | Female | ElevenLabs |
| Vadim | Male | Azure |
| Orhan | Male | ElevenLabs |
| Flora | Female | Azure |
| Scarlett | Female | ElevenLabs |
| Parker | Male | Azure |
| Ethan | Male | ElevenLabs |
Each avatar has a unique `avatar_code`, `avatar_url`, `avatar_canvas` dimensions, `voice_id`, and `voice_provider`.
### 6.3 How It Works
```
Speaking Task Text (from GPT)
Select Avatar (user choice from available list)
Build video config (slide, avatar, canvas, logo, voice settings)
POST to ELAI API → create video
POST render request → start processing
Poll GET status every 10 seconds until "ready"
Return video URL for playback
```
### 6.4 Where It's Connected
| Feature | Connection |
|---|---|
| **Speaking Exam** | AI avatar presents speaking questions via video |
| **Exam Editor** | Teachers generate speaking videos while creating exams |
---
## 7. GPTZero — AI Writing Detection
### 7.1 Configuration
| Setting | Value |
|---|---|
| **API Endpoint** | `https://api.gptzero.me/v2/predict/text` |
| **Auth** | `x-api-key` header |
| **Multilingual** | `false` |
### 7.2 How It Works
```
Student's Writing Submission
POST to GPTZero API with document text
Response: predicted_class, confidence, per-sentence AI probability
Returned as part of writing evaluation
Frontend displays AI Detection component
```
**Response fields:**
- `predicted_class`: `ai`, `mixed`, or `human`
- `confidence_category`: confidence level
- `class_probabilities`: probability distribution
- `sentences`: per-sentence analysis with `highlight_sentence_for_ai` flag
### 7.3 Where It's Connected
| Feature | Connection |
|---|---|
| **Writing Grading** | Runs in parallel with GPT evaluation, perfect answer, and spelling fix |
| **Frontend UI** | `AIDetection` component displays results with radial progress, segmented bars, and highlighted AI-generated sentences |
---
## 8. End-to-End Data Flows
### 8.1 Exam Generation Flow
```
Teacher clicks "Generate" in Exam Editor
Frontend: POST /api/exam/generate/{module}/{sectionId}
Next.js API Route: proxies to BACKEND_URL/{module}/...
FastAPI Controller → Service
├── Reading: GPT-4o generates passage + exercises
├── Listening: GPT-4o generates script → Polly TTS → MP3 → Firebase
├── Writing: GPT-4o/3.5 generates task prompt
└── Speaking: GPT-4o generates questions → ELAI creates avatar video
Response with generated content → stored in exam editor state
```
### 8.2 Writing Grading Flow
```
Student submits writing answer
Frontend: POST /api/evaluate/writing
Next.js API: inserts pending evaluation in MongoDB → proxies to backend
FastAPI runs 4 tasks IN PARALLEL:
├── GPT-4o: Grade against IELTS band criteria (temp 0.1)
├── GPT-4o: Generate perfect answer for comparison
├── GPT-3.5: Fix spelling/transcription errors (temp 0.2)
└── GPTZero: Detect AI-generated content
Combined result stored in MongoDB evaluation collection
Frontend polls /api/evaluate/status until complete
UI shows: band scores, detailed comments, perfect answer, AI detection
```
### 8.3 Speaking Grading Flow
```
Student records audio response
Frontend: POST /api/evaluate/speaking (FormData with audio)
Next.js API: inserts pending evaluation → proxies to backend
FastAPI pipeline:
Whisper (local): Transcribe audio → text
GPT-4o: Grade transcript against IELTS speaking criteria (temp 0.1)
GPT-4o/3.5: Generate perfect answer
Result stored in MongoDB
Frontend polls and displays scores + transcript + perfect answer
```
### 8.4 Training / Personalized Tips Flow
```
Student completes an exam
Frontend: POST /api/training
Backend analyzes exam performance with GPT
GPT generates search queries + categories
Sentence Transformers encodes queries → FAISS L2 search
Top 5 tips per category retrieved
GPT selects most relevant tips
Training content stored in MongoDB → displayed to student
```
---
## 9. Frontend AI Integration Points
### 9.1 API Routes (Next.js → FastAPI)
| Frontend Route | Backend Route | AI Services Used |
|---|---|---|
| `POST /api/evaluate/writing` | `BACKEND_URL/grade/writing/{task}` | GPT-4o, GPT-3.5, GPTZero |
| `POST /api/evaluate/speaking` | `BACKEND_URL/grade/speaking/{task}` | Whisper, GPT-4o, GPT-3.5 |
| `GET /api/exam/generate/reading/{id}` | `BACKEND_URL/reading/{passage}` | GPT-4o |
| `GET /api/exam/generate/listening/{id}` | `BACKEND_URL/listening/{section}` | GPT-4o |
| `GET /api/exam/generate/writing/{id}` | `BACKEND_URL/writing/{task}` | GPT-4o / GPT-3.5 |
| `GET /api/exam/generate/speaking/{id}` | `BACKEND_URL/speaking/{task}` | GPT-4o |
| `POST /api/exam/media/listening` | `BACKEND_URL/listening/media` | AWS Polly |
| `POST /api/exam/media/speaking` | `BACKEND_URL/speaking/media` | ELAI |
| `POST /api/transcribe` | `BACKEND_URL/listening/transcribe` | Whisper |
| `POST /api/training` | `BACKEND_URL/training/` | GPT, FAISS, Sentence Transformers |
| `GET /api/exam/avatars` | `BACKEND_URL/speaking/avatars` | ELAI |
### 9.2 Key UI Components
| Component | Purpose |
|---|---|
| `AIDetection.tsx` | Displays AI detection results (radial progress, highlighted sentences) |
| `GenerateBtn.tsx` | Brain icon button with spinner for content generation |
| `generateVideos.ts` | Manages ELAI video creation + polling loop |
| `ExamPage.tsx` | Triggers writing/speaking evaluation + polls for results |
| `useEvaluationPolling.tsx` | Hook that polls evaluation status until grading completes |
| `generation.tsx` | Page for generating exams (gated by permissions) |
### 9.3 Permissions
| Permission | Controls |
|---|---|
| `generate_reading` | Reading passage generation |
| `generate_listening` | Listening script generation |
| `generate_writing` | Writing prompt generation |
| `generate_speaking` | Speaking task generation |
| `generate_level` | Level test generation |
---
## 10. Environment Variables & Configuration
### 10.1 Required API Keys
| Variable | Service | Where Used |
|---|---|---|
| `OPENAI_API_KEY` | OpenAI GPT-4o / GPT-3.5 | Content generation, grading, evaluation |
| `AWS_ACCESS_KEY_ID` | AWS Polly | Text-to-speech |
| `AWS_SECRET_ACCESS_KEY` | AWS Polly | Text-to-speech |
| `ELAI_TOKEN` | ELAI | Avatar video generation |
| `GPT_ZERO_API_KEY` | GPTZero | AI writing detection |
### 10.2 Backend Service Wiring (Dependency Injection)
```
DI Container
├── llm → OpenAI(client=AsyncOpenAI)
├── tts → AWSPolly(client=polly_client)
├── stt → OpenAIWhisper(model="base", num_models=4)
├── vid_gen → ELAI(client=http_client, token, avatars, conf)
├── ai_detector → GPTZero(client=http_client, key)
├── training_kb → TrainingContentKnowledgeBase(embeddings=SentenceTransformer)
├── Controllers
│ ├── ReadingController → uses llm
│ ├── ListeningController → uses llm, tts
│ ├── WritingController → uses llm, ai_detector
│ ├── SpeakingController → uses llm, stt, vid_gen
│ ├── GradeController → uses llm, stt, ai_detector
│ ├── LevelController → uses llm
│ └── TrainingController → uses llm, training_kb
```
### 10.3 Cost Drivers
| Service | Cost Model | Usage Pattern |
|---|---|---|
| **OpenAI GPT-4o** | Per token (input + output) | Every generation, every grading — highest cost |
| **OpenAI GPT-3.5** | Per token (cheaper) | Summaries, spelling, some writing tasks |
| **AWS Polly** | Per character (Neural) | Every listening exam audio |
| **ELAI** | Per video minute | Every speaking exam video |
| **GPTZero** | Per API call | Every writing grading |
| **Whisper (local)** | Compute only (no API cost) | Every speaking grading + transcription |
| **FAISS (local)** | Compute only (no API cost) | Every training session |

344
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,344 @@
# EnCoach Platform -- Architecture & Connectivity Document
## Overview
EnCoach is an **IELTS preparation and English proficiency testing platform** composed of four repositories that work together as a distributed system. The platform supports exam taking, AI-powered grading, training content, multi-tenant entity management, payments, and a public-facing marketing website.
---
## Repositories at a Glance
- **[ielts-ui](ielts-ui)** -- Full-stack Next.js 14 app. The main platform for students, teachers, admins, and corporate users. Serves both the browser UI and internal API routes.
- **[ielts-be](ielts-be)** -- Python/FastAPI backend. Handles AI/ML workloads: exam generation, grading (writing/speaking), audio transcription, TTS, and training content.
- **[encoachcms](encoachcms)** -- Strapi v4 headless CMS. Manages bilingual (EN/AR) marketing content for the landing page.
- **[encoach-landing-page](encoach-landing-page)** -- Next.js 13 marketing website. Consumes CMS content and links users into the main platform.
---
## System Architecture Diagram
```mermaid
graph TB
subgraph publicFacing ["Public-Facing Layer"]
LP["encoach-landing-page<br/>(Next.js 13)"]
CMS["encoachcms<br/>(Strapi v4)"]
end
subgraph platformCore ["Platform Core"]
UI["ielts-ui<br/>(Next.js 14 + API Routes)"]
BE["ielts-be<br/>(FastAPI / Python)"]
end
subgraph dataStores ["Data Stores"]
MongoDB["MongoDB"]
MySQL["MySQL<br/>(CMS)"]
FirebaseAuth["Firebase Auth"]
FirebaseStorage["Firebase Storage"]
GCS["Google Cloud Storage<br/>(CMS uploads)"]
end
subgraph externalServices ["External Services"]
OpenAI["OpenAI GPT-4o"]
Whisper["OpenAI Whisper<br/>(local)"]
Polly["AWS Polly<br/>(TTS)"]
ELAI["ELAI<br/>(AI Video)"]
GPTZero["GPTZero<br/>(AI Detection)"]
Stripe["Stripe"]
PayPal["PayPal"]
Paymob["Paymob"]
end
LP -->|"REST + Bearer token"| CMS
LP -->|"GET /api/packages, POST /api/tickets"| UI
LP -->|"Links to platform.encoach.com"| UI
CMS --> MySQL
CMS --> GCS
UI --> MongoDB
UI --> FirebaseAuth
UI -->|"Proxy: Bearer JWT"| BE
BE --> MongoDB
BE --> FirebaseAuth
BE --> FirebaseStorage
BE --> OpenAI
BE --> Whisper
BE --> Polly
BE --> ELAI
BE --> GPTZero
UI --> Stripe
UI --> PayPal
UI --> Paymob
LP --> Stripe
```
---
## 1. ielts-ui (Main Platform)
**Tech:** Next.js 14, React 18, TypeScript, Tailwind CSS + DaisyUI, Zustand, SWR, iron-session, Firebase Auth, MongoDB (direct)
**Role:** The central application. Serves the browser UI and acts as a **BFF (Backend-for-Frontend)** via Next.js API routes. It directly queries MongoDB for user/entity/group data and proxies AI-related requests to `ielts-be`.
### Key Modules
| Module | Description |
|--------|-------------|
| **Auth** | Firebase email/password auth + iron-session cookies. SSR-protected via `withIronSessionSsr`. |
| **Dashboards** | Role-based: student, teacher, admin, corporate, mastercorporate, developer, agent. |
| **Exams** | Taking exams (Reading, Listening, Writing, Speaking, Level). Zustand store tracks session, progress, solutions. |
| **Exam Editor** | Create/edit exams with dedicated components under `src/components/ExamEditor/`. |
| **Training** | Training content consumed from `ielts-be`. |
| **Grading** | Writing and speaking grading proxied to `ielts-be` as background tasks; UI polls for results. |
| **Entities** | Multi-tenant entity management (schools, organizations). |
| **Classrooms** | Classroom and group management for teachers. |
| **Assignments** | Assign exams to students with deadlines. |
| **Stats** | Performance statistics and PDF report generation (via `@react-pdf/renderer`). |
| **Payments** | Stripe, PayPal, Paymob webhook handlers in API routes. |
| **Tickets** | Support ticket system. |
| **User Mgmt** | Bulk import, permissions, role-based access. |
### How it Connects to ielts-be
API routes in `src/pages/api/` proxy to `BACKEND_URL` with `Authorization: Bearer ${BACKEND_JWT}`:
| UI API Route | Backend Endpoint |
|--------------|------------------|
| `/api/evaluate/writing` | `BACKEND_URL/grade/writing/{task}` |
| `/api/evaluate/speaking` | `BACKEND_URL/grade/speaking/{task}` |
| `/api/transcribe` | `BACKEND_URL/listening/transcribe` |
| `/api/training` | `BACKEND_URL/training/` |
| `/api/exam/upload` | `BACKEND_URL/{endpoint}` |
| `/api/exam/media/*` | `BACKEND_URL/{endpoint}/media` |
| `/api/exam/generate/*` | `BACKEND_URL/{endpoint}` |
| `/api/exam/avatars` | `BACKEND_URL/speaking/avatars` |
| `/api/exam/[module]/import` | `BACKEND_URL/{module}/import` |
| `/api/stats/[id]/[export]/pdf` | `BACKEND_URL/grade/summary` |
| `/api/batch_users` | `BACKEND_URL/user/import` |
### How it Connects to the Landing Page
Exposes public-ish API routes consumed by the landing page:
- `GET /api/packages` -- subscription packages (CORS-enabled)
- `POST /api/tickets` -- contact form submissions (CORS-enabled)
- `GET /api/users/agents` -- list sales agents (CORS-enabled)
---
## 2. ielts-be (AI/ML Backend)
**Tech:** Python 3.11, FastAPI, Motor (async MongoDB), Poetry, dependency-injector, OpenAI, Whisper, AWS Polly, ELAI, FAISS
**Role:** Handles all AI/ML-intensive operations -- exam content generation, grading, transcription, text-to-speech, AI video, and training recommendations. Runs as a standalone service on port 8000.
### Architecture Layers
```
API Routes --> Controllers --> Services --> Repositories
|
Third-Party Services
(OpenAI, Whisper, Polly, ELAI, GPTZero)
```
Wired together via `dependency-injector`.
### Key Capabilities
| Capability | Implementation |
|------------|----------------|
| **Writing Grading** | GPT-4o evaluates against IELTS rubric (Task Achievement, Coherence, Lexical Resource, Grammar). GPTZero checks for AI-generated text. Runs as background task; results stored in `evaluation` collection. |
| **Speaking Grading** | Whisper transcribes audio, then GPT grades (Fluency, Lexical, Grammar, Pronunciation). Parts 1/2/3 supported. |
| **Exam Generation** | GPT generates reading passages, listening dialogs, writing prompts, speaking tasks, and level-test exercises. |
| **Audio (TTS)** | AWS Polly synthesizes MP3 for listening sections. |
| **Video** | ELAI generates AI avatar videos for speaking prompts. |
| **Transcription** | Whisper (local model) for speech-to-text. |
| **Training** | FAISS + sentence-transformers for semantic search over tips; GPT for personalized recommendations. |
| **Short Answer Grading** | GPT-4o for reading/listening fill-in-the-blank answers. |
### Authentication
All endpoints (except `/api/healthcheck`) require a Bearer JWT token validated with `JWT_SECRET_KEY` (HS256). The `ielts-ui` sends this as `BACKEND_JWT`.
---
## 3. encoachcms (Content Management)
**Tech:** Strapi v4.21, Node.js 18, MySQL (prod) / SQLite (dev), Google Cloud Storage for uploads
**Role:** Headless CMS for managing the marketing website content. Editors create/update bilingual (EN/AR) content via the Strapi admin panel at `/admin`.
### Content Types (all Single Types)
`home`, `about`, `services`, `contact`, `footer`, `nav-bar`, `history`, `price`, `terms-and-conditions`, `privacy-policy`, `country-managers-contact`
### How it Connects
- Exposes REST API at `STRAPI_URL/api/{content-type}/?populate=deep&locale={en|ar}`
- Authenticated via API token (`STRAPI_TOKEN`)
- **Only consumed by `encoach-landing-page`** -- neither `ielts-ui` nor `ielts-be` use it
### Plugins
- `strapi-plugin-populate-deep` -- deep relation population
- `strapi-plugin-import-export-entries` -- content import/export
- `strapi-plugin-publisher` -- scheduled publishing
- `@strapi/plugin-i18n` -- internationalization
- `@strapi/plugin-documentation` -- OpenAPI docs
---
## 4. encoach-landing-page (Marketing Website)
**Tech:** Next.js 13 (App Router), TypeScript, Tailwind CSS + DaisyUI, Stripe
**Role:** Public marketing website at `encoach.com`. Bilingual (EN at `/`, AR at `/ar/`). Fetches content from the CMS and links users into the main platform.
### Pages
`/` (Home), `/about`, `/services`, `/price`, `/history`, `/contact`, `/terms`, `/privacy-policy`, `/contacts/[country]`, `/success` -- all mirrored under `/ar/` for Arabic.
### How it Connects
| Target | Connection |
|--------|------------|
| **encoachcms** | `getData()` fetches `STRAPI_URL/api/{page}/?populate=deep&locale={locale}` with Bearer token |
| **ielts-ui** | `GET /api/packages` for pricing, `POST /api/tickets` for contact form, `GET /api/users/agents` for sales agents |
| **Stripe** | Checkout flow; `/api/success` route validates session and POSTs to `WEBHOOK_URL` |
| **Platform links** | Buttons link to `platform.encoach.com/register`, `platform.encoach.com/official-exam` |
---
## Data Flow Diagrams
### Authentication Flow
```mermaid
sequenceDiagram
participant Browser
participant UI as ielts-ui
participant Firebase as Firebase Auth
participant Mongo as MongoDB
Browser->>UI: POST /api/login (email, password)
UI->>Firebase: signInWithEmailAndPassword
Firebase-->>UI: Firebase UID + token
UI->>Mongo: Find user by Firebase UID
Mongo-->>UI: User document
UI->>UI: Save to iron-session cookie
UI-->>Browser: Set-Cookie (eCrop/ielts)
Browser->>UI: Subsequent requests with cookie
UI->>UI: withIronSessionSsr validates cookie
```
### Exam Grading Flow (Writing)
```mermaid
sequenceDiagram
participant Student
participant UI as ielts-ui API Route
participant BE as ielts-be
participant GPT as OpenAI GPT-4o
participant GPTZero as GPTZero
participant Mongo as MongoDB
Student->>UI: Submit writing answer
UI->>BE: POST /api/exam/grade/writing/{task}
BE->>BE: Start background task
BE-->>UI: 200 OK (accepted)
UI-->>Student: "Grading in progress"
par Parallel Grading
BE->>GPT: Evaluate against IELTS rubric
GPT-->>BE: Scores + feedback
and AI Detection
BE->>GPTZero: Check for AI content
GPTZero-->>BE: AI probability
end
BE->>Mongo: Save evaluation result
Student->>UI: Poll for result
UI->>Mongo: Query evaluation
Mongo-->>UI: Grading result
UI-->>Student: Display scores + feedback
```
### Landing Page Content Flow
```mermaid
sequenceDiagram
participant Visitor
participant LP as encoach-landing-page
participant CMS as encoachcms (Strapi)
participant Platform as ielts-ui
Visitor->>LP: Visit encoach.com
LP->>CMS: GET /api/home/?populate=deep&locale=en
CMS-->>LP: Home content (JSON)
LP-->>Visitor: Rendered page
Visitor->>LP: View pricing
LP->>CMS: GET /api/price/?populate=deep&locale=en
LP->>Platform: GET /api/packages
Platform-->>LP: Package list
LP-->>Visitor: Pricing page with packages
Visitor->>LP: Submit contact form
LP->>Platform: POST /api/tickets
Platform-->>LP: Ticket created
```
---
## Shared Infrastructure
| Resource | Used By | Details |
|----------|---------|---------|
| **MongoDB** | ielts-ui, ielts-be | Same database (`MONGODB_URI` / `MONGODB_DB`). UI reads users, groups, stats directly. BE reads/writes evaluations, training. |
| **Firebase Auth** | ielts-ui, ielts-be | Shared Firebase project. UI handles login/signup; BE imports users. |
| **Firebase Storage** | ielts-be | Stores exam media (audio, images). |
| **Google Cloud Storage** | encoachcms | CMS file uploads (images, media). |
---
## Deployment Topology
| Service | Port | URL (Production) |
|---------|------|-------------------|
| ielts-ui | 3000 | `platform.encoach.com` |
| ielts-be | 8000 | Internal (proxied by ielts-ui) |
| encoachcms | 1337 | Internal (consumed by landing page) |
| encoach-landing-page | 3000 | `encoach.com` |
Both Next.js apps use `output: "standalone"` and have Dockerfiles. The BE is not directly exposed to the internet -- it sits behind ielts-ui's API route proxy layer.
---
## User Roles
| Role | Access |
|------|--------|
| **student** | Take exams, view stats, training, assignments |
| **teacher** | Manage classrooms, assignments, view student performance |
| **admin** | Full platform management, user management, entities |
| **corporate** | Corporate dashboard, bulk operations |
| **mastercorporate** | Multi-entity corporate management |
| **developer** | Developer dashboard, system tools |
| **agent** | Sales agent, visible on landing page |
---
## Key Environment Variables (Cross-Service)
| Variable | Service | Purpose |
|----------|---------|---------|
| `MONGODB_URI` / `MONGODB_DB` | ielts-ui, ielts-be | Shared MongoDB |
| `BACKEND_URL` | ielts-ui | URL of ielts-be |
| `BACKEND_JWT` / `JWT_SECRET_KEY` | ielts-ui / ielts-be | Shared JWT secret for service-to-service auth |
| `FIREBASE_*` | ielts-ui, ielts-be | Shared Firebase project credentials |
| `STRAPI_URL` / `STRAPI_TOKEN` | encoach-landing-page | CMS connection |
| `STRIPE_KEY` / `STRIPE_SECRET` | ielts-ui, encoach-landing-page | Payment processing |
| `OPENAI_API_KEY` | ielts-be | AI grading and generation |
| `AWS_ACCESS_KEY_ID/SECRET` | ielts-be | AWS Polly TTS |

249
docs/DEVELOPER_FEEDBACK.md Normal file
View File

@@ -0,0 +1,249 @@
# EnCoach Odoo 19 -- Developer Feedback
**Date:** March 11, 2026
**Status:** ALL ITEMS RESOLVED (March 13, 2026)
**Reference:** ODOO_MIGRATION_SRS_v2.md
---
## Overall Assessment
The delivery covers approximately **85% of the SRS v2 requirements**. All 15 custom modules are present, the core AI/ML services are well-implemented, data models match the specification, and 67-72 API endpoints are wired up with proper JWT authentication and security groups. The code quality is solid -- clean structure, proper error handling, and good separation of concerns.
The items below still need to be implemented to reach full SRS compliance.
---
## 1. Payment Integration (Critical -- Not Implemented)
**SRS Reference:** Section 7
All three payment services are currently stubs that return `"not yet implemented"`. The SRS requires full integration with:
### 1.1 Stripe
**Files:** `encoach_subscription/services/stripe_service.py`
Required functionality:
- Install and use the `stripe` Python SDK
- `create_checkout_session()` -- Create a Stripe Checkout Session using `stripe.checkout.Session.create()` with:
- `line_items` from the selected `encoach.package`
- `success_url` and `cancel_url` from the request
- `client_reference_id` = user ID
- `customer_email` = user email
- Apply discount if `discount_code` is provided (validate against `encoach.discount`)
- Return `{ "sessionId": "cs_...", "url": "https://checkout.stripe.com/..." }`
- **Webhook endpoint** (`POST /api/stripe/webhook`):
- Verify signature using `stripe.Webhook.construct_event()` with `STRIPE_WEBHOOK_SECRET`
- Handle `checkout.session.completed` event:
- Find user by `client_reference_id`
- Extend `subscription_expiration` by the package's `duration_days`
- Create `encoach.subscription.payment` record
- For corporate users, propagate subscription to entity members
### 1.2 PayPal
**Files:** `encoach_subscription/services/paypal_service.py`
Required functionality:
- Use PayPal REST API (`httpx` or `requests`)
- Obtain access token from `PAYPAL_ACCESS_TOKEN_URL` using `client_id` + `client_secret`
- `create_order()` -- Create PayPal order via `POST /v2/checkout/orders`:
- `intent: "CAPTURE"`
- `purchase_units` with amount from package (minus discount)
- Return `{ "orderId": "...", "approvalUrl": "https://paypal.com/..." }`
- `capture_order()` -- Capture approved order via `POST /v2/checkout/orders/{id}/capture`:
- On success, extend subscription and create payment record
- Return `{ "ok": true }`
### 1.3 Paymob
**Files:** `encoach_subscription/services/paymob_service.py`
Required functionality:
- Use Paymob API (`httpx` or `requests`)
- `create_intention()` -- Create payment intention:
- Authenticate with `PAYMOB_API_KEY`
- Create order, then payment key
- Return `{ "intentionId": "...", "clientSecret": "..." }`
- `verify_transaction()` -- Verify webhook callback:
- Validate HMAC using `PAYMOB_SECRET`
- On success, extend subscription and create payment record
- **Webhook endpoint** (`POST /api/paymob/webhook`):
- Verify transaction authenticity
- Update subscription on success
### 1.4 Subscription Extension Logic
After any successful payment (all providers), the system must:
1. Calculate new expiration: `max(current_expiration, now()) + duration_days`
2. Update `user.subscription_expiration`
3. Create `encoach.subscription.payment` record with provider, amount, transaction ID
4. For corporate users: propagate the new expiration to all entity members
---
## 2. Missing API Endpoints
**SRS Reference:** Section 4
The following endpoints are specified in the SRS but not present in the delivery:
### 2.1 Exam Variants (add to `encoach_api/controllers/generation.py` or `exams.py`)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `POST /api/exam/writing/<task>/attachment` | POST | Academic writing Task 1 with image upload (multipart form with `file` + `difficulty`). Must send image to GPT-4o vision API for prompt generation. |
| `GET /api/exam/level/` | GET | Return a pre-built level exam (no AI generation) |
| `GET /api/exam/level/utas` | GET | Return a UTAS-format level exam |
| `POST /api/exam/level/import/` | POST | Import level exam from uploaded file |
| `POST /api/exam/level/custom/` | POST | Generate custom level exam from JSON specification |
| `POST /api/exam/reading/import` | POST | Import reading exam from Word/Excel file |
| `POST /api/exam/listening/import` | POST | Import listening exam from file |
### 2.2 Entity/Role/Permission Management (new controller files needed)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `GET /api/entities` | GET | List entities (with pagination) |
| `POST /api/entities` | POST | Create entity |
| `PATCH /api/entities/<id>` | PATCH | Update entity |
| `DELETE /api/entities/<id>` | DELETE | Delete entity |
| `GET /api/roles` | GET | List roles (filter by entityID) |
| `POST /api/roles` | POST | Create role |
| `PATCH /api/roles/<id>` | PATCH | Update role |
| `DELETE /api/roles/<id>` | DELETE | Delete role |
| `GET /api/permissions` | GET | List permissions |
### 2.3 Other Missing Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `GET /api/discounts` | GET | List discount codes |
| `POST /api/discounts` | POST | Create discount code |
| `PATCH /api/discounts/<id>` | PATCH | Update discount code |
| `DELETE /api/discounts/<id>` | DELETE | Delete discount code |
| `GET /api/approval-workflows` | GET | List approval workflows |
| `POST /api/approval-workflows` | POST | Create approval workflow |
| `PATCH /api/approval-workflows/<id>` | PATCH | Update approval workflow |
| `DELETE /api/approval-workflows/<id>` | DELETE | Delete approval workflow |
---
## 3. Evaluation Status Endpoint Path
**SRS Reference:** Section 4.2
**Current:** `GET /api/evaluate/status?sessionId=...&exerciseId=...` (query params)
**SRS specifies:** `GET /api/evaluate/<sessionId>/<exerciseId>` (path params)
The frontend SRS document (`ODOO_MIGRATION_FRONTEND_SRS.md`) references the path-param version. Please either:
- (A) Change the endpoint to use path params to match the SRS, OR
- (B) Confirm that query params are intentional so we can update the frontend SRS
---
## 4. Record-Level Security Rules (`ir.rule`)
**SRS Reference:** Section 5
The delivery has model-level ACL rules (`ir.model.access.csv`) for each module, which is good. However, record-level security rules (`ir.rule`) are also needed to ensure:
- **Students** can only read/write their own records (sessions, stats, evaluations, training, tickets)
- **Teachers** can see records for students in their groups/assignments
- **Corporate** users can see records within their entity
- **Admin/Developer** users have unrestricted access
Example rules needed (add to each module's `security/` directory):
```xml
<!-- encoach_stats/security/rules.xml -->
<record id="rule_stat_student_own" model="ir.rule">
<field name="name">Students see own stats</field>
<field name="model_id" ref="model_encoach_stat"/>
<field name="groups" eval="[(4, ref('encoach_core.group_encoach_student'))]"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
```
Models that need record rules:
- `encoach.session` -- students see own sessions only
- `encoach.stat` -- students see own stats only
- `encoach.evaluation` -- students see own evaluations only
- `encoach.training` -- students see own training only
- `encoach.ticket` -- students see own tickets, admins see all
- `encoach.assignment` -- students see assignments they're assigned to
- `encoach.exam` -- filtered by access field (public/private/entity)
---
## 5. Whisper Scaling
**SRS Reference:** Section 6.5
**Current:** Single lazy-loaded Whisper model instance in `whisper_service.py`
**SRS specifies:** 4 model instances with `ThreadPoolExecutor(max_workers=4)` for parallel transcription
Update `encoach_ai_media/services/whisper_service.py` to:
1. Load the configurable number of model instances (from `encoach.whisper_workers` system parameter, default 4)
2. Use `concurrent.futures.ThreadPoolExecutor` to process transcription requests in parallel
3. Round-robin or queue-based assignment of requests to model instances
This is important for production performance when multiple students submit speaking exams simultaneously.
---
## 6. FAISS Index Type
**SRS Reference:** Section 6.8
**Current:** `faiss.IndexFlatIP` (inner product)
**SRS specifies:** `faiss.IndexFlatL2` (L2/Euclidean distance)
The current implementation normalizes vectors and uses inner product, which effectively gives cosine similarity. This works correctly, but it's a deviation from the SRS. If the original `ielts-be` training data was built with L2 distance, the results may differ slightly. Please verify which index type the original system used and align accordingly.
---
## 7. Payment Webhook Endpoints
**SRS Reference:** Section 7
In addition to the payment service implementations (Item 1 above), the following webhook controller routes need to be added to `encoach_api/controllers/subscriptions.py`:
```python
@http.route('/api/stripe/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
def stripe_webhook(self, **kwargs):
"""Handle Stripe webhook events (checkout.session.completed, etc.)."""
...
@http.route('/api/paymob/webhook', type='http', auth='public', methods=['POST'], csrf=False, cors='*')
def paymob_webhook(self, **kwargs):
"""Handle Paymob transaction callbacks."""
...
```
These must be `auth='public'` (no JWT) since they're called by external services.
---
## Summary of Action Items
| # | Item | Priority | Status |
|---|------|----------|--------|
| 1 | Payment integration (Stripe/PayPal/Paymob) | **Critical** | RESOLVED |
| 2 | Missing API endpoints (~20 endpoints) | **High** | RESOLVED |
| 3 | Evaluation status endpoint path alignment | **Medium** | RESOLVED |
| 4 | `ir.rule` record-level security rules | **High** | RESOLVED |
| 5 | Whisper multi-instance scaling | **Medium** | RESOLVED |
| 6 | FAISS index type verification | **Low** | RESOLVED |
| 7 | Payment webhook endpoints | **Critical** | RESOLVED |
---
## Changes Already Applied
The following small fixes have been applied directly to the codebase:
1. **`encoach_ai_media/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `openai-whisper`, `librosa`, `soundfile`, `httpx`
2. **`encoach_training/__manifest__.py`** -- Added missing `external_dependencies`: `numpy`, `faiss-cpu`, `sentence-transformers`
3. **`requirements.txt`** -- Created at project root with all Python dependencies and versions from SRS Section 6.10

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,186 @@
# EnCoach -- Product Description
**Prepared for:** UTAS University Management
**Date:** March 24, 2026
**Version:** 1.0
**Classification:** Client-Facing
---
## 1. Executive Summary
EnCoach is an AI-powered adaptive learning platform designed to deliver personalized education across multiple academic subjects. The platform automatically assesses each student's current knowledge level, generates a tailored learning plan, delivers the right content at the right time, and continuously adjusts as the student progresses. EnCoach serves two distinct audiences -- **UTAS-enrolled students** who follow university curriculum with AI-enhanced guidance, and **freelance learners** worldwide who access fully AI-driven self-paced programs. The platform launches with three subjects -- **IELTS/English, Mathematics, and Information Technology** -- and is designed to accommodate any additional subject without rebuilding the system.
---
## 2. Product Vision
### The Problem
Traditional learning platforms deliver the same material to every student regardless of their existing knowledge. Students waste time reviewing topics they already understand, while critical gaps in their foundation go unaddressed. Teachers lack visibility into individual student needs at scale, and institutions struggle to provide personalized learning outside the classroom.
### The Solution
EnCoach combines a proven Learning Management System with an intelligent adaptive engine that treats every student as an individual. The platform:
- **Discovers what each student knows** through an AI-driven diagnostic assessment
- **Builds a personalized roadmap** that prioritizes weak areas while respecting how topics build on each other
- **Delivers the right content** -- human-curated materials when available, AI-generated explanations when not
- **Adapts in real time** -- as students learn faster or struggle, the plan adjusts automatically
- **Provides an AI coaching companion** available on every page for hints, explanations, and study advice
### Strategic Value for UTAS
| Value | Description |
|-------|-------------|
| **Scalable personalization** | Every UTAS student receives an individualized learning experience without increasing teaching staff |
| **Multi-subject from day one** | English, Math, and IT share a single platform; new subjects can be added by defining a topic structure |
| **Revenue expansion** | Freelance students outside UTAS generate subscription revenue on the same platform |
| **Data-driven insights** | Real-time analytics show administrators exactly where students succeed or struggle, per topic, per class, per department |
| **Quality assurance** | AI-generated content is supplementary -- academic staff control the primary learning materials and approve AI outputs |
---
## 3. Key Capabilities
### 3.1 Adaptive Learning Engine
The core innovation of EnCoach. When a student begins a new subject, the platform runs an intelligent diagnostic that adapts in real time -- asking harder questions when answers are correct and easier ones when they are not. Within minutes, the system maps the student's strengths and weaknesses across every topic in the subject.
From this assessment, the AI generates a **personalized learning plan**: a sequenced path through topics the student needs to study, ordered so that foundational concepts are learned before advanced ones. As the student works through the plan, short mastery quizzes confirm understanding before moving on. If a student struggles, the system provides additional resources and intensifies AI coaching. If they progress quickly, the plan accelerates.
**For UTAS students**, the learning plan aligns with the university's curriculum and semester schedule. Teachers can view and override individual plans. **For freelance students**, the AI generates the complete plan independently based on the student's goals and timeline.
### 3.2 Multi-Subject Support
EnCoach is not a single-purpose English testing tool -- it is a universal education platform. Every subject is organized into the same clear hierarchy:
- **Subject** (e.g., Mathematics)
- **Domain** (e.g., Algebra)
- **Topic** (e.g., Linear Equations)
This structure supports any academic discipline. The platform launches with:
| Subject | Coverage |
|---------|----------|
| **IELTS / English** | Reading, Listening, Writing, Speaking, Grammar, Vocabulary, Pronunciation |
| **Mathematics** | Arithmetic, Algebra, Geometry, Statistics & Probability, Calculus |
| **Information Technology** | Computer Fundamentals, Networking, Databases, Programming, Cybersecurity |
Adding a new subject (e.g., Arabic, Physics, Business) requires only defining its topic structure -- the adaptive engine, exam system, grading, and analytics apply automatically.
### 3.3 Learning Management System
A full-featured LMS for structured academic delivery:
- **Course management** -- Create courses with modules and lessons, assign instructors, set enrollment limits
- **Student enrollment** -- UTAS students enrolled automatically via SIS sync; freelance students self-enroll
- **Batches** -- Group students for cohort-based learning and shared schedules
- **Timetable** -- Schedule and display class sessions for students, teachers, and administrators
- **Attendance** -- Teachers record attendance; students view their own records; administrators monitor patterns
- **Gradebook** -- Centralized grade management with AI-generated narrative reports for academic reviews
### 3.4 Exam Engine
A powerful assessment system that goes far beyond multiple-choice:
- **AI-generated exams** -- The platform creates new exam content on demand for any subject and difficulty level. English exams include reading passages, listening scripts, writing tasks, and speaking prompts. Math exams include numerical problems, word problems, and multi-step worked problems. IT exams include code completion, scenario-based questions, and true/false items.
- **AI-powered grading** -- Writing and speaking responses are graded automatically against detailed rubrics. Math answers are checked with numerical tolerance. IT code answers are evaluated for correctness and approach. All AI grades can be reviewed and approved by teachers through an approval workflow.
- **AI writing detection** -- Every written submission is scanned for AI-generated content, providing per-sentence confidence scores.
- **Multiple delivery modes** -- Practice exams, timed official exams, scheduled assignments, diagnostic assessments, and mastery quizzes.
### 3.5 AI Assistant
An intelligent companion embedded throughout the platform:
| What It Does | Where |
|-------------|-------|
| **Answers questions** in natural language about any topic | Available on every page via a chat drawer |
| **Gives hints** during practice exercises without revealing the answer | Practice and study pages |
| **Explains grades** so students understand what to improve | After exam results |
| **Suggests what to study next** based on progress and weaknesses | Student dashboard |
| **Generates study tips** personalized to the student's current topic | Topic learning pages |
| **Helps teachers create content** -- course outlines, assignments, rubrics | Teacher course builder |
| **Produces report narratives** for academic reviews | Admin reports |
| **Flags at-risk students** based on attendance, grades, and engagement patterns | Teacher and admin dashboards |
### 3.6 Administration and Reporting
Comprehensive tools for managing the platform at every level:
- **User management** -- Create, search, filter, and export student, teacher, and corporate accounts
- **Multi-tenant organizations** -- Each institution or company operates in its own space with its own roles, permissions, and branding
- **Classroom management** -- Create student groups, transfer students between classes
- **Assignment scheduling** -- Schedule exams with start/end dates, assign to classrooms or individual students, track completion
- **Permission system** -- Granular control over who can view, create, edit, or delete content, organized by subject
- **Analytics dashboards** -- Real-time metrics on student performance, subject completion rates, AI usage, content gaps, and resource utilization
- **Support ticketing** -- Built-in helpdesk for students and staff to report issues
- **Payment management** -- Institutional licensing for UTAS, individual subscriptions for freelance students, with support for multiple payment providers
---
## 4. User Experience
### The Student Experience
A student logs in and sees their personalized dashboard: current subjects, learning plan progress, upcoming assignments, and a study streak counter. They select a subject and see their mastery profile -- a visual map showing which topics they have mastered, which they are working on, and which are locked until prerequisites are completed. Clicking a topic opens the learning page with curated materials (PDFs, videos, articles) and AI-generated explanations. After studying, a short mastery quiz confirms understanding. Throughout the experience, the AI coach is always available for hints, explanations, and encouragement. The student can also attend scheduled classes, view their timetable, check grades, and track attendance -- all from a single, unified interface.
### The Teacher Experience
A teacher logs in to see their class dashboard with insights at a glance: which students are progressing, which are at risk, and where content gaps exist. They can create courses using the AI course builder that generates outlines from a topic description. When grading, the AI Grading Assistant suggests marks and provides feedback drafts that the teacher can accept, modify, or override. Teachers can view any student's learning plan, adjust it if needed, and monitor mastery across their entire class through a heatmap visualization. Attendance recording and timetable management are integrated directly into their workflow.
### The Administrator Experience
An administrator has full visibility across the entire platform. The LMS dashboard shows enrollment numbers, completion rates, and subject-level performance across all courses. The platform dashboard provides user counts, entity statistics, and system health. Administrators manage organizations, roles, and permissions; they create exam structures, configure approval workflows, and oversee the content library. AI-powered reports generate narrative summaries for academic reviews, and the batch optimizer suggests class compositions for balanced learning outcomes.
---
## 5. How AI Powers the Platform
EnCoach integrates six specialized AI capabilities, each designed for a specific educational purpose:
| Capability | What It Does for Users |
|-----------|----------------------|
| **Intelligent Conversation** | Powers the AI Assistant, the study coach, grade explanations, writing help, and content generation. Understands context -- it knows what page you are on, what topic you are studying, and what your recent performance looks like. |
| **Speech Recognition** | Converts student voice recordings into text for speaking exam grading. Supports multiple accents and speaking styles. |
| **Text-to-Speech** | Generates natural-sounding audio for English listening exams. Supports 11 distinct voices with regional accents for authentic test preparation. |
| **AI Video Avatars** | Creates video-based speaking prompts with realistic virtual presenters, making speaking practice more engaging and exam-like. |
| **Content Authenticity** | Scans written submissions to detect AI-generated text, ensuring academic integrity with per-sentence confidence analysis. |
| **Smart Recommendations** | Finds the most relevant training tips, study materials, and content recommendations based on semantic understanding of each student's needs -- not just keyword matching. |
All AI-generated content is supplementary. Academic staff control primary learning materials, and AI outputs can be routed through approval workflows before students see them.
---
## 6. Integration with UTAS
| Integration Area | How It Works |
|-----------------|-------------|
| **Student Information System (SIS)** | Student enrollment data syncs automatically from UTAS SIS. New students appear in EnCoach without manual data entry. Student records stay synchronized throughout the semester. |
| **Curriculum Alignment** | Learning plans for UTAS students follow the university's curriculum. The topic taxonomy maps to UTAS course structures, ensuring adaptive learning enhances rather than replaces the academic program. |
| **Institutional Licensing** | UTAS operates under an institutional license covering all enrolled students and staff. No individual subscriptions needed for university members. |
| **Teacher and Admin Oversight** | UTAS teachers see their students' AI-generated learning plans and can adjust them. Administrators have full visibility into performance analytics across all departments and subjects. |
| **Branding** | The platform can display UTAS branding (logo, colors) for an institution-native experience. |
---
## 7. Platform at a Glance
| Dimension | Details |
|-----------|---------|
| **Subjects** | IELTS/English, Mathematics, Information Technology (extensible to any subject) |
| **User Roles** | Student, Teacher, Admin, Corporate, Master Corporate, Agent, Developer |
| **Student Modes** | UTAS Institutional (SIS-synced, curriculum-aligned) and Freelance (self-registered, AI-guided) |
| **AI Capabilities** | Adaptive diagnostics, personalized learning plans, content generation, automated grading, real-time coaching, writing integrity detection |
| **LMS Features** | Courses, batches, timetable, attendance, gradebook, reporting |
| **Exam Types** | Practice, official, assignment-based, diagnostic, mastery quiz |
| **Content Model** | Hybrid -- human-uploaded resources prioritized, AI-generated content fills gaps |
| **Payment Options** | Institutional license, individual subscription, bundled packages |
| **Payment Providers** | Stripe, PayPal, Paymob |
| **Security** | Server-side authorization, role-based access control, encrypted authentication, data isolation between organizations |
| **Deployment** | Cloud-hosted, containerized architecture, automated scaling |
| **Accessibility** | Responsive design (desktop and mobile), accessible form controls, screen reader support |
---
*This document describes the EnCoach platform as specified in the Unified Platform SRS (v1.0, March 2026). For detailed technical specifications, refer to ENCOACH_UNIFIED_SRS.md.*

View File

@@ -0,0 +1,308 @@
# EnCoach Platform -- System Features Guide
**Version:** 1.0
**Date:** March 2026
**Purpose:** Describe every section and feature accessible through the platform navigation, organized by user role.
---
## Part 1: Administrator Panel
The Administrator Panel is the central management hub for the entire platform. It is accessible to users with roles: Admin, Corporate, Master Corporate, Agent, and Developer.
---
### 1. Overview
The Overview section provides high-level dashboards that give administrators an at-a-glance view of the entire platform's health and activity.
| Menu Item | Description |
|-----------|-------------|
| **Admin Dashboard** | The LMS-focused dashboard showing real-time counts of courses, students, teachers, batches, and enrollment status. Displays course capacity charts, student status distribution, and recent activity. |
| **Platform Dashboard** | The original platform dashboard focused on exam and assignment activity. Shows exam creation statistics, assignment completion rates, user activity trends, and entity-level performance summaries. |
---
### 2. LMS
The LMS (Learning Management System) section is the core of institutional course management. It handles the full lifecycle of courses, students, teachers, and class scheduling.
| Menu Item | Description |
|-----------|-------------|
| **Courses** | Create, view, and manage courses. Each course has a title, code, description, maximum capacity, status (draft/active/archived), and is linked to a department. Administrators can view enrollment counts and manage course settings. |
| **Students** | Manage student records. Create new students (which automatically creates a portal user account), view student lists with filters, export student data, and manage individual student profiles including their course enrollments. |
| **Teachers** | Manage teacher records. Create teacher profiles with specialization areas, link them to portal user accounts, assign them to courses and batches, and view their teaching schedules. |
| **Batches** | Manage class batches (sections/cohorts). Each batch belongs to a course and has a date range, capacity, and enrolled students. Administrators can view batch details, manage student enrollment per batch, and track batch capacity. |
| **Lessons** | Create and manage individual lessons linked to a course, batch, and subject. Each lesson has a faculty member, date, and duration. Used to organize the day-to-day teaching schedule within a course. |
| **Timetable** | Visual timetable management with a day/time grid. Create, view, and delete teaching sessions. Sessions are linked to courses, batches, subjects, and classrooms with specific day and time slots. |
| **Reports** | LMS-specific reporting dashboards showing course enrollment trends, batch capacity utilization, student status distributions, teacher workload, and overall LMS usage analytics. |
---
### 3. Adaptive Learning
The Adaptive Learning section manages the AI-powered personalized learning engine. It controls the knowledge structure that drives diagnostic tests, proficiency tracking, and personalized learning plans.
| Menu Item | Description |
|-----------|-------------|
| **Taxonomy** | Manage the subject knowledge hierarchy: Subjects (e.g., English, Math, IT) contain Domains, which contain Topics, which contain Learning Objectives. This taxonomy drives the adaptive engine -- diagnostic tests assess proficiency at the topic level, and learning plans are generated based on this structure. Administrators can create subjects, add domains/topics manually, or use AI to suggest topic structures. |
| **Resources** | Manage the learning resource library. Upload educational materials (PDFs, videos, links, documents, interactive content) and tag them to specific topics in the taxonomy. Resources go through a review workflow (pending, approved, rejected) and are served to students through their personalized learning plans. Track resource downloads, completion, and ratings. |
---
### 4. Institutional
The Institutional section handles university-level academic administration -- the calendar, departments, enrollment pipeline, formal exam sessions, grading, and student lifecycle.
| Menu Item | Description |
|-----------|-------------|
| **Academic Years** | Define academic years with start and end dates. Auto-generate academic terms (semesters/quarters) within each year. Academic years organize all institutional activities -- courses, batches, exams, and grades are all scoped to an academic year. |
| **Departments** | Manage the department hierarchy with parent-child relationships. Departments organize courses and faculty. Each department can have sub-departments, a head of department, and associated courses. |
| **Admissions** | View and process individual student admission applications. Each admission goes through a workflow: submitted, confirmed, admitted, or rejected. Administrators can review application details, update status, and convert admitted applicants into enrolled students. |
| **Admission Register** | Manage admission campaigns/registers. Each register defines an admission period (open/closed), linked course, available seats, and start/end dates. Registers go through a workflow: create, confirm (open for applications), close (stop accepting). |
| **Exam Sessions** | Schedule and manage institutional exam sessions. Each session is linked to a course, batch, and academic term. Sessions go through a workflow: draft, scheduled, in progress, done. Manage exam rooms, attendees, and timing within each session. |
| **Marksheets** | Generate and validate student marksheets (grade reports). Marksheets aggregate student scores across exams within a session, apply grade configurations and result templates, and compute pass/fail status. Supports validation workflow before official release. |
| **Student Leave** | Manage student leave requests. Students submit leave requests with reasons and dates; administrators or teachers can approve or reject them. Tracks leave history per student. |
| **Fees** | View fee plans, fee terms, and individual student fee records. Manage fee structures attached to courses, including installment plans and payment tracking. |
| **Gradebook** | View and manage the institutional grading system. Displays grades per student, per course, and per academic year. Supports grading assignment types, weighted grading, and grade configuration (letter grades, percentage scales). |
| **Student Progress** | Track longitudinal student progress across courses, subjects, and academic terms. Provides a historical view of student performance, showing trends and areas that need attention. |
---
### 5. Academic
The Academic section manages the AI-powered exam engine -- creating exams, defining grading rubrics, generating content with AI, and managing the approval workflow for publishing exams.
| Menu Item | Description |
|-----------|-------------|
| **Assignments** | Create and manage exam-linked assignments. Each assignment wraps an exam and assigns it to specific students or classrooms with deadlines. Supports tabs for Active, Planned, Past, and Archived assignments. Includes late submission policies, extension requests, penalty configuration, and reminder settings. |
| **Exams List** | Browse, search, and manage all exams in the system. Filter by subject, module (Reading, Writing, Listening, Speaking), and difficulty. Each exam contains exercises, passages, timer settings, and access controls (public/private). Supports both practice exercises and official exams. |
| **Exam Structures** | Define exam blueprints (templates) that specify the section layout of exams -- how many sections, what type of questions in each, difficulty distribution, and time allocation per section. Structures are reusable across multiple exams. |
| **Rubrics** | Create and manage grading rubrics that define the criteria for evaluating student responses. Rubrics specify scoring dimensions (e.g., Task Achievement, Coherence, Grammar for IELTS writing). Rubric groups organize related rubrics. Used by both human graders and the AI grading engine. |
| **Generation** | The AI exam generation interface. Select a subject, module, and topic; configure difficulty and question types; and let the AI (GPT-4o) generate complete exams with passages, questions, and answer keys. Supports generation from scratch or from existing content. |
| **Approval Workflows** | Manage the approval process for publishing exams and course materials. Configure multi-stage workflows with specific approvers, time limits per stage, auto-escalation rules, and bypass options. Track approval status (pending, approved, rejected, escalated) with comments through each stage. |
---
### 6. Management
The Management section handles platform-wide user, organization, and infrastructure management.
| Menu Item | Description |
|-----------|-------------|
| **Users** | Full user management. Create, update, search, filter, and export users. View user details including role, entity, verification status, and activity history. Supports batch user import from spreadsheets. |
| **Entities** | Manage organizations (multi-tenant). Each entity is an institution or company using the platform. Configure entity settings, manage entity-specific roles and permissions, generate invite codes for onboarding, and set per-entity branding (logo, colors). |
| **Classrooms** | Manage virtual and physical classroom groups. Create classrooms with capacity tracking, assign students to classrooms, and use classrooms for assigning exams and assignments to groups of students. |
| **Roles & Permissions** | Create and manage roles within entities. Each role has a name and a set of granular permissions. Administrators can create custom roles, assign/remove permissions from roles, and manage the permission catalog. |
| **Authority Matrix** | A cross-reference visualization of all roles and their permissions. Displayed as an interactive grid where rows are roles and columns are permissions. Administrators can toggle permissions on/off directly from the matrix view. |
| **User Roles** | Assign roles to individual users. View all users with their current role assignments, search and filter, and assign or remove roles. Supports user-level permission overrides beyond their assigned role. |
---
### 7. Reports
The Reports section provides analytical views of platform performance and student outcomes.
| Menu Item | Description |
|-----------|-------------|
| **Student Performance** | Detailed performance analytics per student. Shows exam scores, assignment completion rates, proficiency levels across subjects, and trends over time. Supports filtering by entity, course, and date range. |
| **Stats Corporate** | Entity-level statistics for corporate/institutional managers. Shows aggregated data across all students within an entity -- enrollment counts, average scores, completion rates, and comparative performance across courses. |
| **Record** | Historical activity records. Browse past exam sessions, assignment submissions, and user activity logs. Supports searching and filtering by date, user, and activity type. |
---
### 8. Configuration
The Configuration section manages platform-wide settings for notifications, FAQs, and approval processes.
| Menu Item | Description |
|-----------|-------------|
| **FAQ Manager** | Create and manage Frequently Asked Questions. Organize FAQs into categories, set target audience per item (student, entity, or both), add rich content including text, images, and video links. FAQs are displayed on a public searchable page with accordion-style expand/collapse. |
| **Notification Rules** | Configure automated notification triggers. Define rules based on event types (assignment due, exam due, chapter unlock, result release), set timing (how many days before the event), frequency (once, daily, custom intervals), and delivery channel (in-app, email, or both). |
| **Approval Config** | Configure the enhanced approval workflow system. Define multi-stage approval templates with specific approvers per stage, maximum days before auto-escalation, notification emails, and bypass permissions. Applied to exam publishing and course material approval. |
---
### 9. Training
The Training section provides reference materials for language learning support.
| Menu Item | Description |
|-----------|-------------|
| **Vocabulary** | Browse vocabulary training content. Displays categorized vocabulary lists, definitions, usage examples, and related exercises. Linked to the FAISS-based recommendation engine for contextual suggestions. |
| **Grammar** | Browse grammar training content. Displays grammar rules, examples, common mistakes, and practice exercises organized by topic and difficulty level. |
---
### 10. Support
The Support section handles financial tracking, user support, and system settings.
| Menu Item | Description |
|-----------|-------------|
| **Payment Record** | View payment history and transaction records. Shows all subscription payments, amounts, providers (Stripe/PayPal/Paymob), status, and associated packages. Supports filtering by date, user, and payment status. |
| **Tickets** | Manage support tickets. View all tickets submitted by users, assign tickets to support agents, update ticket status (open, in progress, resolved, closed), and communicate with ticket reporters. |
| **Settings** | Platform-wide settings management. Configure system parameters, AI service settings (API keys stored securely), default behaviors, and platform-level preferences. |
---
### 11. Other Admin Pages (Accessible but not in main sidebar)
| Page | Description |
|------|-------------|
| **Library** | Manage the institutional library. CRUD operations for library media (books, journals, digital resources), track media movements (issue, return), and manage student library cards. |
| **Activities** | Log and track student activities. Define activity types and record student participation in extracurricular or academic activities outside the standard course structure. |
| **Facilities** | Manage institutional facilities and assets. Track rooms, equipment, and other physical resources used for teaching and examinations. |
---
## Part 2: Teacher Panel
The Teacher Panel provides instructors with tools to manage their courses, create content, track student progress, and communicate with their classes.
---
### 1. Teaching
The Teaching section is the teacher's primary workspace for delivering courses and managing assignments.
| Menu Item | Description |
|-----------|-------------|
| **Dashboard** | The teacher's home screen showing an overview of their assigned courses, upcoming assignments, recent student submissions, and quick statistics (number of students, pending grading, upcoming sessions). |
| **Courses** | View and manage assigned courses. From here, teachers can access three key sub-pages per course: **Chapters** (organize course content into sequential chapters with materials, set unlock dates, and control access), **Chapter Detail** (upload and manage materials within a chapter -- PDFs, videos, audio, images, links -- with download permission controls), and **AI Workbench** (generate course content using AI by inputting topic, objectives, and complexity level; review and publish AI-generated chapters, materials, exercises, and rubrics). |
| **Assignments** | Create and manage assignments for classes. Define deadlines, late submission policies, penalty structures, and reminder frequencies. View assignment submissions, check plagiarism reports, grade with AI assistance, and release results. Drill into individual assignments to see per-student submission status and grading. |
---
### 2. Management
The Management section gives teachers tools to track student attendance and schedules.
| Menu Item | Description |
|-----------|-------------|
| **Attendance** | Take and manage attendance for classes. Mark students as present, absent, or late for each session. View attendance history and trends per batch. |
| **Students** | View the list of students enrolled in the teacher's courses and batches. Access individual student profiles and performance summaries. |
| **Timetable** | View the teacher's personal teaching schedule in a visual day/time grid format. Shows all assigned sessions across courses and batches. |
---
### 3. Communication
The Communication section enables teacher-student interaction and class announcements.
| Menu Item | Description |
|-----------|-------------|
| **Discussions** | View and manage discussion boards for courses. Teachers can enable/disable discussion boards per course or chapter, create discussion threads, reply to student questions, pin important posts, and mark questions as resolved. Supports file attachments. |
| **Announcements** | Create and broadcast announcements to classes. Set priority (normal, important, urgent), target specific courses or batches, optionally send via email in addition to in-app notification. Manage drafts and publish when ready. |
---
### 4. Account
| Menu Item | Description |
|-----------|-------------|
| **Profile** | View and update personal profile information (name, email, specialization, contact details). |
---
## Part 3: Student Panel
The Student Panel provides learners with access to their courses, adaptive learning engine, progress tracking, and communication tools.
---
### 1. Learning
The Learning section is the student's primary workspace for accessing courses and completing assignments.
| Menu Item | Description |
|-----------|-------------|
| **Dashboard** | The student's home screen showing enrolled subjects, upcoming deadlines (assignments, quizzes, exams), recent announcements, personal progress statistics, and AI study tips. Includes an AI coaching assistant for on-demand help. |
| **My Courses** | Browse all enrolled courses. Each course shows its chapters, completion progress, and available materials. Students can navigate into individual courses to see the chapter structure, access unlocked chapter content, download materials (when permitted by the teacher), and track their progress through the course. |
| **Subject Registration** | Register for available subjects within the current academic term. View available subjects, prerequisites, and capacity. Submit registration requests for subjects the student wants to take. |
| **Assignments** | View all active, upcoming, and past assignments. Submit completed work through the platform, view deadlines and submission status, request deadline extensions (when allowed), track submission history, and receive AI-generated feedback and plagiarism reports. |
---
### 2. Adaptive Learning
The Adaptive Learning section provides AI-powered personalized education that adapts to each student's level.
| Menu Item | Description |
|-----------|-------------|
| **My Subjects** | The entry point for the adaptive learning engine. Displays all subjects the student is studying with their overall mastery level. From here, students can: take a **Diagnostic Test** (AI-administered assessment to determine initial proficiency across topics), view their **Proficiency Profile** (detailed breakdown of mastery per topic within a subject), access their **Learning Plan** (AI-generated personalized study path with ordered topics based on proficiency gaps), and study individual **Topics** (AI-curated learning content with practice exercises and mastery quizzes that adapt difficulty based on performance). |
---
### 3. Progress
The Progress section shows academic performance, attendance, and the overall learning journey.
| Menu Item | Description |
|-----------|-------------|
| **Grades** | View grades for all courses, assignments, and exams. Shows current marks, feedback from teachers, and detailed score breakdowns by grading criteria. |
| **Attendance** | View personal attendance records across all enrolled courses. Shows attendance percentage, absent dates, and any leave requests with their approval status. |
| **Timetable** | View personal class schedule in a visual day/time grid. Shows all upcoming sessions with course, subject, teacher, and room information. |
| **My Journey** | A longitudinal progress timeline showing the student's overall academic journey. Displays enrolled courses with completion percentages, per-subject proficiency trends, and a recent activity timeline (exams taken, assignments submitted, chapters completed). Provides a holistic view of growth over time. |
---
### 4. Communication
The Communication section enables students to interact with teachers and classmates.
| Menu Item | Description |
|-----------|-------------|
| **Discussions** | Participate in course and chapter discussion boards. Create new discussion threads, reply to existing threads (nested replies), ask questions about assignments or course content, and view pinned/resolved posts. Students can see other participants within the same class. |
| **Messages** | Direct messaging between users. View inbox (with read/unread indicators and unread count badge), send messages to teachers or classmates within the same class, attach files, and optionally send an email copy. Includes a sent messages view. |
| **Announcements** | View announcements from teachers and system administrators. Announcements are displayed with priority indicators (urgent, important, normal) and sorted by date. |
---
### 5. Account
| Menu Item | Description |
|-----------|-------------|
| **Profile** | View and update personal profile information (name, email, contact details, preferences). |
---
## Part 4: Public Pages (No Login Required)
These pages are accessible without authentication.
| Page | Description |
|------|-------------|
| **Login** | User authentication with email and password. Redirects to the appropriate dashboard based on user role after successful login. |
| **Register** | New user registration. Supports open registration or invite-code-based registration to join a specific entity with a predefined role. |
| **Forgot Password** | Password recovery via email. Users enter their email to receive a verification link for password reset. |
| **Admission Application** | Public admission form for prospective students. Users can apply to an open admission register without logging in, providing personal details and required documents. They can check application status afterward. |
| **FAQ** | Public Frequently Asked Questions page. Searchable, accordion-style display of FAQ items organized by category. Content is filtered based on the user's role (student or entity) if logged in, showing all items if accessed publicly. |
| **Official Exam Access** | Special exam access pages for official/proctored exams. Supports three modes: (1) token-based link access where students open a unique URL, (2) landing page access with an exam code, and (3) a dedicated exam login screen isolated from the rest of the platform. |
---
## Part 5: AI Features (Embedded Across the Platform)
AI capabilities are embedded throughout the platform rather than being a standalone section. Here is where AI appears:
| Feature | Where It Appears | What It Does |
|---------|-----------------|-------------|
| **AI Study Coach** | Student Dashboard | Chat-based coaching assistant that provides hints, concept explanations, writing help, and study tips. |
| **AI Exam Generation** | Admin > Generation, Teacher > AI Workbench | Generates complete exams (passages, questions, answer keys) from subject taxonomy using GPT-4o. |
| **AI Grading** | Admin > Assignments, Teacher > Assignments | Automatically grades writing (rubric-based IELTS band scoring), speaking (transcription + evaluation), math (step evaluation), and IT (code evaluation) using GPT-4o. |
| **AI Course Content** | Teacher > AI Workbench | Generates course outlines, chapter content, exercises, and grading rubrics from high-level requirements. |
| **AI Diagnostics** | Student > My Subjects | Administers adaptive diagnostic tests that adjust question difficulty based on responses to accurately assess proficiency. |
| **AI Learning Plans** | Student > My Subjects | Generates personalized learning plans based on diagnostic results and proficiency gaps. |
| **AI Content Recommendations** | Student > Topic Learning | Suggests additional learning materials based on student performance and learning history using FAISS similarity search. |
| **AI Plagiarism Detection** | Teacher > Assignments, Admin > Assignments | Analyzes submitted text using GPTZero to detect AI-generated content, providing per-sentence analysis and downloadable reports. |
| **AI Search** | Admin Header Bar | Semantic search across the platform using AI. |
| **AI Analytics** | Admin > Reports | Generates narrative reports, identifies content gaps, and provides batch optimization suggestions. |
| **AI Tip Banner** | Student Dashboard | Context-aware training tips selected using FAISS-based recommendation. |
| **Text-to-Speech** | Listening Exams | Converts exam text to natural speech using AWS Polly (11 neural voices). |
| **Speech-to-Text** | Speaking Exams | Transcribes student speaking responses using local Whisper model. |
| **AI Avatar Videos** | Speaking Exams | Generates avatar-based video prompts for speaking exams using ELAI. |
---
*End of System Features Guide*

3223
docs/ENCOACH_UNIFIED_SRS.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

749
docs/ODOO_BACKEND_SRS_v3.md Normal file
View File

@@ -0,0 +1,749 @@
# EnCoach Odoo 19 Backend -- Developer SRS v3
> **SUPERSEDED** -- This document has been replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` (v3.0) and `ENCOACH_UNIFIED_SRS.md` (v2.0). All content below is historical. The developer has implemented everything and the system is deployed at `http://5.189.151.117:8069`.
**Document Version:** 3.0
**Date:** March 11, 2026
**Status:** ~~Active -- Ready for Development~~ **SUPERSEDED**
**Supersedes:** `ODOO_MIGRATION_SRS_v2.md` (v2.0)
**Master Reference:** `ENCOACH_UNIFIED_SRS.md` (v1.0)
**Author:** EnCoach Architecture Team
---
## Purpose
This document is the **backend implementation specification** for the Odoo 19 developer. It defines every API endpoint, data model, and module the frontend expects. The frontend is **fully built and wired** -- it makes real HTTP calls to these endpoints via TanStack Query. Your job is to ensure every endpoint listed here exists, returns the correct response shape, and connects to the correct Odoo models.
**Source of truth for response shapes:** `encoach_frontend_new/src/types/*.ts` (14 files)
**Source of truth for endpoint paths:** `encoach_frontend_new/src/services/*.ts` (21 files)
---
## Table of Contents
1. [What Already Exists](#1-what-already-exists)
2. [What Needs to Be Built or Modified](#2-what-needs-to-be-built-or-modified)
3. [New Odoo Modules](#3-new-odoo-modules)
4. [Existing Module Modifications](#4-existing-module-modifications)
5. [Complete API Contract](#5-complete-api-contract)
6. [Data Model Specification](#6-data-model-specification)
7. [AI Service Integration](#7-ai-service-integration)
8. [Response Format Standards](#8-response-format-standards)
9. [Non-Functional Requirements](#9-non-functional-requirements)
10. [Implementation Priority](#10-implementation-priority)
---
## 1. What Already Exists
### 1.1 Existing Odoo Modules (15)
These modules are built and deployed in `ielts-be-v0/`:
| # | Module | Controller File | Endpoints |
|---|--------|----------------|-----------|
| 1 | `encoach_core` | -- | Users, entities, roles, permissions, codes, invites |
| 2 | `encoach_ai` | -- | OpenAI service wrapper, constants, blacklist |
| 3 | `encoach_ai_media` | `media.py` | TTS (Polly), STT (Whisper), ELAI avatars |
| 4 | `encoach_ai_generation` | `generation.py` | Exam content generation per module |
| 5 | `encoach_ai_grading` | `grading.py` | Writing/speaking AI grading |
| 6 | `encoach_exam` | `exams.py` | Exam CRUD, approval workflows |
| 7 | `encoach_assignment` | `assignments.py` | Assignment management |
| 8 | `encoach_evaluation` | `evaluations.py` | Evaluation results and AI jobs |
| 9 | `encoach_stats` | `sessions.py`, `stats.py` | Exam sessions and statistics |
| 10 | `encoach_training` | `training.py` | Training content, tips (FAISS), walkthroughs |
| 11 | `encoach_classroom` | `classrooms.py` | Classroom groups |
| 12 | `encoach_subscription` | `subscriptions.py`, `discounts.py` | Packages, payments, discounts |
| 13 | `encoach_registration` | `registration.py` | User registration, batch import |
| 14 | `encoach_ticket` | `tickets.py` | Support tickets |
| 15 | `encoach_api` | `auth.py`, `users.py`, `entities.py`, `storage.py`, `approvals.py` | REST API controllers (81+ endpoints) |
### 1.2 Existing Endpoint Count by Group
| Group | Estimated Count | Status |
|-------|----------------|--------|
| Auth (`/api/login`, `/api/logout`, etc.) | 4 | Built |
| Users (`/api/user`, `/api/users/*`) | 6 | Built |
| Entities & Roles (`/api/entities`, `/api/roles`, `/api/permissions`) | 7 | Built |
| Exams (`/api/exam/*`) | 7 | Built |
| AI Generation (`/api/exam/*/generate`) | 12 | Built |
| Media (`/api/exam/*/media`, `/api/transcribe`) | 6 | Built |
| Evaluations (`/api/evaluate/*`, `/api/grading/*`) | 6 | Built |
| Classrooms (`/api/groups`) | 3 | Built |
| Assignments (`/api/assignments`) | 8 | Built |
| Sessions & Stats (`/api/sessions`, `/api/stats`, `/api/statistical`) | 5 | Built |
| Training (`/api/training/*`) | 4 | Built |
| Subscriptions (`/api/packages`, `/api/stripe`, etc.) | 7 | Built |
| Registration (`/api/register`, `/api/code/*`) | 4 | Built |
| Tickets (`/api/tickets`) | 3 | Built |
| Storage (`/api/storage`) | 2 | Built |
| Approvals (`/api/approval-workflows`) | 3 | Built |
| **TOTAL EXISTING** | **~87** | |
---
## 2. What Needs to Be Built or Modified
### 2.1 New Endpoints Required (Frontend Expects These)
The frontend calls the following endpoints that **do not exist yet**. You must create them.
| Group | Count | Priority |
|-------|-------|----------|
| Taxonomy (subjects, domains, topics) | 14 | P0 |
| Adaptive Learning (diagnostic, proficiency, learning plans) | 16 | P0 |
| Resources (upload, manage, review) | 7 | P1 |
| AI Coaching (chat, suggest, explain, tips) | 6 | P1 |
| AI Utilities (search, insights, alerts, reports) | 5 | P2 |
| Analytics (student, class, subject, content gaps) | 4 | P2 |
| LMS Bridge (courses, batches, timetable, attendance, grades) | 13 | P1 |
| **TOTAL NEW** | **~65** | |
### 2.2 Existing Modules Requiring Modification
| Module | What to Change |
|--------|---------------|
| `encoach_exam` | Add `subject_id` (Many2one to `encoach.subject`) and `topic_ids` (Many2many to `encoach.topic`) fields to `encoach.exam` model |
| `encoach_stats` | Add `topic_id` and `subject_id` fields to `encoach.stat` model |
| `encoach_training` | Add `subject_id` field to `encoach.training.tip` for per-subject FAISS indices |
| `encoach_ai_generation` | Extend prompts to support Math (include LaTeX/KaTeX formatting) and IT (code blocks, syntax) question types |
| `encoach_ai_grading` | Add grading logic for numerical-tolerance (Math) and keyword/pattern (IT) answers |
| `encoach_subscription` | Add `subjects` field to `encoach.package` for subject-scoped subscriptions |
| `encoach_api` | Add new controller files for all new endpoint groups |
---
## 3. New Odoo Modules
Create these 8 new modules:
### 3.1 `encoach_taxonomy`
**Purpose:** Subject/domain/topic/learning-objective hierarchy.
**Models:**
| Model | Key Fields |
|-------|-----------|
| `encoach.subject` | `name` (Char), `code` (Char unique), `is_active` (Boolean), `diagnostic_config` (Text/JSON), `mastery_threshold` (Float default=80), `grading_scale` (Selection: percentage/band/letter), `grading_scale_config` (Text/JSON) |
| `encoach.domain` | `name` (Char), `code` (Char), `subject_id` (M2O encoach.subject), `sequence` (Integer) |
| `encoach.topic` | `name` (Char), `code` (Char), `domain_id` (M2O encoach.domain), `estimated_hours` (Float), `difficulty_level` (Selection: easy/medium/hard), `prerequisite_ids` (M2M self-ref), `question_type_weights` (Text/JSON) |
| `encoach.learning.objective` | `name` (Char), `topic_id` (M2O encoach.topic), `bloom_level` (Selection: remember/understand/apply/analyze/evaluate/create), `sequence` (Integer) |
**Computed fields on `encoach.subject`:** `domain_count`, `topic_count`
**Computed fields on `encoach.domain`:** `topic_count`
**Computed fields on `encoach.topic`:** `objective_count`, `prerequisite_names` (list of names)
### 3.2 `encoach_resources`
**Purpose:** Human-uploaded learning materials tagged to topics.
**Models:**
| Model | Key Fields |
|-------|-----------|
| `encoach.resource` | `name` (Char), `resource_type` (Selection: pdf/video/link/document/interactive), `url` (Char), `file` (Binary), `file_name` (Char), `topic_ids` (M2M encoach.topic), `author_id` (M2O res.users), `is_published` (Boolean), `review_status` (Selection: pending/approved/rejected) |
| `encoach.resource.completion` | `student_id` (M2O res.users), `resource_id` (M2O encoach.resource), `viewed` (Boolean), `time_spent_minutes` (Integer), `rating` (Integer 1-5) |
### 3.3 `encoach_adaptive`
**Purpose:** Core adaptive learning engine -- proficiency tracking, learning plans, content cache.
**Models:**
| Model | Key Fields |
|-------|-----------|
| `encoach.proficiency` | `student_id` (M2O res.users), `topic_id` (M2O encoach.topic), `mastery` (Float 0-100), `mastery_level` (Selection: not_started/beginner/developing/proficient/mastered -- computed from mastery), `last_assessed` (Datetime), `time_spent_minutes` (Integer) |
| `encoach.learning.plan` | `student_id` (M2O res.users), `subject_id` (M2O encoach.subject), `status` (Selection: active/paused/completed), `ai_summary` (Text), `overall_progress` (Float -- computed), `target_completion` (Date) |
| `encoach.learning.plan.item` | `plan_id` (M2O encoach.learning.plan), `topic_id` (M2O encoach.topic), `sequence` (Integer), `status` (Selection: locked/available/in_progress/completed), `estimated_hours` (Float), `actual_hours` (Float), `mastery` (Float -- from proficiency) |
| `encoach.ai.content.cache` | `topic_id` (M2O encoach.topic), `content_type` (Char), `difficulty_level` (Char), `content` (Text/JSON), `model_used` (Char), `review_status` (Selection: auto/reviewed/rejected) |
**Business logic:**
- `mastery_level` is computed: 0-19 = not_started, 20-39 = beginner, 40-59 = developing, 60-79 = proficient, 80-100 = mastered
- `overall_progress` is computed from item statuses (completed_count / total_count * 100)
- When a plan item is completed, unlock the next item(s) based on prerequisite graph
### 3.4 `encoach_adaptive_api`
**Purpose:** REST controllers for the adaptive learning engine.
**Controller files to create:**
- `taxonomy.py` -- `/api/subjects`, `/api/domains`, `/api/topics`, `/api/subjects/{id}/taxonomy`
- `resources.py` -- `/api/resources`
- `diagnostic.py` -- `/api/diagnostic/start`, `/api/diagnostic/answer`, `/api/diagnostic/{id}/result`
- `proficiency.py` -- `/api/proficiency`, `/api/proficiency/summary`, `/api/proficiency/class`
- `learning_plan.py` -- `/api/learning-plan`, `/api/learning-plan/generate`, etc.
- `content.py` -- `/api/topics/{id}/content`, `/api/topics/{id}/practice`, `/api/topics/{id}/mastery-quiz`
### 3.5 `encoach_adaptive_ai`
**Purpose:** AI logic for diagnostics, plan generation, content generation, coaching.
**Key services:**
- Diagnostic question selection (adaptive difficulty algorithm)
- Learning plan generation (topological sort of topics by prerequisites, weighted by proficiency gaps)
- AI content generation for topics (call OpenAI GPT-4o with topic context)
- Practice question generation per topic
- Mastery quiz generation and grading
- Coaching: chat, hints, explanations, study suggestions, writing help, contextual tips
### 3.6 `encoach_lms_api`
**Purpose:** REST API bridge exposing OpenEduCat models to the frontend.
**Controller files to create:**
- `courses.py` -- `/api/courses`, `/api/courses/{id}`, `/api/courses/ai-generate`
- `batches.py` -- `/api/batches`, `/api/batches/{id}`
- `timetable.py` -- `/api/timetable`
- `attendance.py` -- `/api/attendance`
- `grades.py` -- `/api/grades`
**Note:** These controllers wrap the existing OpenEduCat Odoo models (`op.course`, `op.batch`, `op.session`, `op.attendance.sheet`, etc.) behind a clean REST API. Do NOT duplicate the models -- use the OpenEduCat models directly.
### 3.7 `encoach_sis`
**Purpose:** UTAS Student Information System integration.
**Models:**
- `encoach.sis.sync` -- sync job tracking (status, last_run, next_run, error_log)
- `encoach.sis.mapping` -- field mapping between SIS fields and EnCoach fields
### 3.8 `encoach_branding`
**Purpose:** Tenant whitelabeling.
**Models:**
- `encoach.branding` -- `entity_id` (M2O encoach.entity), `logo` (Binary), `primary_color` (Char), `secondary_color` (Char), `font_family` (Char)
---
## 4. Existing Module Modifications
### 4.1 `encoach_exam` -- Add Subject/Topic Fields
```python
# In encoach_exam/models/exam.py, add:
subject_id = fields.Many2one('encoach.subject', string='Subject')
topic_ids = fields.Many2many('encoach.topic', string='Topics')
is_diagnostic = fields.Boolean(string='Is Diagnostic Exam', default=False)
```
### 4.2 `encoach_stats` -- Add Subject/Topic Fields
```python
# In encoach_stats/models/stat.py, add:
subject_id = fields.Many2one('encoach.subject', string='Subject')
topic_id = fields.Many2one('encoach.topic', string='Topic')
```
### 4.3 `encoach_training` -- Add Subject Field
```python
# In encoach_training/models/training_tip.py, add:
subject_id = fields.Many2one('encoach.subject', string='Subject')
```
### 4.4 `encoach_subscription` -- Add Subject Scoping
```python
# In encoach_subscription/models/package.py, add:
subject_ids = fields.Many2many('encoach.subject', string='Included Subjects')
```
### 4.5 `encoach_ai_generation` -- Multi-Subject Prompts
Extend the generation prompts to handle:
- **Math:** Include LaTeX formatting instructions in prompts, numerical-tolerance answer checking
- **IT:** Include code block formatting, syntax highlighting hints, code-completion question types
- **Generic:** Accept `subject_id` and `topic_id` parameters, adjust prompt templates per subject
### 4.6 `encoach_ai_grading` -- Multi-Subject Grading
Extend grading logic:
- **IELTS:** Band scoring (existing)
- **Math:** Numerical tolerance (accept answers within +/- epsilon), step-by-step partial credit
- **IT:** Keyword matching, regex pattern matching, AI-graded code explanations
---
## 5. Complete API Contract
This is the definitive list of every endpoint the frontend calls. Each entry shows the HTTP method, path, request body (if POST/PATCH), and expected response shape. **The TypeScript type files in `encoach_frontend_new/src/types/` are the canonical response shapes.**
### 5.1 Authentication (Existing -- `auth.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/login` | `{ login: string, password: string }` | `{ token: string, user: User }` |
| `POST` | `/api/logout` | -- | `void` |
| `GET` | `/api/user` | -- | `User` |
| `POST` | `/api/reset/sendVerification` | `{ email: string }` | `{ success: boolean }` |
**User shape:** See `src/types/auth.ts` -- must include: `id`, `name`, `email`, `login`, `user_type` (one of: student/teacher/admin/corporate/mastercorporate/agent/developer), `is_verified`, `entities` (array of {id, name, role}), `classrooms`.
### 5.2 Users (Existing -- `users.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `GET` | `/api/users/list` | Query: `type`, `entity_id`, `page`, `size`, `search`, `sort`, `order` | `PaginatedResponse<User>` |
| `GET` | `/api/users/{id}` | -- | `User` |
| `PATCH` | `/api/users/update` | `{ id, ...fields }` | `User` |
| `POST` | `/api/users/create` | `Partial<User>` | `User` |
| `POST` | `/api/batch_users` | `{ users: Partial<User>[] }` | `{ success: boolean }` |
**PaginatedResponse shape:** `{ items: T[], total: number, page: number, size: number, pages: number }`
### 5.3 Entities (Existing -- `entities.service.ts`)
| Method | Path | Response |
|--------|------|----------|
| `GET` | `/api/entities` | `PaginatedResponse<Entity>` |
| `GET` | `/api/entities/{id}` | `Entity` |
| `POST` | `/api/entities` | `Entity` |
| `PATCH` | `/api/entities/{id}` | `Entity` |
| `DELETE` | `/api/entities/{id}` | `{ success: boolean }` |
| `GET` | `/api/entities/{id}/roles` | `EntityRole[]` |
| `POST` | `/api/entities/{id}/roles` | `EntityRole` |
| `PATCH` | `/api/roles/{id}/permissions` | `EntityRole` |
| `GET` | `/api/permissions?entity_id=` | `string[]` (list of permission keys for current user) |
### 5.4 Exams (Existing -- `exams.service.ts`)
| Method | Path | Response |
|--------|------|----------|
| `GET` | `/api/exam/{module}` | `PaginatedResponse<Exam>` |
| `GET` | `/api/exam/{module}/{id}` | `Exam` |
| `POST` | `/api/exam` | `Exam` |
| `PATCH` | `/api/exam/{id}` | `Exam` |
| `DELETE` | `/api/exam/{id}` | `{ success: boolean }` |
| `PATCH` | `/api/exam/{id}/access` | `Exam` |
| `GET` | `/api/rubrics` | `PaginatedResponse<Rubric>` |
| `POST` | `/api/rubrics` | `Rubric` |
| `GET` | `/api/rubric-groups` | `PaginatedResponse<RubricGroup>` |
| `GET` | `/api/exam-structures` | `PaginatedResponse<ExamStructure>` |
| `POST` | `/api/exam-structures` | `ExamStructure` |
| `DELETE` | `/api/exam-structures/{id}` | `{ success: boolean }` |
| `GET` | `/api/exam/avatars` | `[{ id, name, thumbnail, voice }]` |
**Note:** `Exam` response must now include optional `subject_id` and `topic_ids` fields.
### 5.5 Assignments (Existing -- `assignments.service.ts`)
| Method | Path | Response |
|--------|------|----------|
| `GET` | `/api/assignments` | `PaginatedResponse<Assignment>` |
| `GET` | `/api/assignments/{id}` | `Assignment` |
| `POST` | `/api/assignments` | `Assignment` |
| `PATCH` | `/api/assignments/{id}` | `Assignment` |
| `DELETE` | `/api/assignments/{id}` | `{ success: boolean }` |
| `POST` | `/api/assignments/{id}/archive` | `Assignment` |
| `POST` | `/api/assignments/{id}/start` | `Assignment` |
### 5.6 Classrooms (Existing -- `classrooms.service.ts`)
| Method | Path | Response |
|--------|------|----------|
| `GET` | `/api/groups` | `PaginatedResponse<Classroom>` |
| `GET` | `/api/groups/{id}` | `Classroom` |
| `POST` | `/api/groups` | `Classroom` |
| `DELETE` | `/api/groups/{id}` | `{ success: boolean }` |
| `POST` | `/api/groups/transfer` | `{ success: boolean }` |
| `POST` | `/api/groups/{id}/members` | `{ success: boolean }` |
| `POST` | `/api/groups/{id}/members/remove` | `{ success: boolean }` |
### 5.7 Stats (Existing -- `stats.service.ts`)
| Method | Path | Response |
|--------|------|----------|
| `GET` | `/api/sessions` | `ExamSession[]` |
| `GET` | `/api/stats` | `ExamStat[]` |
| `GET` | `/api/statistical` | `StatisticalData` |
| `GET` | `/api/stats/performance` | `unknown[]` |
### 5.8 Evaluations (Existing -- `evaluations.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/evaluate/writing` | `{ session_id, text, rubric_id? }` | `Evaluation` |
| `POST` | `/api/evaluate/speaking` | `{ session_id, audio_url, rubric_id? }` | `Evaluation` |
| `POST` | `/api/grading/multiple` | `{ session_id, answers: [{exercise_index, answer}] }` | `{ results: [{exercise_index, correct, score}] }` |
| `POST` | `/api/transcribe` | `FormData (audio file)` | `{ text: string }` |
### 5.9 Generation (Existing -- `generation.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/exam/{module}/generate` | `{ title, label?, entity_id?, subject_id?, topic_id?, difficulty?, count? }` | `{ exam_id, exercises: [] }` |
| `POST` | `/api/exam/{module}/generate/scratch` | Same as above | Same as above |
**Note:** Must now accept `subject_id` and `topic_id` to scope generation to specific subjects/topics.
### 5.10 Training, Subscriptions, Tickets, Storage, Approvals (Existing)
These follow the existing patterns. See `training.service.ts`, `subscriptions.service.ts`, `tickets.service.ts`, `storage.service.ts`, `approvals.service.ts` for exact paths.
---
### 5.11 Taxonomy (NEW -- `taxonomy.service.ts`)
| Method | Path | Request/Params | Response |
|--------|------|---------------|----------|
| `GET` | `/api/subjects` | -- | `Subject[]` |
| `GET` | `/api/subjects/{id}` | -- | `Subject` |
| `POST` | `/api/subjects` | `Partial<Subject>` | `Subject` |
| `PATCH` | `/api/subjects/{id}` | `Partial<Subject>` | `Subject` |
| `DELETE` | `/api/subjects/{id}` | -- | `{ success: boolean }` |
| `GET` | `/api/subjects/{id}/taxonomy` | -- | `TaxonomyTree` (nested: subject + domains + topics + objectives) |
| `POST` | `/api/subjects/{id}/taxonomy/import` | `FormData (CSV/JSON)` | `{ success: boolean }` |
| `GET` | `/api/domains` | Query: `subject_id?` | `Domain[]` |
| `POST` | `/api/domains` | `Partial<Domain>` | `Domain` |
| `PATCH` | `/api/domains/{id}` | `Partial<Domain>` | `Domain` |
| `DELETE` | `/api/domains/{id}` | -- | `{ success: boolean }` |
| `POST` | `/api/domains/{id}/ai-suggest` | -- | `{ suggestions: Partial<Topic>[] }` |
| `GET` | `/api/topics` | Query: `domain_id?`, `subject_id?` | `Topic[]` |
| `POST` | `/api/topics` | `Partial<Topic>` | `Topic` |
| `PATCH` | `/api/topics/{id}` | `Partial<Topic>` | `Topic` |
| `DELETE` | `/api/topics/{id}` | -- | `{ success: boolean }` |
**TaxonomyTree shape:**
```json
{
"subject": { "id": 1, "name": "IELTS", "code": "IELTS", ... },
"domains": [
{
"id": 1, "name": "Writing", "code": "W", "sequence": 1,
"topics": [
{
"id": 1, "name": "Task 1", "difficulty_level": "medium",
"objectives": [
{ "id": 1, "name": "Describe trends", "bloom_level": "apply" }
]
}
]
}
]
}
```
### 5.12 Resources (NEW -- `resources.service.ts`)
| Method | Path | Request/Params | Response |
|--------|------|---------------|----------|
| `GET` | `/api/resources` | Query: `topic_id?`, `resource_type?`, `review_status?`, `page`, `size`, `search` | `PaginatedResponse<Resource>` |
| `POST` | `/api/resources` | `FormData` (file + metadata) | `Resource` |
| `PATCH` | `/api/resources/{id}` | `Partial<Resource>` | `Resource` |
| `DELETE` | `/api/resources/{id}` | -- | `{ success: boolean }` |
| `POST` | `/api/resources/{id}/complete` | -- | `ResourceCompletion` |
| `POST` | `/api/resources/{id}/rate` | `{ rating: number }` | `{ success: boolean }` |
| `GET` | `/api/resources/{id}/download` | -- | Binary file (stream) |
### 5.13 Diagnostic Assessment (NEW -- `adaptive.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/diagnostic/start` | `{ subject_id: number }` | `{ session: DiagnosticSession, first_question: DiagnosticQuestion }` |
| `POST` | `/api/diagnostic/answer` | `{ session_id, question_id, answer }` | `{ next_question?: DiagnosticQuestion, completed: boolean }` |
| `GET` | `/api/diagnostic/{id}/result` | -- | `DiagnosticResult` |
**DiagnosticQuestion shape:**
```json
{
"id": "q_uuid",
"topic_id": 5,
"topic_name": "Line Graphs",
"domain_name": "Writing",
"difficulty": "medium",
"question_type": "multiple_choice",
"question_text": "Which of the following...",
"options": ["A", "B", "C", "D"],
"time_limit_seconds": 120
}
```
**Diagnostic algorithm:**
1. On `/start`: Create a diagnostic session. Pull topics from all domains. Start with `starting_difficulty` from subject's `diagnostic_config`.
2. On `/answer`: Grade the answer. If correct, increase difficulty and mastery estimate for that topic. If incorrect, decrease. Select the next question from a different domain/topic. Use Computer Adaptive Testing (CAT) principles.
3. Continue until `total_question_cap` reached or all domains have 2+ data points.
4. On completion: Write proficiency records for all assessed topics.
### 5.14 Proficiency (NEW -- `adaptive.service.ts`)
| Method | Path | Params | Response |
|--------|------|--------|----------|
| `GET` | `/api/proficiency` | Query: `subject_id?` | `Proficiency[]` |
| `GET` | `/api/proficiency/summary` | -- | `ProficiencySummary[]` |
| `GET` | `/api/proficiency/class` | Query: `subject_id?` | Class-level aggregation |
**ProficiencySummary shape:**
```json
{
"subject_id": 1,
"subject_name": "IELTS",
"overall_mastery": 65.5,
"domain_scores": [{ "domain_id": 1, "domain_name": "Writing", "mastery": 72 }],
"topics_mastered": 8,
"topics_total": 20
}
```
### 5.15 Learning Plan (NEW -- `adaptive.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `GET` | `/api/learning-plan` | Query: `subject_id` | `LearningPlan` |
| `POST` | `/api/learning-plan/generate` | `{ subject_id, target_completion? }` | `LearningPlan` |
| `PATCH` | `/api/learning-plan/{id}` | `Partial<LearningPlan>` | `LearningPlan` |
| `POST` | `/api/learning-plan/{id}/pause` | -- | `LearningPlan` |
| `POST` | `/api/learning-plan/{id}/resume` | -- | `LearningPlan` |
**Plan generation algorithm:**
1. Get student's proficiency records for the subject.
2. Identify topics below mastery threshold.
3. Topologically sort topics by prerequisites.
4. Create plan items in sequence. First item = available, rest = locked.
5. Call GPT-4o to generate `ai_summary` (natural language description of the plan).
6. Set `estimated_hours` from topic definitions.
### 5.16 Content Delivery (NEW -- `adaptive.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `GET` | `/api/topics/{id}/content` | -- | `TopicContent` |
| `POST` | `/api/topics/{id}/generate-content` | -- | `TopicContent` |
| `POST` | `/api/topics/{id}/practice` | -- | `{ questions: [] }` |
| `POST` | `/api/topics/{id}/practice/grade` | `{ answers: [] }` | `{ results: [], mastery_update: number }` |
| `POST` | `/api/topics/{id}/mastery-quiz` | -- | `{ questions: [] }` |
| `POST` | `/api/topics/{id}/mastery-quiz/submit` | `{ answers: [] }` | `{ passed: boolean, score: number, mastery_update: number }` |
**TopicContent shape:**
```json
{
"topic_id": 5,
"topic_name": "Line Graphs",
"resources": [{ "id": 1, "name": "...", "resource_type": "pdf", "url": "..." }],
"ai_content": {
"explanation": "Line graphs show...",
"examples": ["Example 1...", "Example 2..."],
"key_points": ["Point 1", "Point 2"],
"model_used": "gpt-4o"
},
"has_content_gap": true
}
```
**`has_content_gap`** = true when no human-uploaded resources exist for this topic, and AI content was generated to fill the gap.
### 5.17 AI Coaching (NEW -- `coaching.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/coach/chat` | `{ message, context?: { page?, topic_id?, subject_id? }, history?: AiChatMessage[] }` | `{ message: string, suggestions?: string[] }` |
| `POST` | `/api/coach/hint` | `{ topic_id, question_id }` | `{ hint: string }` |
| `POST` | `/api/coach/explain` | `{ context, scores? }` | `{ explanation: string }` |
| `POST` | `/api/coach/suggest` | `{ subject_id? }` | `{ suggestions: string[], study_plan_tips: string[] }` |
| `POST` | `/api/coach/writing-help` | `{ text, task_type }` | `{ feedback, improved, grammar_notes: string[] }` |
| `GET` | `/api/coach/tip` | Query: `context` | `{ title, content, category }` |
All coaching endpoints use GPT-4o with appropriate system prompts.
### 5.18 AI Utilities (NEW -- `analytics.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `POST` | `/api/ai/search` | `{ query }` | `AiSearchResult[]` |
| `POST` | `/api/ai/insights` | `{ data: {} }` | `AiInsight[]` |
| `GET` | `/api/ai/alerts` | -- | `AiAlert[]` |
| `POST` | `/api/ai/report-narrative` | `{ report_type, data: {} }` | `{ narrative: string }` |
| `POST` | `/api/ai/batch-optimize` | `{ batch_id }` | `AiBatchOptimization[]` |
| `POST` | `/api/ai/grade-suggest` | `{ submission_id, text, rubric_id? }` | `AiGradingResult` |
### 5.19 Analytics (NEW -- `analytics.service.ts`)
| Method | Path | Params | Response |
|--------|------|--------|----------|
| `GET` | `/api/analytics/student` | Query params | Dashboard data |
| `GET` | `/api/analytics/class` | Query params | Class analytics |
| `GET` | `/api/analytics/subject` | Query params | Subject analytics |
| `GET` | `/api/analytics/content-gaps` | Query: `subject_id?` | `{ gaps: [{ topic_id, topic_name, resource_count }] }` |
### 5.20 LMS Bridge (NEW -- `lms.service.ts`)
| Method | Path | Request | Response |
|--------|------|---------|----------|
| `GET` | `/api/courses` | Query: `page`, `size`, `search`, `status` | `PaginatedResponse<Course>` |
| `GET` | `/api/courses/{id}` | -- | `Course` |
| `POST` | `/api/courses` | `CourseCreateRequest` | `Course` |
| `PATCH` | `/api/courses/{id}` | `Partial<CourseCreateRequest>` | `Course` |
| `DELETE` | `/api/courses/{id}` | -- | `{ success: boolean }` |
| `POST` | `/api/courses/ai-generate` | `{ title, subject_id?, level? }` | `{ outline: {} }` |
| `GET` | `/api/batches` | Query: pagination | `PaginatedResponse<Batch>` |
| `GET` | `/api/batches/{id}` | -- | `Batch` |
| `POST` | `/api/batches` | `Partial<Batch>` | `Batch` |
| `PATCH` | `/api/batches/{id}` | `Partial<Batch>` | `Batch` |
| `DELETE` | `/api/batches/{id}` | -- | `{ success: boolean }` |
| `GET` | `/api/timetable` | Query: `course_id?`, `teacher_id?`, `batch_id?` | `TimetableSession[]` |
| `POST` | `/api/timetable` | `Partial<TimetableSession>` | `TimetableSession` |
| `GET` | `/api/attendance` | Query: `course_id?`, `student_id?`, `date?` | `AttendanceRecord[]` |
| `POST` | `/api/attendance` | `{ course_id, date, records: [{student_id, status}] }` | `{ success: boolean }` |
| `GET` | `/api/grades` | Query: `course_id?`, `student_id?` | `GradeRecord[]` |
**Course shape:** See `src/types/lms.ts` -- must include: `id`, `title`, `code`, `subject_id?`, `subject_name?`, `instructor_id`, `instructor_name`, `description`, `level`, `modules[]`, `enrolled`, `max_capacity`, `status`, `start_date`, `end_date`, `progress?`.
---
## 6. Data Model Specification
All data model details are in **Section 30 of `ENCOACH_UNIFIED_SRS.md`** (existing models: 30.1, new models: 30.2-30.4). Refer to that document for the complete field-level specification.
**Key relationships:**
```
Subject (1) → (N) Domain (1) → (N) Topic (1) → (N) LearningObjective
Topic (M) ↔ (M) Topic (prerequisites -- self-referencing M2M)
Topic (M) ↔ (M) Resource
Student (1) → (N) Proficiency (per topic)
Student (1) → (N) LearningPlan (per subject) → (N) LearningPlanItem (per topic)
Exam → Subject (optional M2O)
Exam → Topic[] (optional M2M)
```
---
## 7. AI Service Integration
The following external AI services are already configured (API keys in `encoach_core/data/constants.xml` and server `.env`):
| Service | Purpose | Config Key |
|---------|---------|-----------|
| OpenAI GPT-4o | Content generation, grading, coaching, plan generation | `encoach.openai_api_key` |
| OpenAI GPT-3.5-turbo | Lightweight tasks (tips, search, suggestions) | Same key |
| OpenAI Whisper (local) | Speech-to-text | Local model |
| AWS Polly (Neural) | Text-to-speech for listening exams | `encoach.aws_access_key_id`, `encoach.aws_secret_access_key` |
| ELAI | AI avatar video generation | `encoach.elai_api_key` |
| GPTZero | AI writing detection | `encoach.gptzero_api_key` |
| FAISS + SentenceTransformers | Semantic similarity for training tips | Local model (`all-MiniLM-L6-v2`) |
**New AI tasks to implement:**
| Task | Model | Endpoint |
|------|-------|----------|
| Diagnostic question generation | GPT-4o | `/api/diagnostic/start`, `/answer` |
| Learning plan generation | GPT-4o | `/api/learning-plan/generate` |
| Topic content generation | GPT-4o | `/api/topics/{id}/generate-content` |
| Practice question generation | GPT-4o | `/api/topics/{id}/practice` |
| Mastery quiz generation | GPT-4o | `/api/topics/{id}/mastery-quiz` |
| AI coaching chat | GPT-4o | `/api/coach/chat` |
| Study suggestions | GPT-3.5-turbo | `/api/coach/suggest` |
| Writing feedback | GPT-4o | `/api/coach/writing-help` |
| Grade explanation | GPT-4o | `/api/coach/explain` |
| Contextual tips | GPT-3.5-turbo | `/api/coach/tip` |
| Semantic search | FAISS | `/api/ai/search` |
| Report narrative | GPT-4o | `/api/ai/report-narrative` |
| Data insights | GPT-4o | `/api/ai/insights` |
| Topic suggestion | GPT-4o | `/api/domains/{id}/ai-suggest` |
| Course outline generation | GPT-4o | `/api/courses/ai-generate` |
| Batch optimization | GPT-3.5-turbo | `/api/ai/batch-optimize` |
---
## 8. Response Format Standards
### 8.1 Paginated Responses
All list endpoints that support pagination must return:
```json
{
"items": [...],
"total": 150,
"page": 1,
"size": 20,
"pages": 8
}
```
Query parameters: `page` (1-based), `size` (default 20), `search` (text filter), `sort` (field name), `order` (asc/desc).
### 8.2 Error Responses
```json
{
"error": "Human-readable error message",
"code": "ERROR_CODE",
"details": {}
}
```
HTTP status codes: 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), 500 (server error).
### 8.3 Success Responses
For write operations that don't return an entity:
```json
{
"success": true,
"message": "Optional message"
}
```
### 8.4 JWT Token
- Token returned on login as `{ token: "...", user: {...} }`
- Frontend sends `Authorization: Bearer <token>` on every request
- 401 response = token expired, frontend redirects to login
---
## 9. Non-Functional Requirements
| Requirement | Specification |
|-------------|--------------|
| API response time (CRUD) | < 2 seconds |
| Diagnostic question generation | < 3 seconds |
| AI grading (writing/speaking) | < 60 seconds |
| Learning plan generation | < 10 seconds |
| AI content generation | < 15 seconds |
| Concurrent students per subject | 200 |
| Topics per subject | 500 |
| Resources per subject | 1,000 |
| Pagination on all list endpoints | Required |
| Server-side search/filter | Required |
| Soft delete for resources | Required (preserve completion records) |
| Proficiency weighted averages | Required (never direct overwrite) |
| Math content | LaTeX/KaTeX formatting in question text |
| IT content | Code blocks with language hints in question text |
---
## 10. Implementation Priority
| Phase | Modules | Endpoints | Estimated Effort |
|-------|---------|-----------|-----------------|
| **P0** | `encoach_taxonomy`, modify `encoach_exam`/`encoach_stats`/`encoach_training` | Taxonomy CRUD (14), Subject/Topic fields on existing models | 1 week |
| **P1** | `encoach_adaptive`, `encoach_adaptive_api`, `encoach_adaptive_ai` | Diagnostic (3), Proficiency (3), Learning Plan (5), Content (6) | 2 weeks |
| **P1** | `encoach_resources` | Resource CRUD (7) | 3 days |
| **P1** | `encoach_lms_api` | LMS bridge (13) | 1 week |
| **P2** | `encoach_adaptive_ai` (coaching) | AI Coaching (6), AI Utilities (5) | 1 week |
| **P2** | Analytics controllers | Analytics (4) | 3 days |
| **P3** | `encoach_sis`, `encoach_branding` | SIS (4), Branding | 1 week |
| **P3** | Multi-subject prompts/grading | Modify generation & grading modules | 3 days |
| **TOTAL** | 8 new modules + 7 modified | ~65 new endpoints | ~7 weeks |
---
## Appendix: File Handoff Checklist
| File/Folder | Purpose | Location |
|-------------|---------|----------|
| This document | Backend SRS | `ODOO_BACKEND_SRS_v3.md` |
| Product SRS | Full platform specification | `ENCOACH_UNIFIED_SRS.md` |
| TypeScript types | Response shape contracts | `encoach_frontend_new/src/types/*.ts` |
| Service layer | Endpoint path contracts | `encoach_frontend_new/src/services/*.ts` |
| Existing modules | Already-built backend | `ielts-be-v0/` |
| Developer feedback | Review of existing work | `ielts-be-v0/DEVELOPER_FEEDBACK.md` |
| AI keys & config | Service credentials | `ielts-be-v0/encoach_core/data/constants.xml` |
---
*This document supersedes `ODOO_MIGRATION_SRS_v2.md`. The Odoo developer should treat this as the definitive backend specification. All endpoint paths, response shapes, and data models are aligned with the production frontend at `encoach_frontend_new/`.*

View File

@@ -0,0 +1,100 @@
# Odoo Developer Handoff — Implementation Complete
**Status:** All deliverables implemented and deployed to staging.
**Date:** March 11, 2026
---
## 1. Current Repositories
| Repository | URL | Description |
|-----------|-----|-------------|
| **Frontend v2** | `https://git.albousalh.com/devops/encoach_frontend_new_v2.git` | React 18 SPA (93 pages, 37 services, 29 types) |
| **Backend v2** | `https://git.albousalh.com/devops/encoach_backend_new_v2.git` | Odoo 19 backend (27 custom + 14 OpenEduCat = 41 modules) |
**Note:** The older repositories (`encoach_frontend_new_v1`, `encoach_be_odoo19`, `ielts-be-v0`) are superseded. All development should continue in the v2 repositories above.
---
## 2. Staging Environment
| Service | URL |
|---------|-----|
| **Frontend** | `http://5.189.151.117:3000` |
| **Backend (Odoo 19)** | `http://5.189.151.117:8069` |
Deployment is automated via Git post-receive hooks. Push to `main` triggers rebuild.
---
## 3. Documentation
| Document | Status | Description |
|----------|--------|-------------|
| `ENCOACH_UNIFIED_SRS.md` (v2.0) | **Current** | Unified frontend + backend SRS with implementation traceability |
| `ENCOACH_ODOO19_BACKEND_SRS.md` (v3.0) | **Current** | Backend-specific SRS with all API contracts and data models |
| `ENCOACH_PRODUCT_DESCRIPTION.md` | **Current** | Non-technical product description |
| `ENCOACH_SYSTEM_FEATURES_GUIDE.md` | **Current** | System features guide by sidebar section |
| `ODOO_BACKEND_SRS_v3.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_SRS_v2.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_SRS.md` | **Superseded** | Replaced by `ENCOACH_ODOO19_BACKEND_SRS.md` v3.0 |
| `ODOO_MIGRATION_FRONTEND_SRS.md` | **Superseded** | Replaced by `ENCOACH_UNIFIED_SRS.md` v2.0 |
---
## 4. What Was Implemented
### Frontend (93 page components)
- **28 root pages** -- login, register, FAQ, exam runner, etc.
- **33 admin pages** -- full LMS/platform administration
- **19 student pages** -- courses, adaptive learning, communication
- **14 teacher pages** -- course builder, AI workbench, grading
- **37 API service modules** calling ~280+ backend endpoints
- **14 AI components** wired to real backend AI services
### Backend (41 Odoo modules, ~377 API routes)
- **`encoach_api`** -- 16 controller files, ~120 routes (auth, users, entities, roles, exams, assignments, classrooms, stats, subscriptions, tickets, approvals, training, generation, AI analytics)
- **`encoach_lms_api`** -- 28 controller files, ~209 routes (courses, students, teachers, batches, timetable, attendance, grades, departments, academic, admissions, subject registration, institutional exams, course assignments, student leave, fees, lessons, gradebook, student progress, library, activities, facilities, notifications, FAQ, courseware, plagiarism, communication)
- **`encoach_adaptive_api`** -- 6 controller files, ~41 routes (taxonomy, diagnostic, proficiency, learning plans, content, coaching)
- **`encoach_resources`** -- 1 controller file, ~7 routes (resource CRUD)
### Beyond Original SRS
The developer also implemented:
- Student Leave Management (types, requests, approve/reject)
- Fees Management (plans, student fees, terms)
- Lessons Management (timetable-linked)
- Gradebook and Grading Assignments
- Student Progress Tracking (aggregate)
- Library Management (media, movements, cards)
- Activity Management (configurable types)
- Facility and Asset Management
- Roles CRUD, User Role Assignment, Authority Matrix
---
## 5. AI Service Credentials
The developer needs API keys for:
| Service | Purpose |
|---------|---------|
| **OpenAI** | GPT-4o and Whisper -- grading, coaching, content generation, speech-to-text |
| **AWS Polly** | Text-to-speech (neural voices) |
| **ELAI** | AI avatar video generation |
| **GPTZero** | AI writing detection |
These are in `06-All-Credentials-Inventory.md` locally. Keys must be provided **separately** -- never committed to the git repo. On staging, they are in `/opt/encoach/backend-v2/.env`.
---
## 6. Frontend Code Reference
For any new backend development, the frontend contract is defined in:
- **`src/services/*.ts`** (37 files) -- every URL path, request body shape, and response handling
- **`src/types/*.ts`** (29 files) -- all TypeScript interfaces the frontend expects from the API
- **`src/hooks/queries/*.ts`** (21 files) -- TanStack Query cache keys and query configurations

View File

@@ -0,0 +1,910 @@
# EnCoach Frontend API Migration SRS
> **SUPERSEDED** -- This document has been replaced by `ENCOACH_UNIFIED_SRS.md` (v2.0). The migration is complete. The old `ielts-ui` Next.js frontend has been replaced by `encoach_frontend_new_v2` (React 18 + Vite + TypeScript), deployed at `http://5.189.151.117:3000`.
## ielts-ui Migration Guide: Next.js API Routes to Odoo 19
**Version:** 1.0
**Date:** March 11, 2026
**Status:** ~~Active~~ **SUPERSEDED**
**Audience:** Frontend developer(s) maintaining ielts-ui
**Companion document:** `ODOO_MIGRATION_SRS.md` (backend Odoo specification)
---
## Table of Contents
1. [Migration Overview](#1-migration-overview)
2. [Authentication Migration](#2-authentication-migration)
3. [API Base URL Change](#3-api-base-url-change)
4. [Endpoint-by-Endpoint Migration Map](#4-endpoint-by-endpoint-migration-map)
5. [SSR Migration Strategy](#5-ssr-migration-strategy)
6. [Files to Delete](#6-files-to-delete)
7. [Migration Checklist](#7-migration-checklist)
---
## 1. Migration Overview
### 1.1 What Is Changing
The ielts-ui Next.js application currently serves two roles:
1. **Browser UI** -- React pages, components, Zustand stores, hooks
2. **BFF (Backend-for-Frontend)** -- 108 API route files in `src/pages/api/` that query MongoDB directly, manage iron-session cookies, and proxy AI requests
After migration, ielts-ui serves **only the Browser UI**. All backend logic moves to Odoo 19. The `src/pages/api/` directory and all server-side database utilities are eliminated entirely.
### 1.2 Current Architecture (what the frontend sees)
```
Browser (React)
├── axios/SWR/fetch calls to /api/* (same origin)
└── Next.js API Routes (src/pages/api/)
├── iron-session cookie auth
├── MongoDB queries (via src/utils/*.be.ts)
└── Proxy to AI backend (via BACKEND_URL)
```
### 1.3 Target Architecture (what the frontend sees)
```
Browser (React)
├── axios/SWR/fetch calls to ODOO_URL/api/* (cross-origin)
│ └── Authorization: Bearer <JWT>
└── Odoo 19 (separate service)
├── JWT auth
├── PostgreSQL (replaces MongoDB)
└── All AI/ML handled internally by Odoo
```
### 1.4 What Does NOT Change
- All React page components, layouts, and UI rendering
- Zustand stores (`src/stores/exam/`, `src/stores/preferencesStore.ts`, etc.)
- All components in `src/components/` (they still call the same API shapes -- only the base URL and auth mechanism change)
- Styling (Tailwind, DaisyUI)
- TypeScript interfaces in `src/interfaces/`
- Client-side routing
- Strapi CMS and the landing page
### 1.5 Guiding Principle
Odoo's REST API is designed to return the **same JSON shapes** the frontend currently expects. If the frontend calls `GET /api/users/list` and expects `{ users: [...], total: N }`, Odoo's `GET /api/users/list` returns the same shape. The migration is primarily a **transport change** (base URL + auth header), not a data format change.
---
## 2. Authentication Migration
This is the most significant structural change.
### 2.1 Current Auth Pattern
| Aspect | Current Implementation |
|--------|----------------------|
| **Identity provider** | Firebase Auth (`signInWithEmailAndPassword`) |
| **Session** | iron-session (encrypted httpOnly cookie named `eCrop/ielts`) |
| **Client auth** | Automatic -- browser sends cookie on same-origin requests |
| **SSR auth** | `withIronSessionSsr` + `getServerSideProps` reads `req.session.user` |
| **User hook** | `useUser()` calls `GET /api/user` via SWR; cookie sent automatically |
| **Config** | `src/lib/session.ts` (`SECRET_COOKIE_PASSWORD`, `cookieName`) |
### 2.2 Target Auth Pattern
| Aspect | Target Implementation |
|--------|----------------------|
| **Identity provider** | Odoo 19 native auth (`res.users`) |
| **Session** | JWT token issued by Odoo |
| **Client auth** | `Authorization: Bearer <token>` header on every request |
| **SSR auth** | JWT stored in httpOnly cookie; `getServerSideProps` reads cookie and calls Odoo |
| **User hook** | `useUser()` calls `GET ODOO_URL/api/user` with JWT header |
| **Config** | `NEXT_PUBLIC_API_URL` env var |
### 2.3 New Auth Infrastructure
Create a new file `src/lib/api.ts`:
```typescript
import axios from "axios";
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
});
api.interceptors.request.use((config) => {
const token = getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearToken();
if (typeof window !== "undefined") {
window.location.href = "/login";
}
}
return Promise.reject(error);
}
);
export default api;
```
JWT storage helpers (also in `src/lib/api.ts` or a separate `src/lib/auth.ts`):
```typescript
import Cookies from "js-cookie";
const TOKEN_KEY = "encoach_token";
export function setToken(token: string) {
Cookies.set(TOKEN_KEY, token, { secure: true, sameSite: "lax" });
}
export function getToken(): string | undefined {
return Cookies.get(TOKEN_KEY);
}
export function clearToken() {
Cookies.remove(TOKEN_KEY);
}
```
**Note:** Using a cookie for JWT storage (rather than localStorage) is recommended because `getServerSideProps` can read cookies from the request headers, enabling SSR auth. The cookie is NOT httpOnly in this approach (so JS can read it for the axios interceptor). If httpOnly is required, use a thin proxy or Next.js middleware to inject the header.
### 2.4 Files That Change for Auth
| File | Current | Target |
|------|---------|--------|
| `src/pages/login.tsx` | `axios.post("/api/login", { email, password })` | `api.post("/api/login", { email, password })` -- store JWT from response |
| `src/pages/register.tsx` | `fetch` to `/api/register` (SSR) + code lookup | `api.post("/api/register", payload)` -- store JWT from response |
| `src/pages/official-exam.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/components/Sidebar.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/components/MobileMenu.tsx` | `axios.post("/api/logout")` | `api.post("/api/logout")` + `clearToken()` |
| `src/utils/email.ts` | `axios.post("/api/reset/sendVerification")` | `api.post("/api/reset/sendVerification")` |
| `src/hooks/useUser.tsx` | SWR fetcher uses `axios.get("/api/user")` | SWR fetcher uses `api.get("/api/user")` |
| `src/lib/session.ts` | iron-session config | **DELETE** -- replaced by `src/lib/api.ts` |
### 2.5 Login Flow Change
**Current:**
1. User submits email + password
2. `POST /api/login` (same origin)
3. API route calls Firebase Auth, looks up user in MongoDB, saves to iron-session
4. Response: user JSON + `Set-Cookie` header
5. `mutateUser(response.data)` updates SWR cache
**Target:**
1. User submits email + password
2. `POST ODOO_URL/api/login` (cross-origin)
3. Odoo validates credentials, returns `{ user: {...}, token: "jwt..." }`
4. Frontend stores JWT via `setToken(response.data.token)`
5. `mutateUser(response.data.user)` updates SWR cache
**Login page change (`src/pages/login.tsx`):**
```typescript
// BEFORE
const response = await axios.post("/api/login", { email, password });
mutateUser(response.data);
// AFTER
import api, { setToken } from "@/lib/api";
const response = await api.post("/api/login", { email, password });
setToken(response.data.token);
mutateUser(response.data.user);
```
### 2.6 Logout Flow Change
**Current:** `POST /api/logout` destroys iron-session.
**Target:** `POST ODOO_URL/api/logout` (optional server-side invalidation) + `clearToken()` client-side.
```typescript
// BEFORE
await axios.post("/api/logout");
mutateUser(null);
router.push("/login");
// AFTER
import api, { clearToken } from "@/lib/api";
await api.post("/api/logout").catch(() => {});
clearToken();
mutateUser(null);
router.push("/login");
```
---
## 3. API Base URL Change
### 3.1 Environment Variable
Add to `.env.local`:
```
NEXT_PUBLIC_API_URL=https://api.encoach.com
```
Development:
```
NEXT_PUBLIC_API_URL=http://localhost:8069
```
### 3.2 Shared Axios Instance
Replace all direct `axios` imports with the shared `api` instance from `src/lib/api.ts` (defined in Section 2.3).
**Search and replace pattern across the codebase:**
```typescript
// BEFORE (scattered across ~40 files)
import axios from "axios";
axios.get("/api/...");
axios.post("/api/...");
axios.patch("/api/...");
axios.delete("/api/...");
// AFTER
import api from "@/lib/api";
api.get("/api/...");
api.post("/api/...");
api.patch("/api/...");
api.delete("/api/...");
```
The `/api/...` path stays the same -- the `baseURL` on the axios instance prepends the Odoo host.
### 3.3 SWR Fetcher Update
Update `src/hooks/useUser.tsx`:
```typescript
// BEFORE
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
// AFTER
import api from "@/lib/api";
const fetcher = (url: string) => api.get(url).then((res) => res.data);
```
### 3.4 fetch() Calls
A few files use `fetch()` instead of axios (primarily `src/components/ExamEditor/ImportExam/WordUploader.tsx`). These also need the base URL and auth header:
```typescript
// BEFORE
const res = await fetch(`/api/exam/${module}/import/`, { method: "POST", body: formData });
// AFTER
import { getToken } from "@/lib/api";
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/exam/${module}/import/`, {
method: "POST",
body: formData,
headers: { Authorization: `Bearer ${getToken()}` },
});
```
**Note:** `fetch()` calls to blob URLs (e.g., `fetch(blobUrl)` for audio/video previews) do NOT change. Only calls to `/api/*` endpoints change.
---
## 4. Endpoint-by-Endpoint Migration Map
For each API call the frontend makes, this section specifies the current URL, new Odoo URL, HTTP method, and every file that makes the call.
**Convention:** Where the Odoo URL is the same path as the current URL (just different host), only the base URL changes. These are marked "Path unchanged." Where the Odoo URL differs, the new path is shown.
### 4.1 Auth
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 1 | POST | `/api/login` | Path unchanged | `src/pages/login.tsx` |
| 2 | POST | `/api/logout` | Path unchanged | `src/pages/official-exam.tsx`, `src/components/Sidebar.tsx`, `src/components/MobileMenu.tsx` |
| 3 | POST | `/api/reset` | Path unchanged | `src/pages/login.tsx` |
| 4 | POST | `/api/reset/sendVerification` | Path unchanged | `src/utils/email.ts` |
**Response change for login:** Odoo returns `{ user: {...}, token: "jwt..." }` instead of just the user object. Frontend must extract and store the token.
### 4.2 User
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 5 | GET | `/api/user` | Path unchanged | `src/hooks/useUser.tsx` (SWR) |
| 6 | DELETE | `/api/user` | Path unchanged | (called from useUser context) |
| 7 | POST | `/api/users/update` | `PATCH /api/users/update` | `src/pages/profile.tsx`, `src/components/UserCard.tsx` |
| 8 | GET | `/api/users/list` | Path unchanged | `src/utils/groups.ts` |
| 9 | GET | `/api/users/{id}` | Path unchanged | `src/utils/groups.ts` |
| 10 | POST | `/api/users/controller` | Path unchanged | `src/components/Imports/StudentClassroomTransfer.tsx` |
| 11 | GET | `/api/users/balance` | Path unchanged | `src/hooks/useUserBalance.tsx` (if used) |
**Note on #7:** The current frontend uses `POST` for user updates. Odoo convention is `PATCH`. Either Odoo can accept both, or the frontend changes the method.
### 4.3 Registration / Codes
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 12 | POST | `/api/register` | Path unchanged | `src/pages/register.tsx` (SSR currently; move to client-side) |
| 13 | GET | `/api/code/{code}` | Path unchanged | `src/pages/register.tsx` |
**Note on #12:** Registration is currently handled in `getServerSideProps`. With Odoo, move registration to a client-side form submission. The register page should call `api.post("/api/register", payload)` and store the returned JWT.
### 4.4 Exams
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 14 | GET | `/api/exam` | Path unchanged | `src/hooks/useExams.tsx` |
| 15 | GET | `/api/exam/{module}?{params}` | Path unchanged | `src/utils/exams.ts` (`getExam`) |
| 16 | GET | `/api/exam/{module}/{id}` | Path unchanged | `src/utils/exams.ts` (`getExamById`) |
| 17 | POST | `/api/exam/{module}` | Path unchanged | `src/components/ExamEditor/SettingsEditor/reading/index.tsx`, `writing/index.tsx`, `listening/index.tsx`, `speaking/index.tsx`, `level.tsx` |
| 18 | PATCH | `/api/exam/{module}/{id}` | Path unchanged | `src/pages/(admin)/Lists/ExamList.tsx`, `src/pages/approval-workflows/[id]/index.tsx` |
| 19 | DELETE | `/api/exam/{module}/{id}` | Path unchanged | `src/pages/(admin)/Lists/ExamList.tsx` |
| 20 | GET | `/api/exam/avatars` | Path unchanged | `src/pages/generation.tsx` |
| 21 | GET/POST | `/api/exam/generate/{module}/{sectionId}` | Path unchanged | `src/components/ExamEditor/SettingsEditor/Shared/Generate.ts` |
| 22 | POST | `/api/exam/media/speaking` | Path unchanged | `src/components/ExamEditor/SettingsEditor/Shared/generateVideos.ts` |
| 23 | POST | `/api/exam/media/listening` | Path unchanged | `src/components/ExamEditor/SettingsEditor/listening/components.tsx` |
| 24 | POST | `/api/exam/media/instructions` | Path unchanged | `src/components/ExamEditor/Standalone/ListeningInstructions/index.tsx` |
| 25 | POST | `/api/exam/{module}/import/` | Path unchanged | `src/components/ExamEditor/ImportExam/WordUploader.tsx` (uses `fetch`) |
### 4.5 Evaluation / Grading
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 26 | POST | `/api/evaluate/writing` | Path unchanged | `src/utils/evaluation.ts` |
| 27 | POST | `/api/evaluate/speaking` | Path unchanged | `src/utils/evaluation.ts` |
| 28 | POST | `/api/evaluate/interactiveSpeaking` | Path unchanged | `src/utils/evaluation.ts` |
| 29 | GET | `/api/evaluate/status?...` | Path unchanged | `src/utils/evaluation.ts` (polling) |
| 30 | GET | `/api/evaluate/fetchSolutions?...` | Path unchanged | `src/utils/evaluation.ts` |
| 31 | POST | `/api/grading` | Path unchanged | `src/pages/api/grading/index.ts` (if called by frontend) |
| 32 | POST | `/api/grading/multiple` | Path unchanged | (if called by frontend) |
### 4.6 Storage
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 33 | POST | `/api/storage` | Path unchanged | `src/components/ExamEditor/SettingsEditor/writing/index.tsx`, `speaking/index.tsx`, `listening/index.tsx`, `level.tsx` |
| 34 | POST | `/api/storage/delete` | `DELETE /api/storage/{id}` (or keep POST) | `src/utils/evaluation.ts` |
**Note:** Odoo will use `ir.attachment` for file storage. The response must return a publicly accessible URL for the uploaded file, matching the current behavior.
### 4.7 Speaking Audio
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 35 | POST | `/api/speaking` | Path unchanged | `src/components/Solutions/Speaking.tsx`, `src/components/Solutions/InteractiveSpeaking.tsx` |
### 4.8 Sessions
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 36 | POST | `/api/sessions` | Path unchanged | `src/stores/exam/index.ts` (Zustand `saveSession`) |
| 37 | DELETE | `/api/sessions/{id}` | Path unchanged | `src/components/Medium/SessionCard.tsx` |
### 4.9 Stats
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 38 | POST | `/api/stats` | Path unchanged | `src/stores/exam/index.ts` (Zustand `saveStats`) |
| 39 | GET | `/api/stats/{id}` | Path unchanged | `src/pages/training/[id]/index.tsx` |
| 40 | POST | `/api/statistical` | Path unchanged | `src/pages/statistical.tsx` |
### 4.10 Training
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 41 | POST | `/api/training` | Path unchanged | `src/pages/training/index.tsx` |
| 42 | GET | `/api/training/{id}` | Path unchanged | `src/pages/training/[id]/index.tsx` |
| 43 | GET | `/api/training/walkthrough` | Path unchanged | `src/pages/training/[id]/index.tsx` |
### 4.11 Groups
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 44 | GET | `/api/groups?participant={userId}` | Path unchanged | `src/utils/groups.ts` |
| 45 | POST | `/api/groups` | Path unchanged | `src/pages/classrooms/create.tsx` |
| 46 | PATCH | `/api/groups/{id}` | Path unchanged | `src/pages/classrooms/[id].tsx` |
| 47 | DELETE | `/api/groups/{id}` | Path unchanged | `src/pages/classrooms/[id].tsx` |
### 4.12 Entities
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 48 | POST | `/api/entities` | Path unchanged | `src/pages/entities/create.tsx` |
| 49 | PATCH | `/api/entities/{id}` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
| 50 | DELETE | `/api/entities/{id}` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
| 51 | PATCH | `/api/entities/{id}/users` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
### 4.13 Roles
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 52 | POST | `/api/roles` | Path unchanged | `src/pages/entities/[id]/roles/index.tsx` |
| 53 | PATCH | `/api/roles/{id}` | Path unchanged | `src/pages/entities/[id]/roles/[role].tsx` |
| 54 | DELETE | `/api/roles/{id}` | Path unchanged | `src/pages/entities/[id]/roles/[role].tsx` |
| 55 | POST | `/api/roles/{id}/users` | Path unchanged | `src/pages/entities/[id]/index.tsx` |
### 4.14 Permissions
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 56 | PATCH | `/api/permissions/{id}` | Path unchanged | `src/pages/permissions/[id].tsx` |
### 4.15 Payments
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 57 | POST | `/api/payments` | Path unchanged | `src/pages/payment-record.tsx` |
| 58 | PATCH | `/api/payments/{id}` | Path unchanged | `src/pages/payment-record.tsx` |
| 59 | DELETE | `/api/payments/{id}` | Path unchanged | `src/pages/payment-record.tsx` |
| 60 | GET/POST/DELETE | `/api/payments/files/{type}/{paymentId}` | Path unchanged | `src/components/PaymentAssetManager.tsx` |
| 61 | POST | `/api/paymob` | Path unchanged | `src/components/PaymobPayment.tsx` |
**Bug fix opportunity:** In `src/pages/payment-record.tsx`, the PATCH call uses `api/payments/${id}` without a leading `/`. When switching to the shared axios instance with a `baseURL`, this will work correctly. But verify during testing.
### 4.16 Assignments
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 62 | POST | `/api/assignments` | Path unchanged | `src/pages/assignments/creator/index.tsx` |
| 63 | PATCH | `/api/assignments/{id}` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
| 64 | DELETE | `/api/assignments/{id}` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
| 65 | POST | `/api/assignments/{id}/start` | Path unchanged | `src/pages/assignments/creator/[id].tsx`, `src/pages/assignments/[id].tsx` |
### 4.17 Invites
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 66 | GET | `/api/invites/{decision}/{id}` | `POST /api/invites/{decision}/{id}` | `src/components/Medium/InviteCard.tsx`, `src/components/Medium/InviteWithUserCard.tsx` |
**Note:** Currently uses GET for accept/decline actions (side-effectful GET). Odoo should accept POST. Consider updating the frontend to use POST -- it's a more correct HTTP method for state-changing operations.
### 4.18 Approval Workflows
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 67 | POST | `/api/approval-workflows/create` | Path unchanged | `src/pages/approval-workflows/create.tsx` |
| 68 | PUT | `/api/approval-workflows/{id}` | Path unchanged | `src/pages/approval-workflows/[id]/index.tsx` |
| 69 | PUT | `/api/approval-workflows/{id}/edit` | Path unchanged | `src/pages/approval-workflows/[id]/edit.tsx` |
| 70 | DELETE | `/api/approval-workflows/{id}` | Path unchanged | `src/pages/approval-workflows/index.tsx` |
### 4.19 Transcription
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 71 | POST | `/api/transcribe` | Path unchanged | (called from evaluation utils if needed) |
### 4.20 Batch Users
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 72 | POST | `/api/batch_users` | Path unchanged | (admin import page) |
### 4.21 Make User (Admin)
| # | Method | Current URL | Odoo URL | Files |
|---|--------|-------------|----------|-------|
| 73 | POST | `/api/make_user` | Path unchanged | (admin user creation) |
---
## 5. SSR Migration Strategy
### 5.1 Current SSR Auth Pattern
~35 pages use this pattern:
```typescript
import { withIronSessionSsr } from "iron-session/next";
import { sessionOptions } from "@/lib/session";
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user || !user.isVerified) return redirect("/login");
return { props: { user } };
}, sessionOptions);
```
### 5.2 Recommended: JWT Cookie + Server-Side Validation (Option A)
Store the JWT in a regular cookie (set by `js-cookie` on login). In `getServerSideProps`, read the cookie from the request and call Odoo to validate.
**New helper `src/lib/ssr-auth.ts`:**
```typescript
import type { GetServerSidePropsContext, GetServerSidePropsResult } from "next";
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const TOKEN_COOKIE = "encoach_token";
export function withAuth<P extends Record<string, unknown>>(
handler: (ctx: GetServerSidePropsContext, user: User) => Promise<GetServerSidePropsResult<P>>
) {
return async (ctx: GetServerSidePropsContext): Promise<GetServerSidePropsResult<P>> => {
const token = ctx.req.cookies[TOKEN_COOKIE];
if (!token) {
return { redirect: { destination: "/login", permanent: false } };
}
try {
const res = await fetch(`${API_URL}/api/user`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("Unauthorized");
const user = await res.json();
if (!user.isVerified) {
return { redirect: { destination: "/login", permanent: false } };
}
return handler(ctx, user);
} catch {
return { redirect: { destination: "/login", permanent: false } };
}
};
}
```
**Page migration example:**
```typescript
// BEFORE
export const getServerSideProps = withIronSessionSsr(async ({ req, res }) => {
const user = await requestUser(req, res);
if (!user) return redirect("/login");
// ... fetch additional data using user ...
return { props: { user, someData } };
}, sessionOptions);
// AFTER
export const getServerSideProps = withAuth(async (ctx, user) => {
// ... fetch additional data using user ...
return { props: { user, someData } };
});
```
### 5.3 Alternative: Client-Only Auth (Option B)
Remove all `getServerSideProps` auth checks. Every page uses `useUser({ redirectTo: "/login" })` to protect itself.
**Pros:** Simpler; no SSR auth at all.
**Cons:** Brief flash of unauthenticated content; pages that fetch data server-side need to move data fetching to client-side.
### 5.4 Pages Requiring SSR Migration
Every page below currently uses `withIronSessionSsr` and needs to switch to `withAuth` (Option A) or remove SSR auth (Option B):
| Page | SSR Data Fetching |
|------|-------------------|
| `src/pages/index.tsx` | User only (redirects to dashboard) |
| `src/pages/login.tsx` | Reverse guard (redirect if already logged in) |
| `src/pages/dashboard/index.tsx` | User only |
| `src/pages/dashboard/teacher.tsx` | User + groups + assignments + stats |
| `src/pages/dashboard/student.tsx` | User + assignments + stats |
| `src/pages/dashboard/corporate.tsx` | User + entity data |
| `src/pages/dashboard/mastercorporate.tsx` | User + multi-entity data |
| `src/pages/dashboard/admin.tsx` | User + platform stats |
| `src/pages/dashboard/developer.tsx` | User |
| `src/pages/users/index.tsx` | User + user list |
| `src/pages/users/performance.tsx` | User + performance data |
| `src/pages/training/index.tsx` | User |
| `src/pages/training/[id]/index.tsx` | User + training data |
| `src/pages/tickets.tsx` | User + tickets |
| `src/pages/stats.tsx` | User + stats |
| `src/pages/statistical.tsx` | User |
| `src/pages/settings.tsx` | User |
| `src/pages/record.tsx` | User |
| `src/pages/profile.tsx` | User |
| `src/pages/popout.tsx` | User (session-based) |
| `src/pages/permissions/index.tsx` | User + permissions |
| `src/pages/permissions/[id].tsx` | User + permission detail |
| `src/pages/payment.tsx` | User |
| `src/pages/payment-record.tsx` | User + payments |
| `src/pages/official-exam.tsx` | User |
| `src/pages/generation.tsx` | User |
| `src/pages/exercises.tsx` | User + exam data |
| `src/pages/exam.tsx` | User + exam + session data |
| `src/pages/entities/index.tsx` | User + entities |
| `src/pages/entities/create.tsx` | User |
| `src/pages/entities/[id]/index.tsx` | User + entity detail |
| `src/pages/entities/[id]/roles/index.tsx` | User + roles |
| `src/pages/entities/[id]/roles/[role].tsx` | User + role detail |
| `src/pages/classrooms/*.tsx` | User + groups |
| `src/pages/assignments/*.tsx` | User + assignments |
| `src/pages/approval-workflows/*.tsx` | User + workflows |
**For pages that only need user auth (no server-side data):** Replace `getServerSideProps` with client-side `useUser({ redirectTo: "/login" })`.
**For pages that fetch additional data server-side:** Use `withAuth` and make the data-fetching calls to Odoo using the JWT token from the cookie.
---
## 6. Files to Delete
After migration, the following files are no longer needed. They represent the BFF layer being replaced by Odoo.
### 6.1 API Route Files (108 files)
Delete the entire `src/pages/api/` directory:
**Auth & User:**
- `src/pages/api/login.ts`
- `src/pages/api/logout.ts`
- `src/pages/api/register.ts`
- `src/pages/api/user.ts`
- `src/pages/api/make_user.ts`
- `src/pages/api/batch_users.ts`
- `src/pages/api/reset/index.ts`
- `src/pages/api/reset/confirm.ts`
- `src/pages/api/reset/sendVerification.ts`
- `src/pages/api/reset/verify.ts`
- `src/pages/api/users/update.ts`
- `src/pages/api/users/search.ts`
- `src/pages/api/users/list.ts`
- `src/pages/api/users/controller.ts`
- `src/pages/api/users/balance.ts`
- `src/pages/api/users/agents/index.ts`
- `src/pages/api/users/agents/[code].ts`
- `src/pages/api/users/[id].ts`
**Exams:**
- `src/pages/api/exam/index.ts`
- `src/pages/api/exam/upload.ts`
- `src/pages/api/exam/avatars.ts`
- `src/pages/api/exam/generate/[...module].ts`
- `src/pages/api/exam/media/[...module].ts`
- `src/pages/api/exam/media/poll.ts`
- `src/pages/api/exam/media/instructions.ts`
- `src/pages/api/exam/[module]/index.ts`
- `src/pages/api/exam/[module]/import.ts`
- `src/pages/api/exam/[module]/[id].ts`
**Evaluation:**
- `src/pages/api/evaluate/writing.ts`
- `src/pages/api/evaluate/speaking.ts`
- `src/pages/api/evaluate/interactiveSpeaking.ts`
- `src/pages/api/evaluate/status.ts`
- `src/pages/api/evaluate/fetchSolutions.ts`
**Grading:**
- `src/pages/api/grading/index.ts`
- `src/pages/api/grading/multiple.ts`
**Stats & Sessions:**
- `src/pages/api/stats/index.ts`
- `src/pages/api/stats/update.ts`
- `src/pages/api/stats/disabled.ts`
- `src/pages/api/stats/user/[user].ts`
- `src/pages/api/stats/session/[session].ts`
- `src/pages/api/stats/[id]/index.ts`
- `src/pages/api/stats/[id]/[export]/pdf.tsx`
- `src/pages/api/statistical.ts`
- `src/pages/api/sessions/index.ts`
- `src/pages/api/sessions/[id].ts`
**Training:**
- `src/pages/api/training/index.ts`
- `src/pages/api/training/[id].ts`
- `src/pages/api/training/user/[user].ts`
- `src/pages/api/training/walkthrough/index.ts`
**Entities & Groups:**
- `src/pages/api/entities/index.ts`
- `src/pages/api/entities/users.ts`
- `src/pages/api/entities/groups.ts`
- `src/pages/api/entities/[id]/index.ts`
- `src/pages/api/entities/[id]/users.ts`
- `src/pages/api/groups/index.ts`
- `src/pages/api/groups/controller.ts`
- `src/pages/api/groups/[id].ts`
**Roles & Permissions:**
- `src/pages/api/roles/index.ts`
- `src/pages/api/roles/[id]/index.ts`
- `src/pages/api/roles/[id]/users.ts`
- `src/pages/api/permissions/index.ts`
- `src/pages/api/permissions/bootstrap.ts`
- `src/pages/api/permissions/[id].ts`
**Invites & Codes:**
- `src/pages/api/invites/index.ts`
- `src/pages/api/invites/[id].ts`
- `src/pages/api/invites/accept/[id].ts`
- `src/pages/api/invites/decline/[id].ts`
- `src/pages/api/code/index.ts`
- `src/pages/api/code/entities.ts`
- `src/pages/api/code/[id].ts`
**Payments:**
- `src/pages/api/stripe.ts`
- `src/pages/api/paypal/index.ts`
- `src/pages/api/paypal/approve.ts`
- `src/pages/api/paypal/raas.ts`
- `src/pages/api/paymob/index.ts`
- `src/pages/api/paymob/webhook.ts`
- `src/pages/api/payments/index.ts`
- `src/pages/api/payments/[id].ts`
- `src/pages/api/payments/assigned.ts`
- `src/pages/api/payments/paypal.ts`
- `src/pages/api/payments/files/[type]/[paymentId].ts`
- `src/pages/api/packages/index.ts`
- `src/pages/api/packages/[id].ts`
- `src/pages/api/discounts/index.ts`
- `src/pages/api/discounts/[id].ts`
**Assignments:**
- `src/pages/api/assignments/index.ts`
- `src/pages/api/assignments/corporate/index.ts`
- `src/pages/api/assignments/corporate/[id].ts`
- `src/pages/api/assignments/[id]/index.ts`
- `src/pages/api/assignments/[id]/start.ts`
- `src/pages/api/assignments/[id]/release.ts`
- `src/pages/api/assignments/[id]/archive.tsx`
- `src/pages/api/assignments/[id]/unarchive.tsx`
- `src/pages/api/assignments/[id]/[export]/pdf.tsx`
- `src/pages/api/assignments/[id]/[export]/excel.ts`
**Approval Workflows:**
- `src/pages/api/approval-workflows/index.ts`
- `src/pages/api/approval-workflows/create.ts`
- `src/pages/api/approval-workflows/[id]/index.ts`
- `src/pages/api/approval-workflows/[id]/edit.ts`
**Other:**
- `src/pages/api/storage/index.ts`
- `src/pages/api/storage/insert.ts`
- `src/pages/api/storage/delete.ts`
- `src/pages/api/speaking.ts`
- `src/pages/api/transcribe/index.ts`
- `src/pages/api/tickets/index.ts`
- `src/pages/api/tickets/[id].ts`
- `src/pages/api/tickets/assignedToUser/index.ts`
- `src/pages/api/hello.ts`
### 6.2 Backend Utility Files (14 files)
These files contain MongoDB query logic used by API routes. Delete all:
- `src/utils/approval.workflows.be.ts`
- `src/utils/assignments.be.ts`
- `src/utils/codes.be.ts`
- `src/utils/disabled.be.ts`
- `src/utils/entities.be.ts`
- `src/utils/exams.be.ts`
- `src/utils/grading.be.ts`
- `src/utils/groups.be.ts`
- `src/utils/invites.be.ts`
- `src/utils/permissions.be.ts`
- `src/utils/roles.be.ts`
- `src/utils/sessions.be.ts`
- `src/utils/stats.be.ts`
- `src/utils/users.be.ts`
### 6.3 Library Files (3 files)
- `src/lib/session.ts` -- iron-session config (replaced by `src/lib/api.ts`)
- `src/lib/mongodb.ts` -- MongoDB connection (no longer needed)
- `src/lib/createWorkflowsOnExamCreation.ts` -- server-side workflow logic (moved to Odoo)
**Keep:**
- `src/lib/utils.ts` -- general client-side utilities
- `src/lib/formidable-serverless.d.ts` -- only needed if any remaining file upload logic stays; likely deletable
### 6.4 Firebase Files
If Odoo replaces Firebase Auth entirely, also remove:
- `src/firebase/` directory (Firebase client config)
- `src/constants/platform.json` (Firebase service account -- server-side only)
- `src/constants/staging.json` (Firebase service account -- server-side only)
### 6.5 Dependencies to Remove from package.json
```
iron-session
firebase
firebase-admin
mongodb
@beam-australia/react-env (if no longer needed for client env)
bcrypt
formidable
```
### 6.6 Dependencies to Add
```
js-cookie (JWT cookie storage)
@types/js-cookie (TypeScript types)
```
---
## 7. Migration Checklist
A step-by-step checklist for the frontend developer. Complete in order.
### Phase 1: Infrastructure Setup
- [ ] **1.1** Add `NEXT_PUBLIC_API_URL` to `.env.local` (point to Odoo dev instance)
- [ ] **1.2** Create `src/lib/api.ts` with shared axios instance, base URL, and JWT interceptor (Section 2.3)
- [ ] **1.3** Create `src/lib/auth.ts` with `setToken()`, `getToken()`, `clearToken()` helpers
- [ ] **1.4** Add `js-cookie` dependency: `yarn add js-cookie @types/js-cookie`
- [ ] **1.5** Create `src/lib/ssr-auth.ts` with `withAuth()` helper for SSR (Section 5.2)
### Phase 2: Auth Migration
- [ ] **2.1** Update `src/pages/login.tsx`: use `api.post()`, store JWT, extract user from response
- [ ] **2.2** Update `src/pages/register.tsx`: move registration to client-side, use `api.post()`
- [ ] **2.3** Update logout calls in `Sidebar.tsx`, `MobileMenu.tsx`, `official-exam.tsx`: use `api.post()` + `clearToken()`
- [ ] **2.4** Update `src/hooks/useUser.tsx`: use `api` instance for SWR fetcher
- [ ] **2.5** Update `src/utils/email.ts`: use `api` instance
- [ ] **2.6** Verify login/logout/register work end-to-end with Odoo
### Phase 3: SSR Migration
- [ ] **3.1** For each page in the SSR page list (Section 5.4), replace `withIronSessionSsr` with `withAuth` or client-side `useUser({ redirectTo: "/login" })`
- [ ] **3.2** Update pages that fetch server-side data to call Odoo with the JWT token
- [ ] **3.3** Verify all ~35 protected pages redirect correctly when not authenticated
- [ ] **3.4** Verify the login page redirects correctly when already authenticated
### Phase 4: API Call Migration (domain by domain)
For each domain, replace `axios` with `api` from `@/lib/api`:
- [ ] **4.1** User endpoints (`src/pages/profile.tsx`, `src/components/UserCard.tsx`, `src/utils/groups.ts`, `src/components/Imports/StudentClassroomTransfer.tsx`)
- [ ] **4.2** Exam endpoints (`src/utils/exams.ts`, `src/hooks/useExams.tsx`, `src/pages/generation.tsx`, all `ExamEditor` files, `ExamList.tsx`)
- [ ] **4.3** Evaluation endpoints (`src/utils/evaluation.ts`)
- [ ] **4.4** Storage endpoints (all `ExamEditor/SettingsEditor` files, `src/utils/evaluation.ts`)
- [ ] **4.5** Speaking audio (`src/components/Solutions/Speaking.tsx`, `InteractiveSpeaking.tsx`)
- [ ] **4.6** Sessions (`src/stores/exam/index.ts`, `src/components/Medium/SessionCard.tsx`)
- [ ] **4.7** Stats (`src/stores/exam/index.ts`, `src/pages/training/[id]/index.tsx`, `src/pages/statistical.tsx`)
- [ ] **4.8** Training (`src/pages/training/index.tsx`, `src/pages/training/[id]/index.tsx`)
- [ ] **4.9** Groups (`src/utils/groups.ts`, `src/pages/classrooms/create.tsx`, `src/pages/classrooms/[id].tsx`)
- [ ] **4.10** Entities (`src/pages/entities/create.tsx`, `src/pages/entities/[id]/index.tsx`)
- [ ] **4.11** Roles (`src/pages/entities/[id]/roles/index.tsx`, `[role].tsx`)
- [ ] **4.12** Permissions (`src/pages/permissions/[id].tsx`)
- [ ] **4.13** Payments (`src/pages/payment-record.tsx`, `src/components/PaymentAssetManager.tsx`, `src/components/PaymobPayment.tsx`)
- [ ] **4.14** Assignments (`src/pages/assignments/creator/index.tsx`, `[id].tsx`, `src/pages/assignments/[id].tsx`)
- [ ] **4.15** Invites (`src/components/Medium/InviteCard.tsx`, `InviteWithUserCard.tsx`)
- [ ] **4.16** Approval workflows (`src/pages/approval-workflows/*.tsx`)
- [ ] **4.17** Codes (`src/pages/register.tsx`)
- [ ] **4.18** Remaining: transcribe, batch_users, make_user, statistical
- [ ] **4.19** Update `WordUploader.tsx` `fetch()` call with base URL and auth header
### Phase 5: Cleanup
- [ ] **5.1** Delete all 108 files in `src/pages/api/`
- [ ] **5.2** Delete all 14 `src/utils/*.be.ts` files
- [ ] **5.3** Delete `src/lib/session.ts`, `src/lib/mongodb.ts`, `src/lib/createWorkflowsOnExamCreation.ts`
- [ ] **5.4** Delete `src/firebase/` directory (if Firebase Auth fully removed)
- [ ] **5.5** Delete `src/constants/platform.json`, `src/constants/staging.json`
- [ ] **5.6** Remove unused dependencies from `package.json`: `iron-session`, `firebase`, `firebase-admin`, `mongodb`, `bcrypt`, `formidable`
- [ ] **5.7** Run `yarn install` to update lockfile
- [ ] **5.8** Run TypeScript compiler (`yarn tsc --noEmit`) and fix any remaining type errors
- [ ] **5.9** Run linter (`yarn lint`) and fix issues
### Phase 6: Testing
- [ ] **6.1** Test login flow (email + password -> JWT stored -> user loaded)
- [ ] **6.2** Test registration flow (individual and corporate)
- [ ] **6.3** Test logout (token cleared, redirect to login)
- [ ] **6.4** Test SSR protection (unauthenticated access to protected page -> redirect)
- [ ] **6.5** Test exam taking flow (load exam, answer questions, submit, save stats)
- [ ] **6.6** Test writing/speaking grading (submit, poll, receive result)
- [ ] **6.7** Test entity/group/classroom management (CRUD operations)
- [ ] **6.8** Test assignment lifecycle (create, start, release, archive)
- [ ] **6.9** Test payment flows (Stripe, PayPal, Paymob)
- [ ] **6.10** Test file uploads (exam media, profile pictures)
- [ ] **6.11** Test training content generation
- [ ] **6.12** Test ticket creation (from platform and landing page)
- [ ] **6.13** Test admin user management (create, update, batch import)
- [ ] **6.14** Cross-browser test (Chrome, Firefox, Safari)

2337
docs/ODOO_MIGRATION_SRS.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

727
docs/UTAS_MASTER_PLAN.md Normal file
View File

@@ -0,0 +1,727 @@
# EnCoach -- UTAS University Deployment Master Plan
**Document Version:** 1.0
**Date:** March 16, 2026
**Classification:** Confidential -- EnCoach & UTAS Internal
**Prepared by:** EnCoach Engineering Team
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Project Scope and Objectives](#2-project-scope-and-objectives)
3. [System Architecture](#3-system-architecture)
4. [Feature Modules Breakdown](#4-feature-modules-breakdown)
5. [Development Phases](#5-development-phases)
6. [University Integration Plan](#6-university-integration-plan)
7. [Master Timeline](#7-master-timeline)
8. [Testing and Rollout Strategy](#8-testing-and-rollout-strategy)
9. [Team and Responsibilities](#9-team-and-responsibilities)
10. [Risk Register](#10-risk-register)
11. [Assumptions and Prerequisites](#11-assumptions-and-prerequisites)
---
## 1. Executive Summary
EnCoach is deploying a comprehensive AI-powered education platform for the University of Technology and Applied Sciences (UTAS). The platform delivers nine integrated modules spanning self-paced English learning, AI-graded examinations, Mathematics and IT courses with AI-assisted assessment, a full Learning Management System, university SIS integration, institutional whitelabeling, AI virtual tutors, and a support ticketing system.
### Platform at a Glance
| Dimension | Detail |
|-----------|--------|
| **Modules** | 9 (English Self Learning, Examinations, Math, IT, LMS, Whitelabeling, Humanisation, SIS Integration, Ticketing) |
| **AI Services** | 6 (OpenAI GPT-4o, Whisper STT, AWS Polly TTS, ELAI Avatars, GPTZero Detection, FAISS Semantic Search) |
| **Backend** | Odoo 19 -- 15 custom modules, 70+ REST API endpoints, PostgreSQL |
| **Frontend** | React 18 SPA -- 55+ pages, TypeScript, Tailwind CSS, shadcn/ui |
| **LMS Engine** | OpenEduCat (Odoo-native modules) |
| **Team** | 1 Solution Architect, 1 Senior Developer (AI-assisted), 1 Full-Stack Developer (AI-assisted) |
### Key Milestones
| Milestone | Target Date |
|-----------|-------------|
| Project Kickoff | April 1, 2026 |
| Staging Demo to UTAS | April 24, 2026 |
| English Self Learning + Examinations -- GO LIVE | June 12, 2026 |
| Math + IT + LMS + Ticketing -- GO LIVE | July 24, 2026 |
| Full Platform GO LIVE (SIS, Whitelabeling, Humanisation) | August 21, 2026 |
### Current State of Development
The platform is not starting from zero. Significant engineering has already been completed:
- **Backend (Odoo 19):** 15 custom modules fully developed, covering exams, AI grading, content generation, media services, training, assignments, classrooms, subscriptions, registration, and ticketing. All AI services (OpenAI, Whisper, Polly, ELAI, GPTZero, FAISS) are integrated and configured. The backend is deployed and running on a staging server.
- **Frontend (React):** A complete UI prototype with 55+ pages across student, teacher, and admin portals. Role-based routing, layouts, and forms are built. Currently using mock data -- API integration is the primary remaining work.
- **Infrastructure:** Docker Compose deployment with PostgreSQL, staging environment operational at the current staging server.
---
## 2. Project Scope and Objectives
### 2.1 Objectives
1. **Deliver a production-ready AI-powered education platform** for UTAS covering English, Mathematics, and IT subjects.
2. **Integrate with UTAS's existing Student Information System (SIS)** for seamless student enrollment, grade sync, and attendance tracking.
3. **Provide institutional whitelabeling** with UTAS branding, colors, and identity.
4. **Deploy AI virtual tutors** (Humanisation) to enhance the student learning experience across subjects.
5. **Establish a scalable LMS** built on OpenEduCat for course management, timetabling, and academic administration.
### 2.2 Module Scope
| # | Module | Description | Acceptance Criteria |
|---|--------|-------------|---------------------|
| 1 | **English Self Learning** | AI-powered IELTS preparation: Reading, Listening, Writing, Speaking practice with AI grading, personalized training tips, and progress tracking. | Students can complete full practice sessions in all 4 skills; AI grading returns scores within 60 seconds; training tips are personalized to weaknesses. |
| 2 | **Examinations** | Full exam lifecycle: AI-generated exam content, timed exam sessions, automated grading, results dashboard, and teacher approval workflows. | Teachers can generate, review, and assign exams; students complete timed exams; grading is automated with band scores and feedback. |
| 3 | **Mathematics** | Math courses and AI-assisted exams: course content delivery, formula-based question generation, automated grading for numerical and short-answer responses. | Students access Math courses; AI generates Math questions at configurable difficulty levels; automated grading handles numerical and short-answer formats. |
| 4 | **Information Technology** | IT courses and AI-assisted exams: course content delivery, conceptual and practical question generation, automated grading for MCQ and short-answer formats. | Students access IT courses; AI generates IT questions across topics; automated grading covers MCQ, true/false, and short answers. |
| 5 | **LMS (OpenEduCat)** | Course management, enrollment, timetabling, attendance tracking, batch management, gradebook, and academic reporting via OpenEduCat Odoo modules. | Admin manages courses, batches, and timetables; teachers record attendance and grades; students view schedules and progress. |
| 6 | **Whitelabeling** | UTAS institutional branding: customizable logo, color palette, fonts, login screen, email templates, and report headers. | Platform displays UTAS branding throughout; branding is configurable via admin settings without code changes. |
| 7 | **Humanisation** | AI-powered virtual tutors using ELAI avatar technology: video-based tutoring, interactive speaking practice, and subject-specific AI guidance. | AI avatars present content and interact with students across subjects; avatar selection is available; videos generate within 2 minutes. |
| 8 | **SIS Integration** | Bidirectional integration with UTAS's existing Student Information System: student enrollment sync, grade export, attendance sync, and course catalog mapping. | Student data syncs from SIS to EnCoach on enrollment; grades and attendance export back to SIS; data is consistent within 15-minute sync cycles. |
| 9 | **Ticketing** | Support ticket system for students and staff: ticket creation, categorization, assignment, status tracking, and resolution workflow. | Users submit tickets; staff can categorize, assign, and resolve; status updates are visible to the reporter. |
### 2.3 Out of Scope (This Engagement)
- Mobile native applications (iOS/Android)
- Content creation for Math and IT courses (content to be provided by UTAS or generated via AI)
- UTAS internal network infrastructure changes
- Marketing website / landing page redesign
- Payment gateway integration for UTAS (institutional licensing assumed)
---
## 3. System Architecture
### 3.1 Architecture Overview
```mermaid
graph TB
subgraph frontend ["Frontend Layer"]
ReactApp["React 18 SPA<br/>TypeScript + Tailwind + shadcn/ui<br/>55+ pages, role-based routing"]
end
subgraph backend ["Backend Layer -- Odoo 19"]
API["REST API Controllers<br/>70+ JSON endpoints"]
Core["Core Modules<br/>Users, Entities, Roles, Permissions"]
Exam["Exam Engine<br/>Reading, Listening, Writing, Speaking, Level"]
LMS["OpenEduCat LMS<br/>Courses, Batches, Timetable, Attendance"]
AI["AI Modules<br/>Generation, Grading, Media, Training"]
Sub["Business Modules<br/>Subscriptions, Tickets, Registration"]
end
subgraph aiServices ["AI Services"]
GPT["OpenAI GPT-4o<br/>Content Generation + Grading"]
Whisper["OpenAI Whisper<br/>Speech-to-Text (local)"]
Polly["AWS Polly<br/>Text-to-Speech (neural)"]
ELAI["ELAI<br/>AI Avatar Video"]
GPTZero["GPTZero<br/>AI Writing Detection"]
FAISS["FAISS + SentenceTransformers<br/>Semantic Search"]
end
subgraph dataLayer ["Data Layer"]
PG["PostgreSQL 16"]
end
subgraph external ["External Systems"]
SIS["UTAS SIS<br/>Student Information System"]
end
ReactApp -->|"JWT Auth + REST API"| API
API --> Core
API --> Exam
API --> LMS
API --> AI
API --> Sub
AI --> GPT
AI --> Whisper
AI --> Polly
AI --> ELAI
AI --> GPTZero
AI --> FAISS
Core --> PG
Exam --> PG
LMS --> PG
Sub --> PG
API <-->|"REST API Sync"| SIS
```
### 3.2 Technology Stack
| Layer | Technology | Details |
|-------|-----------|---------|
| **Frontend** | React 18, TypeScript, Vite 5 | SPA with TanStack Query, React Router v6, Zod validation |
| **UI Framework** | shadcn/ui, Tailwind CSS | Radix UI primitives, responsive design, dark mode capable |
| **Backend** | Odoo 19, Python 3.11 | 15 custom modules, ORM-based data access, background job processing |
| **Database** | PostgreSQL 16 | Odoo-managed schema, full-text search, JSONB for flexible data |
| **LMS** | OpenEduCat | Odoo-native education modules: courses, batches, attendance, timetable |
| **AI -- Text Generation** | OpenAI GPT-4o, GPT-3.5-turbo | Exam content generation, grading, evaluation, training tips |
| **AI -- Speech-to-Text** | OpenAI Whisper (local, base model) | 4 instances, round-robin, 30s chunked transcription |
| **AI -- Text-to-Speech** | AWS Polly (Neural) | 11 English voices, MP3 output, sentence-boundary chunking |
| **AI -- Video** | ELAI | 7 AI avatars (male/female), ElevenLabs + Azure voices |
| **AI -- Detection** | GPTZero | AI-generated writing detection per submission |
| **AI -- Search** | FAISS + all-MiniLM-L6-v2 | Semantic similarity search over training tips knowledge base |
| **Deployment** | Docker Compose | Odoo + PostgreSQL containers, Nginx reverse proxy |
| **Version Control** | Gitea | Private repositories for frontend and backend |
### 3.3 Deployment Architecture
```mermaid
graph LR
subgraph production ["Production Server"]
Nginx["Nginx<br/>Reverse Proxy<br/>SSL Termination"]
FrontendContainer["Frontend Container<br/>React SPA<br/>Port 3000"]
OdooContainer["Odoo 19 Container<br/>Python 3.11<br/>Port 8069"]
DBContainer["PostgreSQL 16<br/>Port 5432"]
end
Browser["Browser"] -->|"HTTPS"| Nginx
Nginx -->|"/app/*"| FrontendContainer
Nginx -->|"/api/*"| OdooContainer
OdooContainer --> DBContainer
FrontendContainer -->|"REST API"| OdooContainer
```
### 3.4 Existing Assets Inventory
| Asset | Count | Status |
|-------|-------|--------|
| Odoo custom modules | 15 | Deployed on staging |
| Backend REST API endpoints | 70+ | Functional, English/IELTS focused |
| Frontend pages (React) | 55+ | UI complete, mock data only |
| AI service integrations | 6 | Configured and tested |
| ELAI avatar profiles | 7 | Seeded in database |
| Training tips (FAISS) | 80 | Imported with embeddings |
| Odoo data models | 20+ | Schema deployed |
---
## 4. Feature Modules Breakdown
### 4.1 English Self Learning
| Aspect | Detail |
|--------|--------|
| **Current State** | Backend complete: AI-powered generation for Reading, Listening, Writing, Speaking. Grading via GPT-4o with IELTS rubric. Polly TTS for listening audio. ELAI avatars for speaking. FAISS-based personalized training tips. Frontend UI built (mocked). |
| **Work Required** | Replace mock authentication with Odoo JWT. Wire all student-facing pages to backend APIs. Integrate AI grading display (scores, feedback, AI detection). Connect training/tips module. |
| **Dependencies** | Stable Odoo API (Phase 1 complete). Frontend auth working. |
| **Effort** | 6 person-weeks |
| **Risk** | Low -- both backend and frontend exist; integration is the main task. |
### 4.2 Examinations
| Aspect | Detail |
|--------|--------|
| **Current State** | Backend complete: exam CRUD, 5 module types (reading, listening, writing, speaking, level), AI generation endpoints, approval workflows. Frontend: exam editor, exam list, generation page, approval workflows (all mocked). |
| **Work Required** | Wire admin exam management pages to APIs. Connect AI generation UI to real endpoints. Implement exam-taking flow with timer and state persistence. Connect grading pipeline and results display. |
| **Dependencies** | English Self Learning module (shared components). |
| **Effort** | Included in English Self Learning estimate (shared codebase). |
| **Risk** | Low -- core exam engine is proven. |
### 4.3 Mathematics
| Aspect | Detail |
|--------|--------|
| **Current State** | No backend module exists. No frontend pages exist. The AI exam engine (GPT-based generation + grading) provides a reusable foundation. |
| **Work Required** | **Backend:** Create `encoach_math` Odoo module with Math-specific exam models, AI prompt templates for formula-based questions, numerical grading logic, and multi-format question types (MCQ, fill-in, short answer, calculation). **Frontend:** Create Math course pages, Math exam taking interface with formula rendering (KaTeX/MathJax), Math-specific grading display. |
| **Dependencies** | Core exam engine must be stable. AI prompts require testing with GPT-4o for mathematical accuracy. |
| **Effort** | 5 person-weeks |
| **Risk** | Medium -- AI generation of math questions requires careful prompt engineering for accuracy. Formula rendering on frontend adds complexity. |
### 4.4 Information Technology
| Aspect | Detail |
|--------|--------|
| **Current State** | No backend module exists. No frontend pages exist. Similar to Math, the exam engine provides a foundation. |
| **Work Required** | **Backend:** Create `encoach_it` Odoo module with IT-specific exam models, AI prompt templates for conceptual and practical IT questions (networking, databases, programming concepts, security), grading logic for MCQ and short-answer. **Frontend:** Create IT course pages, IT exam interface, IT-specific grading display. |
| **Dependencies** | Core exam engine must be stable. |
| **Effort** | 4 person-weeks |
| **Risk** | Low-Medium -- IT subjects are text-based (no formula complexity). AI generation of IT questions is straightforward with GPT-4o. |
### 4.5 LMS (OpenEduCat)
| Aspect | Detail |
|--------|--------|
| **Current State** | OpenEduCat modules will be provided and installed into Odoo 19. Frontend LMS pages exist and are fully built: student dashboard, courses, course detail, assignments, grades, attendance, timetable (student); courses, course builder, assignments, attendance, students, timetable (teacher); dashboard, courses, students, teachers, batches, batch detail, timetable, reports, settings (admin). All pages use mock data. |
| **Work Required** | **Backend:** Install OpenEduCat modules. Create REST API controller layer (`encoach_lms_api`) to expose OpenEduCat models as JSON endpoints matching the frontend's data expectations. Map OpenEduCat models to frontend data interfaces (Course, Module, Lesson, Batch, Timetable, Attendance, Grade). **Frontend:** Replace mock data imports with API calls using TanStack Query. Wire forms to create/update APIs. |
| **Dependencies** | OpenEduCat modules provided and compatible with Odoo 19. |
| **Effort** | 3 person-weeks |
| **Risk** | Medium -- OpenEduCat model compatibility with Odoo 19 must be verified. API mapping between OpenEduCat's data structures and the frontend's interfaces requires careful alignment. |
### 4.6 Whitelabeling
| Aspect | Detail |
|--------|--------|
| **Current State** | No whitelabeling support. Frontend uses CSS variables for theming (Tailwind + shadcn/ui) with `darkMode: ["class"]` configured but not activated. No branding configuration. |
| **Work Required** | **Backend:** Create `encoach_branding` model in Odoo for tenant branding settings (logo, primary color, secondary color, font, favicon, login background, email header). Expose branding API endpoint. **Frontend:** Create a branding provider that fetches configuration on app load and applies CSS variables dynamically. Configure logo, colors, and fonts from API. Add admin UI for branding management. |
| **Dependencies** | UTAS provides brand assets (logo, color palette, fonts). |
| **Effort** | 2 person-weeks |
| **Risk** | Low -- CSS variable-based theming is well-established. |
### 4.7 Humanisation (AI Virtual Tutors)
| Aspect | Detail |
|--------|--------|
| **Current State** | ELAI integration exists for speaking exam avatar videos. 7 avatars configured (Gia, Vadim, Orhan, Flora, Scarlett, Parker, Ethan) with ElevenLabs and Azure voices. Currently limited to presenting speaking exam questions. |
| **Work Required** | **Backend:** Extend ELAI integration to support tutoring mode -- avatars explain concepts, provide feedback narration, and guide students through exercises. Create endpoints for on-demand tutoring video generation per subject. **Frontend:** Build tutor interaction UI -- avatar selector, video player with synchronized content, interactive Q&A flow. Integrate across English, Math, and IT modules. |
| **Dependencies** | ELAI API capacity and cost model for increased video generation. Subject-specific scripts/prompts. |
| **Effort** | 3 person-weeks |
| **Risk** | Medium -- ELAI video generation time (1-3 minutes per video) affects real-time tutoring UX. Cost scales with usage. |
### 4.8 SIS Integration
| Aspect | Detail |
|--------|--------|
| **Current State** | No SIS integration exists. UTAS has an existing Student Information System that we must integrate with via API. |
| **Work Required** | **Discovery:** Obtain UTAS SIS API documentation, authentication method, and data schemas. **Backend:** Create `encoach_sis` Odoo module with SIS sync models, scheduled sync jobs (cron), and conflict resolution. Implement: student enrollment import, grade export, attendance sync, course catalog mapping. **Frontend:** SIS sync status dashboard for admin. Sync logs and error reporting. |
| **Dependencies** | UTAS provides SIS API documentation, test environment access, and a technical contact. |
| **Effort** | 4 person-weeks |
| **Risk** | High -- entirely dependent on UTAS SIS API quality, documentation completeness, and test environment availability. This is the single highest-risk module. |
### 4.9 Ticketing
| Aspect | Detail |
|--------|--------|
| **Current State** | Backend complete: `encoach_ticket` Odoo module with full CRUD, categorization, and status workflow. API endpoints exist (`/api/tickets`). Frontend: Tickets page exists in admin portal (mocked). |
| **Work Required** | Wire frontend tickets page to backend API. Add ticket creation flow for students and teachers. Add ticket assignment and resolution workflow for admin. |
| **Dependencies** | None -- self-contained module. |
| **Effort** | 1 person-week |
| **Risk** | Low -- both backend and frontend exist. |
### 4.10 Effort Summary
| Module | Person-Weeks | Wave |
|--------|-------------|------|
| English Self Learning + Examinations | 6 | Wave 1 |
| Mathematics | 5 | Wave 2 |
| Information Technology | 4 | Wave 2 |
| LMS (OpenEduCat) | 3 | Wave 2 |
| Ticketing | 1 | Wave 2 |
| Whitelabeling | 2 | Wave 3 |
| Humanisation | 3 | Wave 3 |
| SIS Integration | 4 | Wave 3 |
| **Total** | **28 person-weeks** | |
With a team of 3 producing approximately 3 person-weeks per calendar week, the raw work is approximately 9-10 calendar weeks. Adding UAT cycles, amendments, and buffer, the overall calendar duration is 20 weeks (April 1 -- August 21).
---
## 5. Development Phases
The project is structured into three overlapping waves. Each wave follows a build-test-deploy cycle and produces a production-ready deliverable.
### 5.1 Wave 1 -- Foundation (April 1 -- June 12)
**Goal:** English Self Learning and Examinations modules live in production with real UTAS users.
This wave transforms the existing backend and frontend from separate prototypes into an integrated, production-ready platform.
#### Sprint Breakdown
| Sprint | Dates | Focus | Deliverables |
|--------|-------|-------|-------------|
| **S1** | Apr 1-11 | **Stabilization & Auth** | Fix remaining backend bugs. Replace mock auth with Odoo JWT in React frontend. Set up API service layer. Configure production deployment pipeline. |
| **S2** | Apr 12-18 | **Core API Integration** | Wire student dashboard, courses, and profile pages. Implement exam list and exam detail APIs. Connect user management and entity management. |
| **S3** | Apr 19-25 | **Exam Engine Integration** | Wire exam-taking flow (reading, listening, writing, speaking). Connect AI generation endpoints to exam editor. Integrate timer, state persistence, and submission. **Staging demo to UTAS (Apr 24-25).** |
| **S4** | Apr 26 -- May 9 | **AI Features & Grading** | Wire AI grading pipeline (writing evaluation, speaking evaluation). Connect TTS audio playback. Connect ELAI avatar videos. Integrate training tips and personalized recommendations. Connect AI detection display. |
| **S5** | May 10-23 | **UAT Round 1** | Deploy to UTAS staging. Conduct UAT with UTAS staff and selected students. Collect feedback. Bug fixes and amendments. |
| **S6** | May 24 -- Jun 6 | **UAT Round 2 & Hardening** | Address UAT feedback. Performance optimization. Security review. Documentation. |
| **S7** | Jun 7-12 | **GO LIVE** | Production deployment. Monitoring setup. Handover to UTAS for English + Examinations. Post-launch support begins. |
#### Wave 1 Exit Criteria
- Students can register, log in, and complete full English practice sessions
- AI grading returns accurate band scores with detailed feedback for writing and speaking
- Teachers can generate, review, approve, and assign exams
- Admin can manage users, entities, classrooms, and assignments
- Training tips are personalized based on exam performance
- Platform is stable under expected UTAS load
### 5.2 Wave 2 -- Expansion (May 17 -- July 24)
**Goal:** Mathematics, Information Technology, LMS, and Ticketing modules live in production.
Wave 2 begins during Wave 1's UAT phase, leveraging developers freed from active feature work.
#### Sprint Breakdown
| Sprint | Dates | Focus | Deliverables |
|--------|-------|-------|-------------|
| **S8** | May 17-30 | **Math & IT Backend** | Create `encoach_math` and `encoach_it` Odoo modules. Develop AI prompt templates for Math question generation (numerical, formula-based, word problems). Develop AI prompt templates for IT questions (conceptual, practical). Implement grading logic for non-essay formats. |
| **S9** | May 31 -- Jun 13 | **OpenEduCat & LMS API** | Install and configure OpenEduCat modules in Odoo 19. Create `encoach_lms_api` controller layer. Map OpenEduCat models to frontend data interfaces. Wire LMS admin pages (courses, batches, timetable, attendance). |
| **S10** | Jun 14-27 | **Math & IT Frontend + LMS Wiring** | Build Math exam interface with formula rendering. Build IT exam interface. Wire student and teacher LMS pages. Wire ticketing module. |
| **S11** | Jun 28 -- Jul 11 | **UAT Round 1** | Deploy Math, IT, LMS, Ticketing to staging. UAT with UTAS staff. Math AI accuracy testing. LMS workflow validation. |
| **S12** | Jul 12-18 | **Amendments** | Address UAT feedback. Fix Math/IT grading edge cases. LMS refinements. |
| **S13** | Jul 19-24 | **GO LIVE** | Production deployment of Math, IT, LMS, Ticketing. Post-launch support begins. |
#### Wave 2 Exit Criteria
- Math and IT exams generate accurately via AI with appropriate difficulty levels
- LMS (OpenEduCat) fully functional: courses, enrollment, attendance, timetable, gradebook
- Ticketing system operational for students and staff
- All modules integrated with Wave 1 platform (single sign-on, unified navigation)
### 5.3 Wave 3 -- Integration (July 1 -- August 21)
**Goal:** Whitelabeling, SIS integration, and Humanisation modules complete. Full platform go-live.
Wave 3 begins during Wave 2's frontend phase, with SIS discovery starting earlier.
#### Sprint Breakdown
| Sprint | Dates | Focus | Deliverables |
|--------|-------|-------|-------------|
| **S14** | Jul 1-11 | **Whitelabeling + SIS Discovery** | Implement branding configuration backend and frontend. Apply UTAS brand assets. Begin SIS API discovery and documentation review with UTAS IT team. |
| **S15** | Jul 12-25 | **SIS Integration Development** | Build `encoach_sis` Odoo module. Implement student enrollment import. Implement grade export. Implement attendance sync. Build admin SIS dashboard. |
| **S16** | Jul 26 -- Aug 1 | **Humanisation** | Extend ELAI integration for tutoring mode. Build tutor interaction UI. Generate subject-specific avatar tutoring content. |
| **S17** | Aug 2-8 | **Integration Testing** | End-to-end testing of all 9 modules together. SIS sync validation with UTAS test data. Performance testing under load. Security audit. |
| **S18** | Aug 9-15 | **Final UAT** | Full platform UAT with UTAS. All modules tested together. Final amendments. |
| **S19** | Aug 16-21 | **Full Platform GO LIVE** | Production deployment of complete platform. All 9 modules active. Monitoring and support in place. |
#### Wave 3 Exit Criteria
- Platform displays UTAS branding throughout
- SIS syncs student enrollment, grades, and attendance bidirectionally
- AI virtual tutors available across English, Math, and IT
- All 9 modules function as an integrated platform
- Performance meets production requirements under expected load
---
## 6. University Integration Plan
### 6.1 Integration Architecture
```mermaid
sequenceDiagram
participant SIS as UTAS SIS
participant Odoo as EnCoach Odoo 19
participant React as EnCoach Frontend
participant Student as Student
Note over SIS,Odoo: Enrollment Sync (SIS → EnCoach)
SIS->>Odoo: Student enrollment data (scheduled sync)
Odoo->>Odoo: Create/update student records
Odoo->>Odoo: Assign to courses and batches
Note over Student,React: Daily Usage
Student->>React: Login, take courses, complete exams
React->>Odoo: API calls (exam, grading, attendance)
Odoo->>Odoo: Record grades, attendance, progress
Note over Odoo,SIS: Grade & Attendance Export (EnCoach → SIS)
Odoo->>SIS: Grade export (scheduled sync)
Odoo->>SIS: Attendance export (scheduled sync)
```
### 6.2 Data Exchange Specification
| Data Flow | Direction | Frequency | Format |
|-----------|-----------|-----------|--------|
| Student enrollment | SIS → EnCoach | On enrollment + daily sync | REST API / CSV import |
| Course catalog mapping | Bidirectional | On setup + manual sync | REST API |
| Exam grades | EnCoach → SIS | After grading + daily batch | REST API |
| Attendance records | EnCoach → SIS | End of day batch | REST API |
| Student status changes | SIS → EnCoach | Real-time webhook or daily | REST API |
### 6.3 Authentication Strategy
| Phase | Method | Detail |
|-------|--------|--------|
| **Initial (Wave 1-2)** | Odoo-native JWT | Students and staff authenticate directly with EnCoach. Credentials provisioned from SIS enrollment data. |
| **Full Integration (Wave 3)** | SSO via SAML/OAuth | If UTAS SIS supports SSO, students authenticate once at UTAS portal and access EnCoach seamlessly. Fallback to JWT if SSO is not available. |
### 6.4 UTAS Responsibilities for Integration
| Deliverable | Required By | Impact if Delayed |
|-------------|-------------|-------------------|
| SIS API documentation | June 15, 2026 | Blocks SIS module development; delays Wave 3 by equivalent duration |
| SIS test environment access | July 1, 2026 | Blocks integration testing |
| SIS technical contact (dedicated) | June 15, 2026 | Slows discovery and issue resolution |
| Brand assets (logo, colors, fonts) | June 15, 2026 | Blocks whitelabeling completion |
| UAT testers (3-5 staff, 10-20 students) | May 1, 2026 | Delays UAT cycles; risks quality |
| Math and IT course content (if not AI-generated) | May 15, 2026 | Delays Math/IT module population |
| Production server provisioning | August 1, 2026 | Blocks production deployment |
---
## 7. Master Timeline
### 7.1 Gantt Chart
```mermaid
gantt
title EnCoach UTAS Deployment -- Master Timeline
dateFormat YYYY-MM-DD
axisFormat %d %b
section Wave1_Foundation
S1_Stabilize_Auth :s1, 2026-04-01, 11d
S2_Core_API_Integration :s2, 2026-04-12, 7d
S3_Exam_Engine_Integration :s3, 2026-04-19, 7d
UTAS_Staging_Demo :milestone, m1, 2026-04-24, 0d
S4_AI_Features_Grading :s4, 2026-04-26, 14d
S5_UAT_Round_1 :s5, 2026-05-10, 14d
S6_UAT_Round_2_Hardening :s6, 2026-05-24, 14d
S7_English_Exams_GO_LIVE :s7, 2026-06-07, 5d
English_Exams_LIVE :milestone, m2, 2026-06-12, 0d
section Wave2_Expansion
S8_Math_IT_Backend :s8, 2026-05-17, 14d
S9_OpenEduCat_LMS_API :s9, 2026-05-31, 14d
S10_Math_IT_LMS_Frontend :s10, 2026-06-14, 14d
S11_UAT_Math_IT_LMS :s11, 2026-06-28, 14d
S12_Amendments :s12, 2026-07-12, 7d
S13_Math_IT_LMS_GO_LIVE :s13, 2026-07-19, 5d
Math_IT_LMS_LIVE :milestone, m3, 2026-07-24, 0d
section Wave3_Integration
S14_Whitelabeling_SIS_Discovery :s14, 2026-07-01, 11d
S15_SIS_Integration :s15, 2026-07-12, 14d
S16_Humanisation :s16, 2026-07-26, 7d
S17_Integration_Testing :s17, 2026-08-02, 7d
S18_Final_UAT :s18, 2026-08-09, 7d
S19_Full_Platform_GO_LIVE :s19, 2026-08-16, 5d
Full_Platform_LIVE :milestone, m4, 2026-08-21, 0d
section PostLaunch
Post_Launch_Support :pls, 2026-08-21, 30d
```
### 7.2 Key Milestones
| # | Milestone | Date | Gate Criteria |
|---|-----------|------|---------------|
| M0 | Project Kickoff | April 1, 2026 | Team assembled, scope agreed, environments ready |
| M1 | UTAS Staging Demo | April 24, 2026 | English + Exams functional on staging, basic end-to-end flow |
| M2 | Wave 1 GO LIVE | June 12, 2026 | English Self Learning + Examinations in production, 2 UAT rounds passed |
| M3 | Wave 2 GO LIVE | July 24, 2026 | Math + IT + LMS + Ticketing in production, UAT passed |
| M4 | Full Platform GO LIVE | August 21, 2026 | All 9 modules in production, SIS integration validated, full UAT passed |
| M5 | Post-Launch Support Ends | September 21, 2026 | 30-day support period complete, handover documentation delivered |
### 7.3 Critical Path
The critical path runs through:
1. **Wave 1 auth stabilization** (blocks all frontend work)
2. **Wave 1 API integration** (blocks UAT and all subsequent waves)
3. **UTAS SIS API documentation delivery** (blocks Wave 3 SIS development)
4. **OpenEduCat module compatibility** (blocks LMS development)
Any delay on items 1-2 cascades through the entire timeline. Items 3-4 are external dependencies that must be tracked proactively.
---
## 8. Testing and Rollout Strategy
### 8.1 Testing Stages
```mermaid
graph LR
Dev["Developer Testing<br/>Unit + Integration<br/>Continuous"] --> Staging["Staging<br/>System Integration<br/>Per Sprint"]
Staging --> UAT["UAT with UTAS<br/>Acceptance<br/>Per Wave"]
UAT --> Perf["Performance Testing<br/>Load + Stress<br/>Pre-Go-Live"]
Perf --> Security["Security Audit<br/>OWASP Top 10<br/>Pre-Go-Live"]
Security --> GoLive["GO LIVE<br/>Production<br/>Per Wave"]
```
| Stage | Who | When | Scope |
|-------|-----|------|-------|
| **Developer Testing** | EnCoach dev team | Continuous | Unit tests, API integration tests, manual smoke tests per feature |
| **Staging Deployment** | EnCoach dev team | End of each sprint | Full platform on staging server, regression testing, cross-module integration |
| **UAT (User Acceptance)** | UTAS staff + students | 2 rounds per wave | Guided test scenarios, real-world usage, feedback collection |
| **Performance Testing** | EnCoach dev team | Before each go-live | Concurrent user simulation (target: 200 simultaneous users), API response times (<2s for standard, <60s for AI grading) |
| **Security Audit** | EnCoach architect | Before production go-live | Authentication review, authorization checks, API input validation, OWASP Top 10 |
### 8.2 UAT Process
Each wave includes two UAT rounds:
**UAT Round 1 (2 weeks):**
1. EnCoach deploys to staging environment
2. UTAS UAT team receives test accounts and test scenarios document
3. UTAS testers execute scenarios and log issues via shared tracking system
4. EnCoach triages issues daily (critical = same day, high = 2 days, medium = next sprint)
5. Weekly status call between EnCoach and UTAS
**UAT Round 2 (2 weeks):**
1. All critical and high issues from Round 1 resolved
2. Re-test of failed scenarios
3. Exploratory testing by UTAS testers
4. Go-live readiness assessment
5. Sign-off by UTAS project lead
### 8.3 Go-Live Checklist (Per Wave)
| # | Item | Owner |
|---|------|-------|
| 1 | All critical and high UAT issues resolved | EnCoach |
| 2 | Performance tests pass (response time, concurrent users) | EnCoach |
| 3 | Security audit complete, no critical findings | EnCoach |
| 4 | Production environment provisioned and configured | EnCoach + UTAS IT |
| 5 | DNS / domain configured (if applicable) | UTAS IT |
| 6 | SSL certificate installed | EnCoach + UTAS IT |
| 7 | Database backup and restore procedure tested | EnCoach |
| 8 | Monitoring and alerting configured | EnCoach |
| 9 | Rollback procedure documented and tested | EnCoach |
| 10 | UTAS project lead sign-off | UTAS |
### 8.4 Rollback Plan
Each go-live deployment includes a documented rollback procedure:
1. **Database snapshot** taken immediately before production deployment
2. **Previous container images** tagged and retained (minimum 3 previous versions)
3. **Rollback trigger criteria:** >5% error rate, >10s average response time, data integrity issue
4. **Rollback execution time:** <15 minutes (Docker image swap + database restore)
5. **Communication:** UTAS IT notified within 5 minutes of rollback decision
### 8.5 Post-Launch Support
| Period | Coverage | Scope |
|--------|----------|-------|
| Week 1-2 after each go-live | Daily monitoring, same-day critical fixes | Bug fixes, performance tuning, user support |
| Week 3-4 after each go-live | Standard monitoring, 2-day response for non-critical | Bug fixes, minor enhancements from feedback |
| After Full Platform GO LIVE | 30-day support period (Aug 21 -- Sep 21) | Full platform support, knowledge transfer to UTAS IT |
---
## 9. Team and Responsibilities
### 9.1 EnCoach Team
| Role | Responsibilities |
|------|-----------------|
| **Solution Architect** | System architecture decisions. University integration design. SIS API mapping. Security and performance oversight. Code review. UTAS stakeholder communication. Sprint planning. |
| **Senior Developer (AI-assisted)** | Backend development (Odoo modules). AI service integration. Math/IT module AI prompts. SIS integration implementation. Database design. API development. |
| **Full-Stack Developer (AI-assisted)** | Frontend development (React). API integration. UI/UX implementation. Whitelabeling. LMS frontend wiring. Testing. |
### 9.2 UTAS Responsibilities
| Role | Responsibilities |
|------|-----------------|
| **Project Sponsor** | Budget approval. Strategic decisions. Escalation resolution. |
| **Project Lead** | Scope sign-off. UAT coordination. Go-live approval. Weekly status reviews. |
| **IT Department** | SIS API documentation and test environment. Production server provisioning. Network and DNS configuration. SSL certificates. |
| **Subject Matter Experts** | Math and IT course content review. Exam quality validation. Training content verification. |
| **UAT Testers (3-5 staff, 10-20 students)** | Execute test scenarios. Log issues and feedback. Re-test after fixes. |
### 9.3 Communication Plan
| Activity | Frequency | Participants | Medium |
|----------|-----------|-------------|--------|
| Sprint status report | Weekly (Friday) | EnCoach team → UTAS Project Lead | Email + document |
| Demo / walkthrough | Bi-weekly | EnCoach team + UTAS stakeholders | Video call + screen share |
| UAT feedback review | Daily during UAT | EnCoach team + UTAS testers | Shared issue tracker |
| Escalation call | As needed | Architect + UTAS Project Lead | Video call |
| Go-live readiness review | Before each go-live | All stakeholders | Meeting |
### 9.4 Escalation Path
```
Level 1: Developer → Solution Architect (same day)
Level 2: Solution Architect → UTAS Project Lead (1 business day)
Level 3: UTAS Project Lead → UTAS Project Sponsor (2 business days)
```
---
## 10. Risk Register
| # | Risk | Probability | Impact | Mitigation |
|---|------|------------|--------|------------|
| R1 | **UTAS SIS API documentation delayed or incomplete** | High | High | Begin SIS discovery in Wave 2 (early July). Prepare alternative sync methods (CSV import/export) as fallback. Weekly check-ins with UTAS IT from June onwards. |
| R2 | **Math AI question generation inaccuracy** | Medium | Medium | Invest in thorough prompt engineering with GPT-4o. Build validation test suite with known-correct Math problems. Include human review step in exam approval workflow. |
| R3 | **OpenEduCat incompatibility with Odoo 19** | Medium | High | Test OpenEduCat installation early (Sprint S9). Identify compatibility issues before committing to LMS frontend work. Fallback: build minimal LMS models within existing Odoo modules. |
| R4 | **Frontend API integration slower than estimated** | Medium | Medium | Prioritize critical paths (auth, core pages) first. Use API contract testing to parallelize frontend and backend work. Leverage AI coding assistants to accelerate repetitive wiring. |
| R5 | **Team bandwidth constraints** | Medium | High | AI-assisted development (both developers use AI tools) effectively increases output by 1.5-2x. Strict scope management -- no feature creep. Clear sprint goals with daily standups. |
| R6 | **ELAI video generation latency for Humanisation** | Low | Medium | Pre-generate common tutoring videos. Cache frequently requested content. Set user expectations (1-3 min generation time). Consider alternative: pre-recorded avatar videos for standard content. |
| R7 | **Production infrastructure not ready on time** | Low | High | Request production server provisioning by August 1 (3 weeks before go-live). Test deployment procedure on staging first. Have cloud-based fallback plan. |
| R8 | **UTAS UAT testers unavailable or insufficient feedback** | Medium | Medium | Provide detailed test scenarios with step-by-step instructions. Offer guided UAT sessions. Establish minimum UAT participation requirements in the scope agreement. |
---
## 11. Assumptions and Prerequisites
### 11.1 Assumptions
| # | Assumption | Impact if Invalid |
|---|-----------|-------------------|
| A1 | UTAS provides 3-5 staff and 10-20 students for UAT testing | Delays UAT; reduces quality assurance |
| A2 | OpenEduCat modules are compatible with Odoo 19 | Requires building custom LMS models; adds 3-4 weeks |
| A3 | UTAS SIS exposes REST APIs for enrollment, grades, and attendance | Requires alternative integration (CSV, database sync); adds 2-3 weeks |
| A4 | Math and IT course content is either provided by UTAS or generated by AI | If neither, course content creation becomes a separate workstream |
| A5 | The platform will be accessed via web browsers only (no mobile app) | Mobile app would be a separate project |
| A6 | Institutional licensing model (no per-student payment processing) | Payment gateway integration would add 2-3 weeks |
| A7 | Single UTAS deployment (not multi-university / multi-tenant initially) | Multi-tenant support would add architectural complexity |
| A8 | Internet connectivity at UTAS is sufficient for AI cloud services (OpenAI, ELAI, AWS Polly) | On-premise AI deployment would be a separate project |
### 11.2 Prerequisites (with Deadlines)
| # | Prerequisite | Owner | Deadline | Status |
|---|-------------|-------|----------|--------|
| P1 | Scope agreement signed | UTAS + EnCoach | March 31, 2026 | Pending |
| P2 | OpenEduCat modules provided | EnCoach / UTAS | May 15, 2026 | Pending |
| P3 | UTAS brand assets delivered | UTAS | June 15, 2026 | Pending |
| P4 | SIS API documentation provided | UTAS IT | June 15, 2026 | Pending |
| P5 | SIS test environment access granted | UTAS IT | July 1, 2026 | Pending |
| P6 | UAT test team identified | UTAS | May 1, 2026 | Pending |
| P7 | Production server provisioned | UTAS IT / EnCoach | August 1, 2026 | Pending |
| P8 | Math/IT course content available | UTAS SMEs | May 15, 2026 | Pending |
---
## Appendix A: Module-to-API Mapping (Existing Backend Endpoints)
The following 70+ API endpoints are already built and deployed in the Odoo 19 backend. These form the foundation for Wave 1 frontend integration.
| Category | Endpoints | Count |
|----------|-----------|-------|
| Authentication | `/api/login`, `/api/logout`, `/api/reset`, `/api/reset/sendVerification` | 4 |
| Users | `/api/user`, `/api/users/update`, `/api/users/list`, `/api/users/<id>` | 4 |
| Entities & Roles | `/api/entities`, `/api/roles`, `/api/permissions` | 7 |
| Exams | `/api/exam`, `/api/exam/<module>`, `/api/exam/<module>/<id>`, `/api/exam/avatars` | 7 |
| AI Generation | `/api/exam/reading/*`, `/api/exam/listening/*`, `/api/exam/writing/*`, `/api/exam/speaking/*`, `/api/exam/level/generate` | 8 |
| Media | `/api/exam/listening/media`, `/api/exam/listening/transcribe`, `/api/exam/speaking/media`, `/api/transcribe` | 6 |
| Evaluations & Grading | `/api/evaluate/writing`, `/api/evaluate/speaking`, `/api/evaluate/interactiveSpeaking`, `/api/grading/multiple` | 6 |
| Classrooms | `/api/groups`, `/api/groups/<id>` | 3 |
| Assignments | `/api/assignments`, `/api/assignments/<id>`, `/api/assignments/<id>/start`, release, archive | 8 |
| Sessions & Stats | `/api/sessions`, `/api/stats`, `/api/statistical` | 5 |
| Training | `/api/training`, `/api/training/<id>`, `/api/training/tips`, `/api/training/walkthrough` | 4 |
| Subscriptions | `/api/packages`, `/api/stripe`, `/api/paypal`, `/api/paymob` + webhooks | 7 |
| Registration | `/api/register`, `/api/code/<code>`, `/api/batch_users`, `/api/make_user` | 4 |
| Tickets | `/api/tickets`, `/api/tickets/<id>` | 3 |
| Storage | `/api/storage`, `/api/storage/delete` | 2 |
| Approvals | `/api/approval-workflows`, `/api/approval-workflows/<id>` | 3 |
| **Total** | | **~81** |
---
## Appendix B: AI Service Configuration Summary
| Service | Model | Key Config | Usage |
|---------|-------|-----------|-------|
| OpenAI GPT | gpt-4o (primary), gpt-3.5-turbo (secondary) | Temperature: 0.1 (grading), 0.7 (generation), 0.2 (summaries). Max tokens: 4,097. JSON response format. | Exam generation, grading, evaluation, training tips |
| Whisper | base (local, ~1GB) | 4 instances, round-robin, 16kHz mono, 30s chunks with overlap | Speaking transcription, audio-to-text |
| AWS Polly | Neural engine | 11 voices, MP3 output, 3,000 char chunks | Listening exam audio generation |
| ELAI | 7 avatars | ElevenLabs + Azure voices, fade_in animation | Speaking exam video, virtual tutoring |
| GPTZero | v2/predict/text | Per-sentence analysis, confidence scoring | Writing AI detection |
| FAISS | IndexFlatL2 + all-MiniLM-L6-v2 | Top-5 results, 7 category indices, 80 training tips | Personalized training recommendations |
---
## Appendix C: Frontend Page Inventory (New React Application)
| Portal | Pages | Key Features |
|--------|-------|-------------|
| **Student** | Dashboard, Courses, Course Detail, Assignments, Grades, Attendance, Timetable, Profile | 8 pages |
| **Teacher** | Dashboard, Courses, Course Builder, Course Edit, Assignments, Assignment Detail, Attendance, Students, Timetable, Profile | 10 pages |
| **Admin (LMS)** | Dashboard, Courses, Students, Teachers, Batches, Batch Detail, Timetable, Reports, Settings, Profile | 10 pages |
| **Admin (Platform)** | Platform Dashboard, Assignments, Exams List, Exam Structures, Rubrics, Generation, Approval Workflows, Users, Entities, Classrooms, Student Performance, Stats Corporate, Record, Vocabulary, Grammar, Payment Record, Tickets, Settings, Exam | 19 pages |
| **Auth** | Login, Register, Forgot Password | 3 pages |
| **Total** | | **~50+ pages** |
---
*Document prepared by EnCoach Engineering Team. For questions or clarifications, contact the Solution Architect.*

86
docs/UTAS_SYSTEM_SCOPE.md Normal file
View File

@@ -0,0 +1,86 @@
# EnCoach -- UTAS System Scope Definition
**Date:** March 16, 2026
**Project:** EnCoach University Deployment for UTAS
---
## Core Modules
### 1. Learning Management System (LMS)
| Item | Detail |
|------|--------|
| **Engine** | OpenEduCat (Odoo 19 native modules) |
| **Frontend** | React 18 SPA -- student, teacher, and admin portals (50+ pages built) |
| **Capabilities** | Course catalog and enrollment, batch/cohort management, timetable scheduling, attendance tracking, gradebook, academic reporting |
| **Integration** | REST API bridge between OpenEduCat models and React frontend |
| **Subjects** | English Self Learning, Mathematics, Information Technology (extensible) |
### 2. Exam Portal
| Item | Detail |
|------|--------|
| **Exam Types** | Reading, Listening, Writing, Speaking, Level Test, Math, IT |
| **AI Generation** | GPT-4o generates exam content per subject and difficulty level |
| **AI Grading** | Automated grading for writing (GPT-4o + IELTS rubric), speaking (Whisper STT + GPT-4o), MCQ, short answer, numerical |
| **AI Detection** | GPTZero flags AI-generated student submissions |
| **Media** | AWS Polly TTS for listening audio, ELAI avatars for speaking video |
| **Workflow** | Exam creation, review, approval, assignment, timed sessions, grading, results |
| **Roles** | Admin creates/approves, teacher assigns, student takes, system grades |
### 3. Student Onboarding
| Item | Detail |
|------|--------|
| **Registration** | Self-registration or bulk import (CSV/batch) by admin |
| **SIS Sync** | Automatic student provisioning from UTAS Student Information System |
| **Enrollment** | Assignment to courses, batches, and classrooms upon registration |
| **Verification** | Email verification, registration code validation |
| **Roles** | Student, teacher, admin -- role-based access from first login |
### 4. Knowledge Base
| Item | Detail |
|------|--------|
| **Training Tips** | 80+ categorized tips (reading, writing, strategy, vocabulary, grammar) with FAISS semantic search |
| **Personalization** | After each exam, GPT analyzes performance and retrieves relevant tips via RAG (Retrieval-Augmented Generation) |
| **Walkthroughs** | Per-module guided learning paths |
| **AI Tutoring** | ELAI avatar virtual tutors provide video-based explanations and guidance (Humanisation) |
| **Content Scope** | English/IELTS (existing), Math and IT (to be developed) |
### 5. Helpdesk System
| Item | Detail |
|------|--------|
| **Ticketing** | Students and staff create support tickets with category, priority, and description |
| **Workflow** | Creation, assignment, status tracking (open, in-progress, resolved, closed) |
| **Roles** | Students/teachers submit; admin staff categorize, assign, and resolve |
| **Backend** | `encoach_ticket` Odoo module with full CRUD API (`/api/tickets`) |
### 6. API Integration with SIS
| Item | Detail |
|------|--------|
| **Direction** | Bidirectional: UTAS SIS <--> EnCoach |
| **Inbound (SIS to EnCoach)** | Student enrollment data, course catalog mapping, student status changes |
| **Outbound (EnCoach to SIS)** | Exam grades, attendance records, course completion status |
| **Method** | REST API (preferred) or CSV import/export (fallback) |
| **Frequency** | Real-time webhook (if available) or scheduled sync (daily batch) |
| **Prerequisite** | UTAS provides SIS API documentation, test environment, and technical contact |
---
## Technology Stack Summary
| Layer | Technology |
|-------|-----------|
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui |
| Backend | Odoo 19, Python 3.11, PostgreSQL 16 |
| LMS | OpenEduCat (Odoo modules) |
| AI | OpenAI GPT-4o, Whisper, AWS Polly, ELAI, GPTZero, FAISS |
| Deployment | Docker Compose, Nginx |
---
*EnCoach Engineering -- UTAS Deployment*

27
e2e/login.spec.ts Normal file
View File

@@ -0,0 +1,27 @@
import { expect, test } from "@playwright/test";
/**
* Smoke test: the app loads, serves the login page, and the main form
* controls are present and reachable. We deliberately avoid hitting the
* real backend — this is a lightweight sanity check that catches broken
* routing / bundle / asset problems before they reach production.
*/
test("login page renders the sign-in form", async ({ page }) => {
await page.goto("/login");
await expect(
page.getByRole("heading", { name: /sign in|login|welcome/i }),
).toBeVisible({ timeout: 10_000 });
const email = page.getByLabel(/email/i);
const password = page.getByLabel(/password/i);
await expect(email).toBeVisible();
await expect(password).toBeVisible();
await expect(page.getByRole("button", { name: /sign in|log in/i })).toBeVisible();
});
test("root redirects to login for anonymous users", async ({ page }) => {
const response = await page.goto("/");
expect(response?.ok()).toBeTruthy();
await page.waitForURL(/\/login/i, { timeout: 10_000 });
});

26
eslint.config.js Normal file
View File

@@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},
);

26
index.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EnCoach — IELTS & English Learning Platform</title>
<meta name="description" content="EnCoach is a comprehensive IELTS and English learning platform for students, teachers, and corporates." />
<meta name="author" content="EnCoach" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#4A4068" />
<meta property="og:title" content="EnCoach — IELTS & English Learning Platform" />
<meta property="og:description" content="Comprehensive IELTS preparation and English learning platform." />
<meta property="og:image" content="/logo-icon.png" />
<meta property="og:type" content="website" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

8493
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

96
package.json Normal file
View File

@@ -0,0 +1,96 @@
{
"name": "vite_react_shadcn_ts",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.15",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.15",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@tanstack/react-query": "^5.83.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"i18next": "^26.0.6",
"i18next-browser-languagedetector": "^8.2.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.462.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.61.1",
"react-i18next": "^17.0.4",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"recharts": "^2.15.4",
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.59.1",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"jsdom": "^20.0.3",
"lovable-tagger": "^1.1.13",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19",
"vitest": "^3.2.4"
}
}

42
playwright.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright configuration for EnCoach frontend smoke tests.
*
* By default we run against a Vite preview server on localhost:4173. In CI
* this is started by the `playwright` step in .github/workflows/ci.yml after
* `npm run build`.
*
* Override the base URL with `PLAYWRIGHT_BASE_URL=https://... npx playwright
* test` to run against a deployed environment.
*/
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:4173";
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [["github"], ["list"]] : [["list"]],
use: {
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER
? undefined
: {
command: "npm run preview -- --host 127.0.0.1 --port 4173",
url: baseURL,
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 B

BIN
public/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

12
public/logo.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="EnCoach">
<defs>
<linearGradient id="eg" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="50%" stop-color="#7c3aed"/>
<stop offset="100%" stop-color="#2563eb"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#eg)"/>
<path d="M20 20h22a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h14a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H26v6h16a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H20a2 2 0 0 1-2-2V22a2 2 0 0 1 2-2Z" fill="#ffffff"/>
<circle cx="48" cy="48" r="5" fill="#facc15" stroke="#ffffff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 697 B

1
public/placeholder.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

14
public/robots.txt Normal file
View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View File

@@ -0,0 +1,2 @@
name,email
Jane Doe,jane@example.com
1 name email
2 Jane Doe jane@example.com

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

425
src/App.tsx Normal file
View File

@@ -0,0 +1,425 @@
import { lazy, Suspense } from "react";
import { ThemeProvider } from "next-themes";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, Navigate, useNavigate } from "react-router-dom";
import { AuthProvider } from "@/contexts/AuthContext";
import ProtectedRoute from "@/components/ProtectedRoute";
import StudentLayout from "@/components/StudentLayout";
import TeacherLayout from "@/components/TeacherLayout";
import AdminLmsLayout from "@/components/AdminLmsLayout";
import { queryClient } from "@/lib/query-client";
import { Button } from "@/components/ui/button";
import { ErrorBoundary } from "@/components/ErrorBoundary";
// -----------------------------------------------------------------------------
// Lazy-loaded route pages
// -----------------------------------------------------------------------------
// Keeping the initial bundle small matters on slow networks / modest hardware
// (many teachers/students open the app from school Wi-Fi). Each page below is
// split into its own chunk and only fetched when the user hits the matching
// route. The shell (layouts, providers, router) is still eagerly imported so
// the first paint stays snappy.
// Auth
const Login = lazy(() => import("@/pages/Login"));
const Register = lazy(() => import("@/pages/Register"));
const EmailVerification = lazy(() => import("@/pages/EmailVerification"));
const OnboardingWizard = lazy(() => import("@/pages/OnboardingWizard"));
const ForgotPassword = lazy(() => import("@/pages/ForgotPassword"));
const ResetPassword = lazy(() => import("@/pages/ResetPassword"));
const ScoreVerification = lazy(() => import("@/pages/ScoreVerification"));
// Original platform pages
const AdminDashboard = lazy(() => import("@/pages/AdminDashboard"));
const UsersPage = lazy(() => import("@/pages/UsersPage"));
const EntitiesPage = lazy(() => import("@/pages/EntitiesPage"));
const AssignmentsPage = lazy(() => import("@/pages/AssignmentsPage"));
const ExamsListPage = lazy(() => import("@/pages/ExamsListPage"));
const ExamStructuresPage = lazy(() => import("@/pages/ExamStructuresPage"));
const RubricsPage = lazy(() => import("@/pages/RubricsPage"));
const GenerationPage = lazy(() => import("@/pages/GenerationPage"));
const ApprovalWorkflowsPage = lazy(() => import("@/pages/ApprovalWorkflowsPage"));
const ClassroomsPage = lazy(() => import("@/pages/ClassroomsPage"));
const StudentPerformancePage = lazy(() => import("@/pages/StudentPerformancePage"));
const StatsCorporatePage = lazy(() => import("@/pages/StatsCorporatePage"));
const RecordPage = lazy(() => import("@/pages/RecordPage"));
const VocabularyPage = lazy(() => import("@/pages/VocabularyPage"));
const GrammarPage = lazy(() => import("@/pages/GrammarPage"));
const PaymentRecordPage = lazy(() => import("@/pages/PaymentRecordPage"));
const TicketsPage = lazy(() => import("@/pages/TicketsPage"));
const SettingsPage = lazy(() => import("@/pages/SettingsPage"));
// Student pages
const StudentDashboard = lazy(() => import("@/pages/student/StudentDashboard"));
const StudentCourses = lazy(() => import("@/pages/student/StudentCourses"));
const StudentCourseDetail = lazy(() => import("@/pages/student/StudentCourseDetail"));
const StudentAssignments = lazy(() => import("@/pages/student/StudentAssignments"));
const StudentGrades = lazy(() => import("@/pages/student/StudentGrades"));
const StudentAttendance = lazy(() => import("@/pages/student/StudentAttendance"));
const StudentTimetable = lazy(() => import("@/pages/student/StudentTimetable"));
const StudentProfile = lazy(() => import("@/pages/student/StudentProfile"));
// Adaptive learning pages
const SubjectSelection = lazy(() => import("@/pages/student/SubjectSelection"));
const DiagnosticTest = lazy(() => import("@/pages/student/DiagnosticTest"));
const ProficiencyProfile = lazy(() => import("@/pages/student/ProficiencyProfile"));
const LearningPlanPage = lazy(() => import("@/pages/student/LearningPlan"));
const TopicLearning = lazy(() => import("@/pages/student/TopicLearning"));
// Teacher pages
const TeacherDashboard = lazy(() => import("@/pages/teacher/TeacherDashboard"));
const TeacherQuickSetup = lazy(() => import("@/pages/teacher/TeacherQuickSetup"));
const TeacherCourses = lazy(() => import("@/pages/teacher/TeacherCourses"));
const CourseBuilder = lazy(() => import("@/pages/teacher/CourseBuilder"));
const TeacherAssignments = lazy(() => import("@/pages/teacher/TeacherAssignments"));
const TeacherAssignmentDetail = lazy(() => import("@/pages/teacher/TeacherAssignmentDetail"));
const TeacherAttendance = lazy(() => import("@/pages/teacher/TeacherAttendance"));
const TeacherStudents = lazy(() => import("@/pages/teacher/TeacherStudents"));
const TeacherTimetable = lazy(() => import("@/pages/teacher/TeacherTimetable"));
const TeacherProfile = lazy(() => import("@/pages/teacher/TeacherProfile"));
const TeacherLibrary = lazy(() => import("@/pages/teacher/TeacherLibrary"));
const AdaptiveSettings = lazy(() => import("@/pages/teacher/AdaptiveSettings"));
// Admin LMS pages
const AdminLmsDashboard = lazy(() => import("@/pages/admin/AdminLmsDashboard"));
const AdminQuickSetup = lazy(() => import("@/pages/admin/AdminQuickSetup"));
const SmartWizardHub = lazy(() => import("@/pages/admin/SmartWizardHub"));
const RubricWizard = lazy(() => import("@/pages/admin/wizards/RubricWizard"));
const ExamStructureWizard = lazy(() => import("@/pages/admin/wizards/ExamStructureWizard"));
const CourseWizard = lazy(() => import("@/pages/admin/wizards/CourseWizard"));
const CoursePlanWizard = lazy(() => import("@/pages/admin/wizards/CoursePlanWizard"));
const AdminCoursePlans = lazy(() => import("@/pages/admin/AdminCoursePlans"));
const AdminCoursePlanDetail = lazy(() => import("@/pages/admin/AdminCoursePlanDetail"));
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"));
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
<div className="min-h-screen flex flex-col items-center justify-center gap-4 p-8">
<p className="text-muted-foreground text-center max-w-sm">Subscription checkout will be available here.</p>
<Button onClick={() => navigate("/student/dashboard")}>Back to dashboard</Button>
</div>
);
}
function RouteFallback() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
const App = () => (
<ErrorBoundary>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="encoach-theme"
disableTransitionOnChange
>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<Toaster />
<Sonner />
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Auth routes */}
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email" element={<EmailVerification />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/verify/:verificationHash" element={<ScoreVerification />} />
{/* Public pages */}
<Route path="/apply" element={<AdmissionApplication />} />
<Route path="/exam/access/:token" element={<OfficialExamAccess />} />
<Route path="/exam/official/:code" element={<OfficialExamAccess />} />
<Route path="/exam/login/:examId" element={<OfficialExamAccess />} />
<Route path="/faq" element={<FaqPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/onboarding" element={<OnboardingWizard />} />
</Route>
{/* Student routes */}
<Route element={<ProtectedRoute allowedRoles={["student"]} />}>
<Route path="/student/exam/:examId/session" element={<ExamSession />} />
<Route path="/student/exam/:examId/status" element={<ExamStatus />} />
<Route path="/student/placement/test" element={<PlacementTest />} />
<Route path="/student/subscription" element={<StudentSubscriptionPlaceholder />} />
<Route element={<StudentLayout />}>
<Route path="/student/dashboard" element={<StudentDashboard />} />
<Route path="/student/courses" element={<StudentCourses />} />
<Route path="/student/courses/:id" element={<StudentCourseDetail />} />
<Route path="/student/assignments" element={<StudentAssignments />} />
<Route path="/student/grades" element={<StudentGrades />} />
<Route path="/student/attendance" element={<StudentAttendance />} />
<Route path="/student/timetable" element={<StudentTimetable />} />
<Route path="/student/profile" element={<StudentProfile />} />
<Route path="/student/privacy" element={<PrivacyCenter />} />
<Route path="/student/subjects" element={<SubjectSelection />} />
<Route path="/student/diagnostic/:subjectId" element={<DiagnosticTest />} />
<Route path="/student/proficiency/:subjectId" element={<ProficiencyProfile />} />
<Route path="/student/plan/:subjectId" element={<LearningPlanPage />} />
<Route path="/student/topic/:topicId" element={<TopicLearning />} />
<Route path="/student/subject-registration" element={<SubjectRegistrationPage />} />
<Route path="/student/courses/:id/chapters/:chapterId" element={<StudentChapterView />} />
<Route path="/student/discussions" element={<StudentDiscussionBoard />} />
<Route path="/student/announcements" element={<StudentAnnouncements />} />
<Route path="/student/messages" element={<StudentMessages />} />
<Route path="/student/journey" element={<StudentJourney />} />
<Route path="/student/placement" element={<PlacementBriefing />} />
<Route path="/student/placement/results" element={<PlacementResults />} />
<Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
</Route>
</Route>
{/* Teacher routes */}
<Route element={<ProtectedRoute allowedRoles={["teacher", "admin", "developer"]} />}>
<Route element={<TeacherLayout />}>
<Route path="/teacher/dashboard" element={<TeacherDashboard />} />
<Route path="/teacher/quick-setup" element={<TeacherQuickSetup />} />
<Route path="/teacher/courses" element={<TeacherCourses />} />
<Route path="/teacher/library" element={<TeacherLibrary />} />
<Route path="/teacher/courses/new" element={<CourseBuilder />} />
<Route path="/teacher/courses/:id/edit" element={<CourseBuilder />} />
<Route path="/teacher/assignments" element={<TeacherAssignments />} />
<Route path="/teacher/assignments/:id" element={<TeacherAssignmentDetail />} />
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
<Route path="/teacher/students" element={<TeacherStudents />} />
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
<Route path="/teacher/courses/:courseId/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:courseId/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:courseId/workbench" element={<AiWorkbench />} />
<Route path="/teacher/discussions" element={<TeacherDiscussionBoard />} />
<Route path="/teacher/announcements" element={<TeacherAnnouncements />} />
<Route path="/teacher/profile" element={<TeacherProfile />} />
<Route path="/teacher/privacy" element={<PrivacyCenter />} />
<Route path="/teacher/course/:courseId/progress" element={<CourseProgress />} />
<Route path="/teacher/adaptive/settings" element={<AdaptiveSettings />} />
</Route>
</Route>
{/* Admin routes — unified: original platform + LMS + AI */}
<Route element={<ProtectedRoute allowedRoles={["admin", "corporate", "mastercorporate", "agent", "developer"]} />}>
<Route element={<AdminLmsLayout />}>
{/* LMS Dashboard */}
<Route path="/admin/dashboard" element={<AdminLmsDashboard />} />
<Route path="/admin/quick-setup" element={<AdminQuickSetup />} />
<Route path="/admin/smart-wizard" element={<SmartWizardHub />} />
<Route path="/admin/smart-wizard/rubric" element={<RubricWizard />} />
<Route path="/admin/smart-wizard/exam-structure" element={<ExamStructureWizard />} />
<Route path="/admin/smart-wizard/course" element={<CourseWizard />} />
<Route path="/admin/smart-wizard/course-plan" element={<CoursePlanWizard />} />
<Route path="/admin/course-plans" element={<AdminCoursePlans />} />
<Route path="/admin/course-plans/:planId" element={<AdminCoursePlanDetail />} />
{/* Original platform dashboard */}
<Route path="/admin/platform" element={<AdminDashboard />} />
{/* 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 />} />
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}
<Route path="/admin/assignments" element={<AssignmentsPage />} />
<Route path="/admin/examsList" element={<ExamsListPage />} />
<Route path="/admin/exam-structures" element={<ExamStructuresPage />} />
<Route path="/admin/rubrics" element={<RubricsPage />} />
<Route path="/admin/generation" element={<GenerationPage />} />
<Route path="/admin/approval-workflows" element={<ApprovalWorkflowsPage />} />
{/* Original management pages */}
<Route path="/admin/users" element={<UsersPage />} />
<Route path="/admin/entities" element={<EntitiesPage />} />
<Route path="/admin/classrooms" element={<ClassroomsPage />} />
{/* Original report pages */}
<Route path="/admin/student-performance" element={<StudentPerformancePage />} />
<Route path="/admin/stats-corporate" element={<StatsCorporatePage />} />
<Route path="/admin/record" element={<RecordPage />} />
{/* Training */}
<Route path="/admin/training/vocabulary" element={<VocabularyPage />} />
<Route path="/admin/training/grammar" element={<GrammarPage />} />
{/* Support */}
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
<Route path="/admin/tickets" element={<TicketsPage />} />
<Route path="/admin/settings-platform" element={<SettingsPage />} />
<Route path="/admin/exam/create" element={<ExamTemplateSelection />} />
<Route path="/admin/exam/ielts/create" element={<IeltsExamCreate />} />
<Route path="/admin/exam/ielts/:examId/skills" element={<IeltsSkillConfig />} />
<Route path="/admin/exam/ielts/:examId/content" element={<IeltsContentPool />} />
<Route path="/admin/exam/ielts/:examId/validate" element={<IeltsExamValidation />} />
<Route path="/admin/exam/custom/create" element={<CustomExamCreate />} />
<Route path="/admin/exam/review-queue" element={<ExamReviewQueue />} />
<Route path="/admin/exam/review/:examId" element={<ExamReviewDetail />} />
<Route path="/admin/ai/prompts" element={<AIPromptEditor />} />
<Route path="/admin/ai/feedback" element={<AIFeedbackTriage />} />
<Route path="/admin/taxonomy" element={<TaxonomyManager />} />
<Route path="/admin/resources" element={<ResourceManager />} />
{/* Institutional LMS pages */}
<Route path="/admin/academic-years" element={<AcademicYearManager />} />
<Route path="/admin/departments" element={<DepartmentManager />} />
<Route path="/admin/admissions" element={<AdmissionList />} />
<Route path="/admin/admissions/:id" element={<AdmissionDetail />} />
<Route path="/admin/admission-register" element={<AdmissionRegisterPage />} />
<Route path="/admin/exam-sessions" element={<InstitutionalExamSessions />} />
<Route path="/admin/marksheets" element={<MarksheetManager />} />
{/* OpenEduCat module pages */}
<Route path="/admin/student-leave" element={<AdminStudentLeave />} />
<Route path="/admin/fees" element={<AdminFees />} />
<Route path="/admin/lessons" element={<AdminLessons />} />
<Route path="/admin/gradebook" element={<AdminGradebook />} />
<Route path="/admin/student-progress" element={<AdminStudentProgress />} />
<Route path="/admin/library" element={<AdminLibrary />} />
<Route path="/admin/activities" element={<AdminActivities />} />
<Route path="/admin/facilities" element={<AdminFacilities />} />
<Route path="/admin/faq" element={<FaqManager />} />
<Route path="/admin/notification-rules" element={<NotificationRules />} />
<Route path="/admin/approval-config" element={<ApprovalWorkflowConfig />} />
<Route path="/admin/roles-permissions" element={<RolesPermissions />} />
<Route path="/admin/authority-matrix" element={<AuthorityMatrix />} />
<Route path="/admin/user-roles" element={<UserRoles />} />
<Route path="/admin/exam/:examId/grading" element={<GradingQueue />} />
<Route path="/admin/course/configure/:courseId" element={<CourseConfig />} />
<Route path="/admin/course/:courseId/modules" element={<ModuleBuilder />} />
<Route path="/admin/entity/students/upload" element={<BulkStudentUpload />} />
<Route path="/admin/entity/students/credentials" element={<CredentialDashboard />} />
<Route path="/admin/entity/:entityId/level-mapping" element={<LevelMappingConfig />} />
<Route path="/admin/entity/:entityId/branding" element={<WhiteLabelBranding />} />
<Route path="/admin/ai-course/english/:courseId/quality" element={<AiEnglishQuality />} />
<Route path="/admin/ai-course/english/taxonomy" element={<AiEnglishTaxonomy />} />
<Route path="/admin/ai-course/ielts/:courseId/validation" element={<AiIeltsValidation />} />
<Route path="/admin/adaptive/dashboard" element={<AdaptiveDashboard />} />
<Route path="/admin/adaptive/student/:studentId" element={<AdaptiveStudentDetail />} />
<Route path="/admin/scores/pending" element={<ScoreApprovalQueue />} />
</Route>
</Route>
{/* Redirects */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
{/* Legacy route redirects */}
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
);
export default App;

View File

@@ -0,0 +1,164 @@
import { useEffect, useState } from "react";
import { ThumbsDown, ThumbsUp } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import {
useAIFeedbackSummary,
useSubmitAIFeedback,
} from "@/hooks/queries/useAIFeedback";
import { cn } from "@/lib/utils";
import type {
AIFeedbackRating,
AIFeedbackSubjectType,
} from "@/types/ai-feedback";
export interface AIFeedbackButtonsProps {
subjectType: AIFeedbackSubjectType;
subjectId: number;
promptKey?: string;
promptVersion?: number;
aiLogId?: number;
entityId?: number;
courseId?: number;
size?: "sm" | "md";
className?: string;
}
/** Thumbs up/down widget for any AI-generated artefact.
*
* Always renders the same UI regardless of whether the user has rated the
* artefact before — a previous rating will show as highlighted. Clicking the
* same button again is a no-op; clicking the opposite button updates the
* server-side row in place. A thumbs-down always prompts the user for a short
* comment (required by the backend).
*/
export function AIFeedbackButtons({
subjectType,
subjectId,
promptKey,
promptVersion,
aiLogId,
entityId,
courseId,
size = "sm",
className,
}: AIFeedbackButtonsProps) {
const summary = useAIFeedbackSummary(subjectType, subjectId);
const submit = useSubmitAIFeedback();
const [downOpen, setDownOpen] = useState(false);
const [comment, setComment] = useState("");
useEffect(() => {
if (downOpen) {
setComment(summary.data?.my_comment ?? "");
}
}, [downOpen, summary.data?.my_comment]);
const myRating: AIFeedbackRating | null = summary.data?.my_rating ?? null;
const submitUp = async () => {
if (myRating === "up") return;
try {
await submit.mutateAsync({
subject_type: subjectType,
subject_id: subjectId,
rating: "up",
prompt_key: promptKey,
prompt_version: promptVersion,
ai_log_id: aiLogId,
entity_id: entityId,
course_id: courseId,
});
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit");
}
};
const submitDown = async () => {
if (!comment.trim()) {
toast.error("Please tell us what was wrong.");
return;
}
try {
await submit.mutateAsync({
subject_type: subjectType,
subject_id: subjectId,
rating: "down",
comment: comment.trim(),
prompt_key: promptKey,
prompt_version: promptVersion,
ai_log_id: aiLogId,
entity_id: entityId,
course_id: courseId,
});
setDownOpen(false);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to submit");
}
};
const btnSize = size === "sm" ? "sm" : "default";
const iconCls = size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4";
return (
<div className={cn("flex items-center gap-1", className)}>
<Button
variant={myRating === "up" ? "default" : "outline"}
size={btnSize}
onClick={submitUp}
disabled={submit.isPending}
aria-label="Thumbs up"
>
<ThumbsUp className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.up ?? 0}</span>
</Button>
<Button
variant={myRating === "down" ? "default" : "outline"}
size={btnSize}
onClick={() => setDownOpen(true)}
disabled={submit.isPending}
aria-label="Thumbs down"
>
<ThumbsDown className={iconCls} />
<span className="ml-1 text-xs">{summary.data?.down ?? 0}</span>
</Button>
<Dialog open={downOpen} onOpenChange={setDownOpen}>
<DialogContent
className="max-w-md"
description="Tell us what was wrong so we can improve this AI output."
>
<DialogHeader>
<DialogTitle>What went wrong?</DialogTitle>
</DialogHeader>
<Textarea
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={4}
placeholder="e.g. Wrong answer, confusing wording, off-topic…"
/>
<DialogFooter>
<Button variant="outline" onClick={() => setDownOpen(false)}>
Cancel
</Button>
<Button onClick={submitDown} disabled={submit.isPending}>
Submit feedback
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default AIFeedbackButtons;

View File

@@ -0,0 +1,461 @@
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
GraduationCap, LogOut, User, Settings, LayoutDashboard, ClipboardList,
FileText, Layers, BookOpen, Wand2, GitBranch, Users, BarChart3,
Building2, History, CreditCard, Ticket, BookA, PenTool,
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
// ============= Navigation Config =============
// Items store i18n keys (`titleKey`) rather than literal strings so Arabic
// language switches update the sidebar without the component having to
// re-mount.
interface NavItem { titleKey: string; url: string; icon: LucideIcon }
const overviewItems: NavItem[] = [
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
];
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 },
];
const academicItems: NavItem[] = [
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
];
const adaptiveItems: NavItem[] = [
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
];
const managementItems: NavItem[] = [
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ 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 },
];
const reportItems: NavItem[] = [
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ titleKey: "nav.record", url: "/admin/record", icon: History },
];
const trainingItems: NavItem[] = [
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
];
const configItems: NavItem[] = [
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
];
const supportItems: NavItem[] = [
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
];
// ============= Reusable sidebar group =============
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<SidebarGroup>
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
// ============= Breadcrumbs =============
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
// Segments without an entry fall back to a Title-cased version of the slug.
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses",
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",
generation: "nav.generation", "review-queue": "nav.reviewQueue",
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
feedback: "breadcrumb.feedback",
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
entities: "nav.entities", classrooms: "nav.classrooms",
"student-performance": "nav.studentPerformance",
"stats-corporate": "nav.statsCorporate", record: "nav.record",
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
tickets: "nav.tickets", "settings-platform": "nav.settings",
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
"academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
};
function AppBreadcrumbs() {
const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem>
{segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/");
const key = routeLabelKeys[seg];
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1;
return (
<React.Fragment key={path}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{isLast ? <BreadcrumbPage>{label}</BreadcrumbPage> : <BreadcrumbLink asChild><Link to={path}>{label}</Link></BreadcrumbLink>}
</BreadcrumbItem>
</React.Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
// ============= Route content fallback =============
// Shown only inside the main content area while a lazy-loaded route chunk
// is fetching. Keeping the fallback local means the sidebar and header
// stay mounted during navigation — previously the page felt like a full
// browser reload because the outer App-level Suspense replaced everything
// with a full-viewport spinner.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
// ============= Main Layout =============
export default function AdminLmsLayout() {
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
// on the wizard's own Next/Finish footer (real bug repro: trying to
// click "Next" in the Course-plan wizard at the standard 1024×768
// viewport hits the pill instead). The AI Assistant orb also at the
// bottom-right is preserved — it's smaller and useful in-wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
const handleLogout = async () => {
await logout();
navigate("/login");
};
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<AdminSidebar />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3">
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs />
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t("nav.entities")}
</div>
<DropdownMenuSeparator />
{user.entities.map((entity) => (
<DropdownMenuItem
key={entity.id}
onClick={() => {
setSelectedEntityId(entity.id);
// Force page data to refresh against the newly
// selected entity scope.
window.location.reload();
}}
className="flex items-center justify-between gap-2"
>
<span className="truncate">{entity.name}</span>
{selectedEntity?.id === entity.id && (
<Badge variant="secondary" className="text-[10px]">
{t("common.active", "Active")}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-xs font-semibold text-primary-foreground">{initials}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
{!isWizardRoute && (
<Link
to="/admin/tickets"
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
)}
</SidebarProvider>
);
}
// ============= Admin Sidebar =============
function AdminSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const { user } = useAuth();
const { hasAnyPermission, isAdmin } = usePermissions();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
return (
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
{collapsed ? (
<img
src="/logo.png"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain"
/>
) : (
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain"
/>
)}
</div>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
<SidebarGroup>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex w-full items-center justify-between">
{t("sidebarGroup.training")}
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{trainingItems.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-semibold text-primary">{user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"}</span>
</div>
{!collapsed && (
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
</div>
)}
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,197 @@
import { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar";
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
import React from "react";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { useAuth } from "@/contexts/AuthContext";
const routeLabels: Record<string, string> = {
"dashboard": "Dashboard",
"admin": "Admin",
"assignments": "Assignments",
"examsList": "Exams List",
"exam-structures": "Exam Structures",
"rubrics": "Rubrics",
"generation": "Exam Generation",
"approval-workflows": "Approval Workflows",
"users": "Users",
"entities": "Entities",
"classrooms": "Classrooms",
"student-performance": "Student Performance",
"stats-corporate": "Stats Corporate",
"record": "Record",
"training": "Training",
"vocabulary": "Vocabulary",
"grammar": "Grammar",
"payment-record": "Payment Record",
"tickets": "Tickets",
"settings": "Settings",
"profile": "Profile",
"exam": "Exam",
"tutor": "Tutor",
};
function AppBreadcrumbs() {
const location = useLocation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to="/dashboard/admin">Home</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/");
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1;
return (
<React.Fragment key={path}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{isLast ? (
<BreadcrumbPage>{label}</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={path}>{label}</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
</React.Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
// Local Suspense fallback so the sidebar/header keep rendering while a
// lazy route chunk loads. See AdminLmsLayout for the full rationale.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
export default function AppLayout() {
const navigate = useNavigate();
const { user, selectedEntity, setSelectedEntityId } = useAuth();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
// intercepting clicks on the wizard's own Next/Finish footer at most
// viewport heights, blocking users from finishing the flow. The AI
// Assistant orb (also bottom-right) stays — it's smaller and
// genuinely useful inside the wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<AppSidebar />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3">
<SidebarTrigger />
<AppBreadcrumbs />
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{user.entities.map((entity) => {
const active = selectedEntity?.id === entity.id;
return (
<DropdownMenuItem
key={entity.id}
onClick={() => setSelectedEntityId(entity.id)}
className="flex items-center justify-between gap-3"
>
<span className="truncate">{entity.name}</span>
{active ? <Badge variant="secondary">Current</Badge> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => navigate("/settings")} className="text-muted-foreground hover:text-foreground">
<Settings className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-xs font-semibold text-primary-foreground">AU</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">Admin User</p>
<p className="text-xs text-muted-foreground">admin@encoach.com</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/profile")}>
<User className="mr-2 h-4 w-4" /> Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/settings")}>
<Settings className="mr-2 h-4 w-4" /> Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/login")}>
<LogOut className="mr-2 h-4 w-4" /> Logout
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
{!isWizardRoute && (
<Link
to="/tickets"
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
)}
</SidebarProvider>
);
}

View File

@@ -0,0 +1,161 @@
import {
LayoutDashboard, ClipboardList, FileText, Layers, BookOpen, Wand2,
GitBranch, Users, BarChart3, Building2, History, GraduationCap,
CreditCard, Ticket, Settings, User, BookA, PenTool, LogOut
} from "lucide-react";
import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown } from "lucide-react";
const mainItems = [
{ title: "Dashboard", url: "/dashboard/admin", icon: LayoutDashboard },
{ title: "Assignments", url: "/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/examsList", icon: FileText },
{ title: "Exam Structures", url: "/exam-structures", icon: Layers },
{ 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 },
];
const reportItems = [
{ title: "Student Performance", url: "/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/stats-corporate", icon: Building2 },
{ title: "Record", url: "/record", icon: History },
];
const trainingItems = [
{ title: "Vocabulary", url: "/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/training/grammar", icon: PenTool },
];
const supportItems = [
{ title: "Payment Record", url: "/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/tickets", icon: Ticket },
{ title: "Settings", url: "/settings", icon: Settings },
];
function SidebarNavGroup({ label, items }: { label: string; items: typeof mainItems }) {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.url === "/dashboard/admin"}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export function AppSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
const { i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
return (
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
<GraduationCap className="h-5 w-5 text-primary-foreground" />
</div>
{!collapsed && (
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
EnCoach
</span>
)}
</div>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNavGroup label="LMS" items={mainItems} />
<SidebarNavGroup label="Management" items={managementItems} />
<SidebarNavGroup label="Reports" items={reportItems} />
<SidebarGroup>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex w-full items-center justify-between">
Training
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{trainingItems.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
<SidebarNavGroup label="Support" items={supportItems} />
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<User className="h-4 w-4 text-primary" />
</div>
{!collapsed && (
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">Admin User</span>
<span className="text-xs text-muted-foreground truncate">admin@encoach.com</span>
</div>
)}
</div>
</SidebarFooter>
</Sidebar>
);
}

View File

@@ -0,0 +1,71 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
interface Props extends WithTranslation {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Error boundary is a classical React class component, so we bridge it to
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
* `react-i18next`'s hook path intact while giving us a `t` prop here.
*/
class ErrorBoundaryInner extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
const { t } = this.props;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
<div className="max-w-md w-full text-center space-y-4">
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
<p className="text-muted-foreground text-sm">
{t("errors.unexpectedDescription")}
</p>
{this.state.error && (
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
</details>
)}
<button
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
{t("errors.refreshPage")}
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);

View File

@@ -0,0 +1,48 @@
import { Languages } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
export function LanguageToggle() {
const { i18n, t } = useTranslation();
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("language.change")}
title={t("language.change")}
>
<Languages className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
{SUPPORTED_LANGS.map((lang) => (
<DropdownMenuCheckboxItem
key={lang.code}
checked={current === lang.code}
onCheckedChange={(checked) => {
if (checked) void i18n.changeLanguage(lang.code);
}}
>
{lang.label}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LanguageToggle;

View File

@@ -0,0 +1,235 @@
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { ExternalLink, Download, FileText, Video, Music, Image, Link2 } from "lucide-react";
import { API_BASE_URL } from "@/lib/api-client";
import type { ChapterMaterial, MaterialType } from "@/types/courseware";
interface MaterialViewerProps {
material: ChapterMaterial | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onDownload?: (materialId: number, name: string) => void;
}
const typeLabels: Record<MaterialType, string> = {
pdf: "PDF Document",
document: "Document",
video: "Video",
audio: "Audio",
image: "Image",
link: "External Link",
article: "Article",
};
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-4 w-4" />,
document: <FileText className="h-4 w-4" />,
video: <Video className="h-4 w-4" />,
audio: <Music className="h-4 w-4" />,
image: <Image className="h-4 w-4" />,
link: <Link2 className="h-4 w-4" />,
article: <FileText className="h-4 w-4" />,
};
function getContentUrl(materialId: number): string {
const token = localStorage.getItem("encoach_token") ?? "";
return `${API_BASE_URL}/materials/${materialId}/content?token=${token}`;
}
function isYouTubeUrl(url: string): boolean {
return /(?:youtube\.com\/(?:watch|embed)|youtu\.be\/)/.test(url);
}
function getYouTubeEmbedUrl(url: string): string {
const match = url.match(
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([\w-]+)/
);
return match ? `https://www.youtube.com/embed/${match[1]}` : url;
}
function PdfViewer({ material }: { material: ChapterMaterial }) {
if (material.file_url) {
return (
<iframe
src={getContentUrl(material.id)}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
if (material.url) {
return (
<iframe
src={material.url}
className="w-full h-full min-h-[600px] rounded-lg border"
title={material.name}
/>
);
}
return <EmptyState message="No PDF file available." />;
}
function VideoViewer({ material }: { material: ChapterMaterial }) {
if (material.url && isYouTubeUrl(material.url)) {
return (
<iframe
src={getYouTubeEmbedUrl(material.url)}
className="w-full aspect-video rounded-lg border"
title={material.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
if (material.url) {
return (
<video
src={material.url}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
if (material.file_url) {
return (
<video
src={getContentUrl(material.id)}
controls
className="w-full rounded-lg border"
title={material.name}
>
Your browser does not support the video tag.
</video>
);
}
return <EmptyState message="No video source available." />;
}
function AudioViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No audio source available." />;
return (
<div className="flex items-center justify-center py-12">
<audio src={src} controls className="w-full max-w-lg">
Your browser does not support the audio element.
</audio>
</div>
);
}
function ImageViewer({ material }: { material: ChapterMaterial }) {
const src = material.url || (material.file_url ? getContentUrl(material.id) : null);
if (!src) return <EmptyState message="No image available." />;
return (
<div className="flex items-center justify-center">
<img
src={src}
alt={material.name}
className="max-w-full max-h-[70vh] rounded-lg border object-contain"
/>
</div>
);
}
function ArticleViewer({ material }: { material: ChapterMaterial }) {
if (!material.description) return <EmptyState message="No article content available." />;
return (
<div className="prose prose-sm dark:prose-invert max-w-none p-4 rounded-lg border bg-card">
<div className="whitespace-pre-wrap">{material.description}</div>
</div>
);
}
function LinkViewer({ material }: { material: ChapterMaterial }) {
if (!material.url) return <EmptyState message="No URL available." />;
return (
<div className="space-y-4">
<div className="flex items-center gap-2 p-4 rounded-lg border bg-muted/50">
<Link2 className="h-5 w-5 text-primary shrink-0" />
<a
href={material.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline break-all flex-1"
>
{material.url}
</a>
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open
</a>
</Button>
</div>
<iframe
src={material.url}
className="w-full min-h-[500px] rounded-lg border"
title={material.name}
sandbox="allow-scripts allow-same-origin"
/>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<FileText className="h-12 w-12 mb-3 opacity-40" />
<p>{message}</p>
</div>
);
}
const viewers: Record<MaterialType, React.FC<{ material: ChapterMaterial }>> = {
pdf: PdfViewer,
document: PdfViewer,
video: VideoViewer,
audio: AudioViewer,
image: ImageViewer,
link: LinkViewer,
article: ArticleViewer,
};
export default function MaterialViewer({ material, open, onOpenChange, onDownload }: MaterialViewerProps) {
if (!material) return null;
const Viewer = viewers[material.type] ?? ArticleViewer;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-2xl lg:max-w-4xl overflow-y-auto">
<SheetHeader className="space-y-3 pb-4 border-b">
<div className="flex items-center gap-2">
{typeIcons[material.type]}
<Badge variant="outline">{typeLabels[material.type]}</Badge>
</div>
<SheetTitle className="text-lg">{material.name}</SheetTitle>
{material.description && material.type !== "article" && (
<p className="text-sm text-muted-foreground">{material.description}</p>
)}
<div className="flex gap-2">
{material.allow_download && material.file_url && onDownload && (
<Button variant="outline" size="sm" onClick={() => onDownload(material.id, material.name)}>
<Download className="h-4 w-4 mr-1" /> Download
</Button>
)}
{material.url && (
<Button variant="outline" size="sm" asChild>
<a href={material.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4 mr-1" /> Open Link
</a>
</Button>
)}
</div>
</SheetHeader>
<div className="mt-6">
<Viewer material={material} />
</div>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,60 @@
import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
className?: string;
activeClassName?: string;
pendingClassName?: string;
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, onClick, ...props }, ref) => {
const navigate = useNavigate();
const isExternalTo = (value: NavLinkProps["to"]): boolean => {
if (typeof value !== "string") return false;
return /^(https?:)?\/\//.test(value) || value.startsWith("mailto:") || value.startsWith("tel:");
};
return (
<RouterNavLink
ref={ref}
to={to}
className={({ isActive, isPending }) =>
cn(className, isActive && activeClassName, isPending && pendingClassName)
}
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.altKey ||
event.ctrlKey ||
event.shiftKey ||
props.target === "_blank" ||
isExternalTo(to)
) {
return;
}
// Force client-side route transitions for app menu links.
event.preventDefault();
navigate(to, {
replace: props.replace,
state: props.state,
relative: props.relative,
preventScrollReset: props.preventScrollReset,
viewTransition: props.viewTransition,
});
}}
{...props}
/>
);
},
);
NavLink.displayName = "NavLink";
export { NavLink };

View File

@@ -0,0 +1,51 @@
import { Bell } from "lucide-react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
export default function NotificationDropdown() {
const { t } = useTranslation();
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
const unread = notifications.filter((n) => !n.read).length;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("notifications.ariaLabel")}
title={t("notifications.ariaLabel")}
className="relative text-muted-foreground hover:text-foreground"
>
<Bell className="h-4 w-4" />
{unread > 0 && (
<span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
{unread}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
<DropdownMenuSeparator />
{notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
{t("notifications.empty")}
</div>
) : (
notifications.slice(0, 4).map((n) => (
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
</DropdownMenuItem>
))
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,64 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { usePermissions } from "@/hooks/usePermissions";
import type { UserRole } from "@/types/auth";
interface Props {
allowedRoles?: UserRole[];
requiredPermissions?: string[];
}
function getRoleDashboard(role: UserRole): string {
switch (role) {
case "student":
return "/student/dashboard";
case "teacher":
return "/teacher/dashboard";
case "admin":
case "developer":
return "/admin/dashboard";
case "corporate":
case "mastercorporate":
case "agent":
return "/admin/platform";
default:
return "/login";
}
}
export default function ProtectedRoute({ allowedRoles, requiredPermissions }: Props) {
const { user, isAuthenticated, isLoading } = useAuth();
const { hasAllPermissions, isLoading: permissionsLoading } = usePermissions();
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!isAuthenticated || !user) {
return <Navigate to="/login" replace />;
}
if (allowedRoles && !allowedRoles.includes(user.user_type)) {
return <Navigate to={getRoleDashboard(user.user_type)} replace />;
}
if (requiredPermissions && requiredPermissions.length > 0) {
if (permissionsLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
);
}
if (!hasAllPermissions(requiredPermissions)) {
return <Navigate to={getRoleDashboard(user.user_type)} replace />;
}
}
return <Outlet />;
}

View File

@@ -0,0 +1,250 @@
import { Link } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { LucideIcon, Check, ChevronRight, Sparkles } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* Smart-setup wizard primitives.
*
* The wizard is purposefully thin: each step / quick-create card is a deep
* link into an existing creation page that already knows how to talk to the
* backend. We don't re-implement forms here — the value of this screen is
* the guided sequence, the visual "what's next" hint, and the completion
* ticks that show which parts of the setup still need attention.
*
* Completion detection is optional and client-only. A caller can supply a
* `check` function that returns a Promise<boolean>; the wizard runs it via
* react-query so the UI refreshes automatically when the user returns from
* a sub-page without any extra plumbing.
*/
export interface WizardStep {
/** Stable key used for react-query cache. */
id: string;
titleKey: string;
descriptionKey: string;
/** Destination page that performs the actual creation. */
to: string;
icon: LucideIcon;
/** Optional completion check. When omitted the step is never auto-ticked. */
check?: () => Promise<boolean>;
/** Optional i18n key for a longer help tooltip. */
helpKey?: string;
}
export interface QuickCreate {
id: string;
titleKey: string;
descriptionKey: string;
to: string;
icon: LucideIcon;
}
export interface QuickSetupWizardProps {
/** Page heading i18n key, e.g. "quickSetup.adminTitle". */
titleKey: string;
/** Short lead paragraph i18n key. */
subtitleKey: string;
/** Ordered "Recommended flow" steps. */
steps: WizardStep[];
/** Side-grid of one-click creates that don't belong to the main flow. */
quickCreates: QuickCreate[];
}
export function QuickSetupWizard({
titleKey,
subtitleKey,
steps,
quickCreates,
}: QuickSetupWizardProps) {
const { t } = useTranslation();
// Run all completion checks in parallel. Each step with a `check` gets its
// own cached query keyed by the step id. Steps without a `check` just
// resolve to `undefined` and render as neutral.
const queries = useQueries({
queries: steps.map((step) => ({
queryKey: ["quick-setup-check", step.id],
queryFn: step.check ?? (async () => undefined),
enabled: Boolean(step.check),
// Re-check when the user comes back from a sub-page — that's the most
// common path to "un-greying" a step after they've created a rubric /
// structure / exam.
refetchOnWindowFocus: true,
staleTime: 30_000,
})),
});
const completedCount = queries.filter((q) => q.data === true).length;
const totalCheckable = steps.filter((s) => s.check).length;
const progress = totalCheckable > 0 ? Math.round((completedCount / totalCheckable) * 100) : 0;
return (
<TooltipProvider delayDuration={200}>
<div className="space-y-6">
{/* Heading + progress */}
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
</div>
<p className="text-muted-foreground max-w-2xl">{t(subtitleKey)}</p>
</div>
{totalCheckable > 0 && (
<div className="shrink-0 text-right">
<div className="text-sm text-muted-foreground">
{t("quickSetup.progressLabel")}
</div>
<div className="text-2xl font-semibold tabular-nums">
{completedCount}/{totalCheckable}
</div>
<div className="mt-1 h-1.5 w-32 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
</div>
{/* Recommended flow */}
<section aria-labelledby="quick-setup-flow">
<h2
id="quick-setup-flow"
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
>
{t("quickSetup.recommendedFlow")}
</h2>
<ol className="space-y-3">
{steps.map((step, index) => {
const query = queries[index];
const Icon = step.icon;
const isDone = query?.data === true;
return (
<li key={step.id}>
<Card
className={cn(
"transition-shadow hover:shadow-md",
isDone && "border-green-500/40 bg-green-500/5",
)}
>
<CardContent className="flex items-center gap-4 p-4">
{/* Step number / done tick */}
<div
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full font-semibold",
isDone
? "bg-green-500 text-white"
: "bg-primary/10 text-primary",
)}
aria-hidden
>
{isDone ? <Check className="h-5 w-5" /> : index + 1}
</div>
{/* Title + description */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<h3 className="font-medium">{t(step.titleKey)}</h3>
{isDone && (
<Badge variant="outline" className="border-green-500/50 text-green-600 text-[10px]">
{t("quickSetup.ready")}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">
{t(step.descriptionKey)}
</p>
</div>
{/* CTA */}
<div className="flex items-center gap-2 shrink-0">
{step.helpKey && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
aria-label={t("quickSetup.helpAria")}
>
?
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs">
{t(step.helpKey)}
</TooltipContent>
</Tooltip>
)}
<Button asChild size="sm" variant={isDone ? "outline" : "default"}>
<Link to={step.to} className="flex items-center gap-1">
{isDone ? t("quickSetup.review") : t("quickSetup.start")}
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</Button>
</div>
</CardContent>
</Card>
</li>
);
})}
</ol>
</section>
{/* Quick creates */}
{quickCreates.length > 0 && (
<section aria-labelledby="quick-setup-other" className="pt-2">
<h2
id="quick-setup-other"
className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3"
>
{t("quickSetup.otherQuickCreates")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{quickCreates.map((item) => {
const Icon = item.icon;
return (
<Card key={item.id} className="transition-all hover:shadow-md hover:-translate-y-0.5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<Icon className="h-4 w-4" />
</div>
<CardTitle className="text-base">{t(item.titleKey)}</CardTitle>
</div>
<CardDescription className="line-clamp-2">
{t(item.descriptionKey)}
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild variant="secondary" size="sm" className="w-full">
<Link to={item.to} className="flex items-center justify-center gap-1">
{t("quickSetup.open")}
<ChevronRight className="h-3.5 w-3.5" />
</Link>
</Button>
</CardContent>
</Card>
);
})}
</div>
</section>
)}
</div>
</TooltipProvider>
);
}
export default QuickSetupWizard;

View File

@@ -0,0 +1,189 @@
import { Outlet, useNavigate } from "react-router-dom";
import { Suspense } from "react";
import { useTranslation } from "react-i18next";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
} from "@/components/ui/sidebar";
import { NavLink } from "@/components/NavLink";
import { useAuth, UserRole } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import { Button } from "@/components/ui/button";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { LogOut, User, LucideIcon } from "lucide-react";
/**
* Non-admin portal shell (student / teacher / etc.). Nav items hold i18n
* keys (`titleKey` / `labelKey`) rather than literal strings so they react
* to language changes without re-mounting the layout.
*/
export interface NavItem {
titleKey: string;
url: string;
icon: LucideIcon;
}
export interface NavGroup {
labelKey: string;
items: NavItem[];
}
interface RoleLayoutProps {
navGroups: NavGroup[];
role: UserRole;
}
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<>
{navGroups.map((group) => (
<SidebarGroup key={group.labelKey}>
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
end={item.url.endsWith("/dashboard")}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</>
);
}
// Keeps the student/teacher sidebar + header mounted while the lazy route
// chunk is fetching. See AdminLmsLayout for the rationale.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
const handleLogout = async () => {
await logout();
navigate("/login");
};
const roleLabel = t(`roles.${role}`, { defaultValue: role });
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img
src="/logo.png"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
/>
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/>
</div>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNav navGroups={navGroups} />
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-semibold text-primary">{initials}</span>
</div>
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
<span className="text-sm font-medium truncate text-sidebar-foreground">
{user?.name ?? t("userMenu.userFallback")}
</span>
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
</div>
</div>
</SidebarFooter>
</Sidebar>
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3">
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
</div>
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-xs font-semibold text-primary-foreground">{initials}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,63 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react";
/**
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
* resolves via `useTranslation`, so switching language does not require
* re-mounting the layout.
*/
const navGroups: NavGroup[] = [
{
labelKey: "sidebarGroup.learning",
items: [
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
],
},
{
labelKey: "sidebarGroup.adaptiveLearning",
items: [
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
],
},
{
labelKey: "sidebarGroup.progress",
items: [
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
],
},
{
labelKey: "sidebarGroup.communication",
items: [
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
],
},
{
labelKey: "sidebarGroup.account",
items: [
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
],
},
];
export default function StudentLayout() {
return (
<>
<RoleLayout navGroups={navGroups} role="student" />
<ExamPopup />
</>
);
}

View File

@@ -0,0 +1,143 @@
import { useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { useQuery } from "@tanstack/react-query";
import { taxonomyService } from "@/services/taxonomy.service";
import { lmsService } from "@/services/lms.service";
interface TaxonomyCascadeProps {
subjectId: string;
onSubjectChange: (id: string) => void;
domainId: string;
onDomainChange: (id: string) => void;
topicIds: number[];
onTopicIdsChange: (ids: number[]) => void;
objectiveIds: number[];
onObjectiveIdsChange: (ids: number[]) => void;
}
export function TaxonomyCascade({
subjectId, onSubjectChange,
domainId, onDomainChange,
topicIds, onTopicIdsChange,
objectiveIds, onObjectiveIdsChange,
}: TaxonomyCascadeProps) {
const { data: subjects } = useQuery({
queryKey: ["taxonomy", "subjects"],
queryFn: () => taxonomyService.listSubjects(),
});
const numericSubjectId = subjectId !== "none" ? Number(subjectId) : undefined;
const numericDomainId = domainId !== "none" ? Number(domainId) : undefined;
const { data: domains } = useQuery({
queryKey: ["taxonomy", "domains", numericSubjectId],
queryFn: () => taxonomyService.listDomains({ subject_id: numericSubjectId }),
enabled: !!numericSubjectId,
});
const { data: topics } = useQuery({
queryKey: ["taxonomy", "topics", numericDomainId],
queryFn: () => taxonomyService.listTopics({ domain_id: numericDomainId }),
enabled: !!numericDomainId,
});
const { data: objectivesData } = useQuery({
queryKey: ["learning-objectives", topicIds],
queryFn: () => lmsService.listLearningObjectives({ topic_ids: topicIds.join(",") }),
enabled: topicIds.length > 0,
});
const objectives = objectivesData?.items ?? [];
useEffect(() => {
if (subjectId === "none") {
if (domainId !== "none") onDomainChange("none");
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [subjectId]);
useEffect(() => {
if (domainId === "none") {
if (topicIds.length > 0) onTopicIdsChange([]);
if (objectiveIds.length > 0) onObjectiveIdsChange([]);
}
}, [domainId]);
const toggleTopic = (id: number) => {
const next = topicIds.includes(id) ? topicIds.filter(t => t !== id) : [...topicIds, id];
onTopicIdsChange(next);
if (next.length === 0 && objectiveIds.length > 0) onObjectiveIdsChange([]);
};
const toggleObjective = (id: number) => {
onObjectiveIdsChange(
objectiveIds.includes(id) ? objectiveIds.filter(o => o !== id) : [...objectiveIds, id],
);
};
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Subject</Label>
<Select value={subjectId} onValueChange={(v) => { onSubjectChange(v); if (v === "none") { onDomainChange("none"); } }}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="Select subject" /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(subjects ?? []).map(s => <SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Domain</Label>
<Select value={domainId} onValueChange={onDomainChange} disabled={!numericSubjectId}>
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder={numericSubjectId ? "Select domain" : "Select subject first"} /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{(domains ?? []).map(d => <SelectItem key={d.id} value={String(d.id)}>{d.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{numericDomainId && (topics ?? []).length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Topics</Label>
<div className="flex flex-wrap gap-1.5 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{(topics ?? []).map(t => (
<Badge
key={t.id}
variant={topicIds.includes(t.id) ? "default" : "outline"}
className="cursor-pointer select-none text-xs"
onClick={() => toggleTopic(t.id)}
>
{t.name}
</Badge>
))}
</div>
</div>
)}
{topicIds.length > 0 && objectives.length > 0 && (
<div className="space-y-1.5">
<Label className="text-xs">Learning Objectives</Label>
<div className="space-y-1 p-2 rounded border bg-muted/30 max-h-[100px] overflow-y-auto">
{objectives.map(o => (
<label key={o.id} className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={objectiveIds.includes(o.id)}
onCheckedChange={() => toggleObjective(o.id)}
/>
<span>{o.name}</span>
{o.bloom_level && <Badge variant="outline" className="text-[10px] h-4">{o.bloom_level}</Badge>}
</label>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,45 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Library, Sparkles,
} from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
const navGroups: NavGroup[] = [
{
labelKey: "sidebarGroup.teaching",
items: [
{ titleKey: "nav.quickSetup", url: "/teacher/quick-setup", icon: Sparkles },
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
],
},
{
labelKey: "sidebarGroup.management",
items: [
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
],
},
{
labelKey: "sidebarGroup.communication",
items: [
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
],
},
{
labelKey: "sidebarGroup.account",
items: [
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
],
},
];
export default function TeacherLayout() {
return <RoleLayout navGroups={navGroups} role="teacher" />;
}

View File

@@ -0,0 +1,71 @@
import { Moon, Sun, Monitor } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
/**
* Light/dark/system theme toggle.
*
* We intentionally render a neutral placeholder until `next-themes` has
* hydrated — otherwise the first client render disagrees with the server-less
* pre-render and the icon briefly flashes the wrong state.
*/
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const { t } = useTranslation();
useEffect(() => {
setMounted(true);
}, []);
const isDark = mounted && (resolvedTheme ?? theme) === "dark";
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("theme.toggleLabel")}
title={t("theme.toggleLabel")}
className="text-muted-foreground hover:text-foreground"
>
{mounted ? (
isDark ? (
<Moon className="h-4 w-4" />
) : (
<Sun className="h-4 w-4" />
)
) : (
<Sun className="h-4 w-4 opacity-0" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground"></span>}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default ThemeToggle;

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
const [errorDismissed, setErrorDismissed] = useState(false);
const { t } = useTranslation();
const { data: resp, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"],
queryFn: () => analyticsService.getAlerts(),
});
const alerts = resp?.alerts ?? [];
const visible = alerts.filter((a, i) => !dismissedIds.has(String(i)));
if (isLoading) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
</div>
);
}
if (isError && !errorDismissed) {
return (
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
</p>
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
</div>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
<X className="h-4 w-4" />
</Button>
</div>
);
}
if (isError && errorDismissed) return null;
if (!alerts.length) {
return (
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
</div>
);
}
if (!visible.length) return null;
return (
<div className="space-y-3">
{visible.map((alert, idx) => (
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium flex items-center gap-1">
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
</p>
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState("");
const location = useLocation();
const { toast } = useToast();
const { t } = useTranslation();
// Re-compute when language switches so quick-action chips show translated
// labels immediately. Chips are stored by label (what we send to the
// backend), so localizing the label is safe here — the backend treats
// them as free-form prompts.
const quickActions = useMemo(
() => [
t("ai.quickHealth"),
t("ai.quickDropouts"),
t("ai.quickCompare"),
t("ai.quickBatch"),
],
[t],
);
const chatMutation = useMutation({
mutationFn: (message: string) =>
coachingService.chat({ message, context: { page: location.pathname } }),
onSuccess: (data) => {
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: t("ai.replyErrorTitle"),
description: err.message || t("ai.replyErrorDesc"),
});
setMessages((prev) => [
...prev,
{ role: "ai", text: t("ai.fallbackReply") },
]);
},
});
const handleSend = (text: string) => {
if (!text.trim() || chatMutation.isPending) return;
const userMsg = text.trim();
setMessages((prev) => [...prev, { role: "user", text: userMsg }]);
setInput("");
chatMutation.mutate(userMsg);
};
return (
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label={t("ai.assistantLabel")}
title={t("ai.assistantLabel")}
>
<Sparkles className="h-5 w-5" />
</button>
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent className="w-[400px] sm:w-[440px] flex flex-col">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
{t("ai.assistantTitle")}
</SheetTitle>
</SheetHeader>
<div className="flex flex-wrap gap-2 mt-4">
{quickActions.map((action) => (
<Button
key={action}
variant="outline"
size="sm"
className="text-xs"
disabled={chatMutation.isPending}
onClick={() => handleSend(action)}
>
{action}
</Button>
))}
</div>
<div className="flex-1 overflow-y-auto mt-4 space-y-3 min-h-0">
{messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>{t("ai.emptyLine1")}</p>
<p>{t("ai.emptyLine2")}</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground ms-8"
: "bg-muted me-8"
}`}
>
{msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
)}
{msg.text}
</div>
))}
{chatMutation.isPending && (
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground">{t("ai.thinking")}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t mt-auto">
<Input
placeholder={t("ai.placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
/>
<Button
size="icon"
onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending}
aria-label={t("ai.send")}
title={t("ai.send")}
>
<Send className="h-4 w-4" />
</Button>
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,126 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Sparkles, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { analyticsService } from "@/services/analytics.service";
interface Props {
batchId?: number;
}
export default function AiBatchOptimizer({ batchId }: Props) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const mutation = useMutation({
mutationFn: (id: number) => analyticsService.getBatchOptimization(id),
onError: (err: Error) => {
toast({
title: "Optimization failed",
description: err.message || "Could not analyze this batch.",
variant: "destructive",
});
},
});
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
const handleOpen = () => {
if (batchId == null) {
toast({
title: "No batch selected",
description: "Choose a batch to analyze, or open this from a batch detail page.",
variant: "destructive",
});
return;
}
mutation.reset();
setOpen(true);
mutation.mutate(batchId);
};
const applyMutation = useMutation({
mutationFn: () => analyticsService.applyBatchOptimization(batchId!, mutation.data?.optimized ?? []),
onSuccess: (res) => {
toast({ title: "Suggestion Applied", description: `${res.applied} optimization(s) saved successfully.` });
setOpen(false);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Apply failed",
description: err.message || "Could not apply batch optimization.",
});
},
});
const handleApply = () => {
applyMutation.mutate();
};
const onOpenChange = (next: boolean) => {
setOpen(next);
if (!next) mutation.reset();
};
const optData = mutation.data as OptResult | undefined;
const hasSuggestions = !!optData?.summary;
const showResults = !mutation.isPending && !mutation.isError && hasSuggestions;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && !hasSuggestions;
return (
<>
<Button variant="outline" size="sm" onClick={handleOpen}>
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Suggest Batch Split
</Button>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Batch Optimization
</DialogTitle>
</DialogHeader>
{mutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing batch data...
</div>
) : mutation.isError ? (
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
) : showResults && optData ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted/30 p-4 border border-border/60">
<p className="text-xs font-semibold text-primary uppercase tracking-wide mb-1">{optData.impact} impact</p>
<p className="text-sm font-medium">{optData.summary}</p>
</div>
{Array.isArray(optData.optimized) && optData.optimized.length > 0 && (
<div className="space-y-2 max-h-[40vh] overflow-y-auto">
{optData.optimized.map((item, i) => (
<div key={i} className="rounded-lg bg-muted/20 p-3 border text-sm">
{typeof item === "object" && item !== null ? JSON.stringify(item) : String(item)}
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button className="flex-1" onClick={handleApply} disabled={applyMutation.isPending}>
{applyMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Applying...</>
) : (
"Apply Suggestion"
)}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Dismiss
</Button>
</div>
</div>
) : showEmpty ? (
<p className="text-sm text-muted-foreground py-4 text-center">No optimization suggestions for this batch.</p>
) : null}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,215 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sparkles, Loader2 } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { lmsService } from "@/services/lms.service";
interface Props {
type: "course" | "assignment" | "batch" | "rubric" | "exam" | "student" | "teacher";
trigger?: React.ReactNode;
onGenerated?: (data: any) => void;
}
const typeLabels: Record<string, string> = {
course: "Course", assignment: "Assignment", batch: "Batch", rubric: "Rubric",
exam: "Exam", student: "Student Plan", teacher: "Teacher Assignment",
};
export default function AiCreationAssistant({ type, trigger, onGenerated }: Props) {
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [level, setLevel] = useState("b2");
const [subjectId, setSubjectId] = useState("");
const { toast } = useToast();
const generateCourseMutation = useMutation({
mutationFn: (payload: { title: string; subject_id?: number; level?: string }) =>
lmsService.aiGenerateCourse(payload),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Generation failed",
description: err.message || "Could not generate course outline.",
});
},
});
const handleGenerate = () => {
if (type !== "course") {
toast({
variant: "destructive",
title: "Not available",
description: "Live AI generation is wired for courses only. Use the course type to generate an outline.",
});
return;
}
const title = prompt.trim() || "Untitled course";
const sid = subjectId.trim() ? Number(subjectId) : undefined;
generateCourseMutation.mutate({
title,
...(sid !== undefined && !Number.isNaN(sid) ? { subject_id: sid } : {}),
level,
});
};
const handleApply = () => {
if (!generateCourseMutation.data) return;
onGenerated?.(generateCourseMutation.data);
toast({
title: `AI ${typeLabels[type]} Applied`,
description: `The AI-generated ${type} has been applied to your form.`,
});
setOpen(false);
generateCourseMutation.reset();
};
return (
<>
{trigger ? (
<div onClick={() => setOpen(true)}>{trigger}</div>
) : (
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> AI Generate {typeLabels[type]}
</Button>
)}
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) generateCourseMutation.reset();
}}
>
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI {typeLabels[type]} Generator
</DialogTitle>
</DialogHeader>
{type === "course" && generateCourseMutation.isSuccess ? (
<div className="space-y-4">
<div className="rounded-lg bg-muted/30 p-4 space-y-3">
<p className="text-xs font-semibold text-primary flex items-center gap-1">
<Sparkles className="h-3 w-3" /> AI Generated {typeLabels[type]}
</p>
<div>
<span className="text-xs text-muted-foreground">Outline</span>
<pre className="text-sm mt-1 whitespace-pre-wrap break-words rounded-md border bg-background p-3 max-h-[40vh] overflow-y-auto">
{typeof generateCourseMutation.data?.outline === "string"
? generateCourseMutation.data.outline
: JSON.stringify(generateCourseMutation.data?.outline, null, 2)}
</pre>
</div>
</div>
<div className="flex gap-2">
<Button className="flex-1" onClick={handleApply}>
Apply to Form
</Button>
<Button variant="outline" onClick={() => generateCourseMutation.reset()}>
Regenerate
</Button>
</div>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label>Describe what you need</Label>
<Textarea
placeholder={`Describe the ${type} you want to create... (e.g. "An intermediate IELTS writing course focusing on Task 2 essays")`}
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
{type === "course" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Level</Label>
<Select value={level} onValueChange={setLevel}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subject ID (optional)</Label>
<Input
inputMode="numeric"
placeholder="e.g. 12"
value={subjectId}
onChange={(e) => setSubjectId(e.target.value)}
/>
</div>
</div>
)}
{(type === "assignment" || type === "exam") && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Level</Label>
<Select defaultValue="b2">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Focus Area</Label>
<Select defaultValue="general">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="general">General</SelectItem>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
<Button
className="w-full"
onClick={handleGenerate}
disabled={generateCourseMutation.isPending}
>
{generateCourseMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" /> Generate with AI
</>
)}
</Button>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,233 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Sparkles, Loader2, Trash2 } from "lucide-react";
import type { ExamModule } from "@/types";
import { generationService } from "@/services/generation.service";
import { useToast } from "@/hooks/use-toast";
type ExerciseRow = { title?: string; description?: string; marks?: number };
function exerciseLabel(ex: unknown, index: number): ExerciseRow {
if (ex && typeof ex === "object") {
const o = ex as Record<string, unknown>;
return {
title: typeof o.title === "string" ? o.title : `Exercise ${index + 1}`,
description: typeof o.description === "string" ? o.description : undefined,
marks: typeof o.marks === "number" ? o.marks : typeof o.marks === "string" ? Number(o.marks) : undefined,
};
}
return { title: String(ex) };
}
export default function AiGeneratorModal() {
const [open, setOpen] = useState(false);
const [moduleType, setModuleType] = useState<ExamModule>("reading");
const [difficulty, setDifficulty] = useState("b2");
const [count, setCount] = useState(5);
const [topic, setTopic] = useState("");
const [localExercises, setLocalExercises] = useState<unknown[] | null>(null);
const { toast } = useToast();
const generateMutation = useMutation({
mutationFn: () =>
generationService.generate(moduleType, {
title: topic.trim() || `${moduleType} practice set`,
difficulty,
count,
}),
onSuccess: (res: Record<string, unknown>) => {
const items = Array.isArray(res.questions) ? res.questions : Array.isArray(res.exercises) ? res.exercises : [];
setLocalExercises(items);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Generation failed",
description: err.message || "Could not generate exercises.",
});
},
});
const handleGenerate = () => {
setLocalExercises(null);
generateMutation.mutate();
};
const saveMutation = useMutation({
mutationFn: () => generationService.saveGenerated(moduleType, localExercises ?? []),
onSuccess: (res) => {
toast({ title: "Saved", description: `${res.saved} assignments saved successfully.` });
setOpen(false);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Save failed",
description: err.message || "Could not save generated assignments.",
});
},
});
const generated = localExercises;
const handleRemove = (index: number) => {
setLocalExercises((prev) => (prev ? prev.filter((_, i) => i !== index) : null));
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) {
setLocalExercises(null);
generateMutation.reset();
}
}}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Sparkles className="h-3.5 w-3.5 mr-1 text-primary" /> Generate with AI
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Assignment Generator
</DialogTitle>
</DialogHeader>
{!generated ? (
<div className="space-y-4">
<div className="space-y-2">
<Label>Module Type</Label>
<Select
value={moduleType}
onValueChange={(v) => setModuleType(v as ExamModule)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reading">Reading</SelectItem>
<SelectItem value="listening">Listening</SelectItem>
<SelectItem value="writing">Writing</SelectItem>
<SelectItem value="speaking">Speaking</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Difficulty</Label>
<Select value={difficulty} onValueChange={setDifficulty}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{["A1", "A2", "B1", "B2", "C1", "C2"].map((l) => (
<SelectItem key={l} value={l.toLowerCase()}>
{l}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Count</Label>
<Input
type="number"
value={count}
min={1}
max={10}
onChange={(e) => setCount(Number(e.target.value) || 1)}
/>
</div>
</div>
<div className="space-y-2">
<Label>Topic / Focus Area</Label>
<Input
placeholder="e.g. Academic reading comprehension"
value={topic}
onChange={(e) => setTopic(e.target.value)}
/>
</div>
<Button
className="w-full"
onClick={handleGenerate}
disabled={generateMutation.isPending}
>
{generateMutation.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Generating...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" /> Generate Assignments
</>
)}
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
AI generated {generated.length} assignments. Edit or remove before saving.
</p>
{generated.map((item, i) => {
const row = exerciseLabel(item, i);
return (
<div key={i} className="rounded-lg border p-3 space-y-1">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium">{row.title}</p>
{row.description && (
<p className="text-xs text-muted-foreground mt-1">{row.description}</p>
)}
{row.marks !== undefined && !Number.isNaN(row.marks) && (
<p className="text-xs font-semibold mt-1">{row.marks} marks</p>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => handleRemove(i)}
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</div>
);
})}
<div className="flex gap-2">
<Button
className="flex-1"
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !generated?.length}
>
{saveMutation.isPending ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" /> Saving...</>
) : (
"Save All"
)}
</Button>
<Button
variant="outline"
onClick={() => {
setLocalExercises(null);
generateMutation.reset();
}}
>
Regenerate
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Sparkles, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
export default function AiGradeExplainer({
studentName,
scores,
}: {
studentName: string;
scores?: Record<string, number>;
}) {
const [open, setOpen] = useState(false);
const { toast } = useToast();
const explainMutation = useMutation({
mutationFn: () =>
coachingService.explain({
score_data: scores ?? {},
student_context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
}),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not explain grades",
description: err.message || "Try again in a moment.",
});
},
});
const handleOpen = () => {
setOpen(true);
explainMutation.reset();
explainMutation.mutate();
};
return (
<>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={handleOpen} title="AI Explain Grade">
<Sparkles className="h-3.5 w-3.5 text-primary" />
</Button>
<Dialog
open={open}
onOpenChange={(v) => {
setOpen(v);
if (!v) explainMutation.reset();
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Grade Explanation {studentName}
</DialogTitle>
</DialogHeader>
{explainMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-6 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing grades...
</div>
) : explainMutation.isError ? (
<p className="text-sm text-destructive text-center py-4">
Something went wrong. Close and try again.
</p>
) : (
<div className="rounded-lg bg-muted/30 p-4">
<p className="text-sm leading-relaxed">
{explainMutation.data?.explanation}
</p>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,99 @@
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Sparkles, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
interface Props {
onAccept: (marks: number, feedback: string) => void;
submissionId?: number;
submissionText?: string;
rubricId?: number;
}
const DEFAULT_TEXT =
"Sample submission for AI grading suggestion. Replace by passing submissionText when integrating with real submissions.";
export default function AiGradingAssistant({
onAccept,
submissionId = 1,
submissionText = DEFAULT_TEXT,
rubricId,
}: Props) {
const { toast } = useToast();
const gradeMutation = useMutation({
mutationFn: () =>
analyticsService.getGradingSuggestion({
submission_text: submissionText,
skill: "writing",
}),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not load AI grade",
description: err.message || "Try again later.",
});
},
});
useEffect(() => {
gradeMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when inputs change
}, [submissionId, submissionText, rubricId]);
const data = gradeMutation.data;
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
const feedbackBlock = data
? [
data.feedback,
data.suggestions?.length
? `Suggestions:\n${data.suggestions.map((s) => `${s}`).join("\n")}`
: "",
]
.filter(Boolean)
.join("\n\n")
: "";
return (
<div className="rounded-lg border bg-muted/30 p-4 space-y-3">
<p className="text-xs font-semibold text-primary flex items-center gap-1">
<Sparkles className="h-3 w-3" /> AI Suggested Grade
</p>
{gradeMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> Analyzing submission...
</div>
) : gradeMutation.isError ? (
<p className="text-sm text-destructive">Could not load a suggestion. Check the console or try again.</p>
) : data ? (
<>
<div>
<p className="text-sm text-muted-foreground">Suggested marks</p>
<p className="text-2xl font-bold">{marks}/100</p>
</div>
<div>
<p className="text-sm text-muted-foreground mb-1">Suggested feedback</p>
<p className="text-sm whitespace-pre-wrap">{feedbackBlock}</p>
</div>
{data.scores && Object.keys(data.scores).length > 0 && (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">Rubric scores</p>
<ul className="text-xs text-muted-foreground space-y-0.5">
{Object.entries(data.scores).map(([k, v]) => (
<li key={k}>
{k}: {v}
</li>
))}
</ul>
</div>
)}
<Button size="sm" onClick={() => onAccept(marks, feedbackBlock)}>
<Sparkles className="h-3.5 w-3.5 mr-1" /> Accept AI Grade
</Button>
</>
) : null}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
const EMPTY_PAYLOAD: Record<string, unknown> = {};
function insightIcon(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return AlertTriangle;
case "warning":
return TrendingUp;
default:
return Info;
}
}
function insightColor(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return "text-destructive";
case "warning":
return "text-warning";
default:
return "text-primary";
}
}
interface Props {
data?: Record<string, unknown>;
}
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const { toast } = useToast();
const { t } = useTranslation();
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
onError: (err: Error) => {
toast({
title: t("ai.insightsUnavailable"),
description: err.message || t("ai.couldNotLoadInsights"),
variant: "destructive",
});
},
});
useEffect(() => {
mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [payloadKey]);
const items = mutation.data?.insights ?? [];
return (
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("ai.platformInsightsTitle")}
</CardTitle>
</CardHeader>
<CardContent>
{mutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
{t("ai.loadingInsights")}
</div>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
)}
{mutation.isSuccess && items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
)}
{!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{items.map((item, idx) => {
const Icon = insightIcon(item.severity);
const color = insightColor(item.severity);
return (
<div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
<div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
<span className="text-sm font-semibold min-w-0">{item.title}</span>
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
{item.recommendation && (
<p className="text-xs text-muted-foreground mt-2 italic">
{item.recommendation}
</p>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { Sparkles, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
interface Props {
report_type: string;
data: Record<string, unknown>;
}
export default function AiReportNarrative({ report_type, data }: Props) {
const { toast } = useToast();
const dataKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({
mutationFn: (vars: { report_type: string; data: Record<string, unknown> }) =>
analyticsService.getReportNarrative(vars),
onError: (err: Error) => {
toast({
title: "Summary unavailable",
description: err.message || "Could not generate AI summary.",
variant: "destructive",
});
},
});
useEffect(() => {
mutation.mutate({ report_type, data });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [report_type, dataKey]);
return (
<div className="rounded-lg border bg-primary/5 p-4 mb-4 flex items-start gap-3">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="text-xs font-semibold text-primary">AI Summary</span>
{mutation.isPending && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
Generating summary
</p>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground mt-1">Could not load summary.</p>
)}
{!mutation.isPending && mutation.data?.narrative && (
<p className="text-sm text-muted-foreground mt-1">{mutation.data.narrative}</p>
)}
{mutation.isSuccess && !mutation.data?.narrative?.trim() && (
<p className="text-sm text-muted-foreground mt-1">No summary returned.</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export type AiRiskLevel = "At Risk" | "Monitor" | "On Track";
interface Props {
riskLevel?: AiRiskLevel;
reasons?: string[];
}
export default function AiRiskBadge({ riskLevel, reasons }: Props) {
if (!riskLevel) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs cursor-help">
Unknown
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-[200px]">Risk data not available from profile.</p>
</TooltipContent>
</Tooltip>
);
}
const variant: "destructive" | "secondary" | "default" =
riskLevel === "At Risk" ? "destructive" : riskLevel === "Monitor" ? "secondary" : "default";
const tooltipText =
reasons?.filter(Boolean).length ? reasons!.filter(Boolean).join(" · ") : "No additional details provided.";
return (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={variant} className="text-xs cursor-help">
{riskLevel}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs max-w-[200px]">{tooltipText}</p>
</TooltipContent>
</Tooltip>
);
}

View File

@@ -0,0 +1,111 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Sparkles, Search, Loader2, X } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { analyticsService } from "@/services/analytics.service";
import { useToast } from "@/hooks/use-toast";
export default function AiSearchBar() {
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation();
const searchMutation = useMutation({
mutationFn: (q: string) => analyticsService.search(q),
onError: (err: Error) => {
toast({
variant: "destructive",
title: t("chrome.aiSearchFailedTitle"),
description: err.message || t("chrome.aiSearchFailedDesc"),
});
},
});
const handleSearch = () => {
if (!query.trim() || searchMutation.isPending) return;
searchMutation.mutate(query.trim());
};
const result = searchMutation.data;
return (
<div className="relative max-w-md w-full">
<div className="relative">
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("chrome.aiSearchPlaceholder")}
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
value={query}
onChange={(e) => {
setQuery(e.target.value);
searchMutation.reset();
}}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
{query && (
<button
onClick={() => {
setQuery("");
searchMutation.reset();
}}
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{(searchMutation.isPending || result !== undefined) && (
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{searchMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
{t("chrome.aiSearching")}
</div>
) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
<div className="flex items-start gap-2 pb-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p className="text-muted-foreground">{result.answer}</p>
</div>
{result.suggestions?.length > 0 && (
<div className="border-t pt-2 space-y-1">
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
{result.suggestions.map((s, i) => (
<button
key={i}
type="button"
className="block text-xs text-primary hover:underline"
onClick={() => { setQuery(s); searchMutation.mutate(s); }}
>
{s}
</button>
))}
</div>
)}
{result.related_actions?.map((a, i) => (
<button
key={i}
type="button"
className="text-xs text-primary hover:underline"
onClick={() => navigate(a.action)}
>
{a.label}
</button>
))}
</div>
) : (
<div className="text-sm flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p>{t("chrome.aiNoResults", { q: query })}</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,105 @@
import { useEffect } from "react";
import { useMutation } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Sparkles, RefreshCw, Loader2, Lightbulb } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
export default function AiStudyCoach() {
const { toast } = useToast();
const suggestMutation = useMutation({
mutationFn: () => coachingService.suggest(),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not load coach tips",
description: err.message || "Try refreshing in a moment.",
});
},
});
useEffect(() => {
suggestMutation.mutate();
// eslint-disable-next-line react-hooks/exhaustive-deps -- load once on mount
}, []);
const refresh = () => {
suggestMutation.mutate();
};
const d = suggestMutation.data;
const suggestions = d ? [d.suggestion, ...(d.focus_areas ?? []).map((a: string) => `Focus area: ${a}`)].filter(Boolean) : [];
const planTips = d?.daily_plan?.length
? d.daily_plan.map((p: { activity: string; duration_min: number; skill: string }) => `${p.activity} (${p.duration_min}min — ${p.skill})`)
: d?.motivation ? [d.motivation] : [];
return (
<Card className="border-0 shadow-sm bg-primary/5">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
Your AI Study Coach
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={refresh}
disabled={suggestMutation.isPending}
>
<RefreshCw className={`h-4 w-4 ${suggestMutation.isPending ? "animate-spin" : ""}`} />
</Button>
</div>
</CardHeader>
<CardContent>
{suggestMutation.isPending && !suggestMutation.data ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-4 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" /> Analyzing your performance...
</div>
) : (
<>
{suggestions.length > 0 && (
<div className="mb-4">
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
<Lightbulb className="h-3.5 w-3.5" /> Suggestions
</p>
<ul className="text-sm text-muted-foreground space-y-2 list-disc list-inside">
{suggestions.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
</div>
)}
{planTips.length > 0 && (
<div>
<p className="text-xs font-semibold text-primary mb-2 flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" /> Study plan tips
</p>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{planTips.map((tip, i) => (
<div
key={i}
className="rounded-lg border bg-card p-3 hover:shadow-sm transition-shadow"
>
<p className="text-sm">{tip}</p>
</div>
))}
</div>
</div>
)}
{!suggestMutation.isPending &&
suggestions.length === 0 &&
planTips.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No suggestions yet. Try refreshing.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,92 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Sparkles, X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { coachingService } from "@/services/coaching.service";
interface Props {
context?: string;
variant?: "tip" | "insight" | "recommendation";
dismissible?: boolean;
}
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
const [dismissed, setDismissed] = useState(false);
const { t } = useTranslation();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ai", "tip", context],
queryFn: () => coachingService.getTip(context),
});
if (dismissed) return null;
const bgClass = variant === "recommendation" ? "bg-accent/50 border-accent" : variant === "insight" ? "bg-primary/5 border-primary/20" : "bg-muted/50 border-muted-foreground/10";
const variantLabel =
variant === "insight"
? t("ai.insightLabel")
: variant === "recommendation"
? t("ai.recommendationLabel")
: t("ai.tipLabel");
if (isLoading) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
</div>
);
}
if (isError || !data) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
<p className="text-sm text-muted-foreground mt-0.5">
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
</p>
</div>
{dismissible && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}
if (!data.tip?.trim()) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
</div>
</div>
);
}
const label = data.category && data.category !== "general"
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
: variantLabel;
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<span className="block text-xs font-semibold text-primary">{label}</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
</div>
{dismissible && (
<Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" onClick={() => setDismissed(true)}>
<X className="h-3 w-3" />
</Button>
)}
</div>
);
}

View File

@@ -0,0 +1,141 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Sparkles, Loader2, PenLine, CheckCircle, BarChart3, ChevronDown } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
type Mode = "improve" | "grammar" | "band" | null;
interface Props {
text: string;
task_type?: string;
}
export default function AiWritingHelper({ text, task_type = "ielts_writing" }: Props) {
const [open, setOpen] = useState(false);
const [activeMode, setActiveMode] = useState<Mode>(null);
const [showResult, setShowResult] = useState(false);
const { toast } = useToast();
const mutation = useMutation({
mutationFn: (mode: NonNullable<Mode>) =>
coachingService.writingHelp({
task: task_type,
draft: text.trim(),
help_type: mode,
}),
onSuccess: () => setShowResult(true),
onError: (err: Error) => {
toast({
title: "Writing help failed",
description: err.message || "Could not analyze your writing. Try again.",
variant: "destructive",
});
},
});
const handleAction = (mode: Mode) => {
if (!mode) return;
if (!text.trim()) {
toast({
title: "Add some text first",
description: "Enter your draft in the text area so AI can analyze it.",
variant: "destructive",
});
return;
}
setActiveMode(mode);
setShowResult(false);
mutation.mutate(mode);
};
const loading = mutation.isPending;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between mt-3">
<span className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-primary" />
AI Writing Helper
</span>
<ChevronDown className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => handleAction("improve")} disabled={loading}>
<PenLine className="h-3.5 w-3.5 mr-1" /> Improve my draft
</Button>
<Button variant="outline" size="sm" onClick={() => handleAction("grammar")} disabled={loading}>
<CheckCircle className="h-3.5 w-3.5 mr-1" /> Check grammar
</Button>
<Button variant="outline" size="sm" onClick={() => handleAction("band")} disabled={loading}>
<BarChart3 className="h-3.5 w-3.5 mr-1" /> Estimate band score
</Button>
</div>
{loading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground rounded-lg bg-muted/50 p-3">
<Loader2 className="h-4 w-4 animate-spin text-primary" /> AI is analyzing your writing...
</div>
)}
{showResult && !loading && mutation.data && activeMode === "improve" && (
<div className="space-y-3">
{mutation.data.tips?.length > 0 && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Feedback
</p>
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
</div>
)}
{mutation.data.improved_text && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Improved Version
</p>
<p className="text-sm">{mutation.data.improved_text}</p>
</div>
)}
</div>
)}
{showResult && !loading && mutation.data && activeMode === "grammar" && (
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Grammar notes
</p>
{(mutation.data.changes?.length ?? 0) > 0 ? (
mutation.data.changes.map((c, i) => (
<div key={i} className="text-sm border-l-2 border-warning pl-2">
<p className="text-muted-foreground"><strong>{c.original}</strong> {c.revised} {c.reason}</p>
</div>
))
) : (
<p className="text-sm text-muted-foreground">No grammar issues flagged.</p>
)}
{mutation.data.tips?.length > 0 ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
) : null}
</div>
)}
{showResult && !loading && mutation.data && activeMode === "band" && (
<div className="rounded-lg border bg-muted/30 p-3">
<p className="text-xs font-semibold text-primary mb-1 flex items-center gap-1">
<Sparkles className="h-3 w-3" /> Estimated band / assessment
</p>
<p className="text-sm text-muted-foreground">{mutation.data.tips?.join(" ") ?? ""}</p>
{mutation.data.improved_text ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved_text}</p>
) : null}
</div>
)}
</CollapsibleContent>
</Collapsible>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,124 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Clock, Calendar, PlayCircle, AlertCircle } from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import type { StudentExamAssignment } from "@/types";
export default function ExamPopup() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
const { data } = useQuery({
queryKey: ["student-my-exams"],
queryFn: () => assignmentsService.getStudentExams(),
refetchInterval: 30000,
});
const exams = (data?.items ?? []) as StudentExamAssignment[];
const pendingExams = exams.filter((e) => !dismissed.has(e.id));
useEffect(() => {
if (pendingExams.length > 0 && !open) {
setOpen(true);
}
}, [pendingExams.length]);
if (pendingExams.length === 0) return null;
// Localize dates with the active language so Arabic users see ar-EG
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
};
const handleStart = (exam: StudentExamAssignment) => {
setOpen(false);
navigate(`/student/exam/${exam.exam_id}/session`);
};
const handleDismiss = (id: number) => {
setDismissed((prev) => new Set([...prev, id]));
const remaining = pendingExams.filter((e) => e.id !== id);
if (remaining.length === 0) setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
{t("examPopup.title", { n: pendingExams.length })}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
{pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm">{exam.exam_title || exam.schedule_name}</h3>
<Badge
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
</Badge>
</div>
{exam.schedule_name && exam.exam_title && (
<p className="text-xs text-muted-foreground">{exam.schedule_name}</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{t("examPopup.from")} {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{t("examPopup.to")} {formatDate(exam.end_date)}
</span>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
disabled={!exam.can_start}
onClick={() => handleStart(exam)}
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
{t("common.dismiss")}
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ms-auto">
{exam.schedule_state === "planned"
? t("examPopup.willBeAvailable")
: t("examPopup.notActive")}
</span>
)}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,52 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item ref={ref} className={cn("border-b", className)} {...props} />
));
AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
));
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
));
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,154 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const AlertDialog = AlertDialogPrimitive.Root;
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
const AlertDialogPortal = AlertDialogPrimitive.Portal;
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
ref={ref}
/>
));
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
function hasAlertDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === AlertDialogPrimitive.Description ||
(type && type.displayName === AlertDialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasAlertDialogDescription(nested)) {
found = true;
}
});
return found;
}
type AlertDialogContentProps = React.ComponentPropsWithoutRef<
typeof AlertDialogPrimitive.Content
> & {
/**
* Optional accessible description rendered visually-hidden. When omitted and
* no `AlertDialogDescription` is found in the subtree, a generic fallback is
* emitted so screen readers still have something to announce.
*/
description?: React.ReactNode;
};
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
AlertDialogContentProps
>(({ className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasAlertDialogDescription(children);
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{description !== undefined && (
<AlertDialogPrimitive.Description className="sr-only">
{description}
</AlertDialogPrimitive.Description>
)}
{needsFallback && (
<AlertDialogPrimitive.Description className="sr-only">
Alert dialog content
</AlertDialogPrimitive.Description>
)}
{children}
</AlertDialogPrimitive.Content>
</AlertDialogPortal>
);
});
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-2 text-center sm:text-left", className)} {...props} />
);
AlertDialogHeader.displayName = "AlertDialogHeader";
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
AlertDialogFooter.displayName = "AlertDialogFooter";
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title ref={ref} className={cn("text-lg font-semibold", className)} {...props} />
));
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
));
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(buttonVariants({ variant: "outline" }), "mt-2 sm:mt-0", className)}
{...props}
/>
));
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} />
),
);
AlertTitle.displayName = "AlertTitle";
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("text-sm [&_p]:leading-relaxed", className)} {...props} />
),
);
AlertDescription.displayName = "AlertDescription";
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,5 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
const AspectRatio = AspectRatioPrimitive.Root;
export { AspectRatio };

View File

@@ -0,0 +1,38 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full", className)}
{...props}
/>
));
Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image ref={ref} className={cn("aspect-square h-full w-full", className)} {...props} />
));
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn("flex h-full w-full items-center justify-center rounded-full bg-muted", className)}
{...props}
/>
));
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
const Badge = React.forwardRef<HTMLDivElement, BadgeProps>(
({ className, variant, ...props }, ref) => (
<div ref={ref} className={cn(badgeVariants({ variant }), className)} {...props} />
),
);
Badge.displayName = "Badge";
export { Badge, badgeVariants };

View File

@@ -0,0 +1,95 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode;
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
Breadcrumb.displayName = "Breadcrumb";
const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWithoutRef<"ol">>(
({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className,
)}
{...props}
/>
),
);
BreadcrumbList.displayName = "BreadcrumbList";
const BreadcrumbItem = React.forwardRef<HTMLLIElement, React.ComponentPropsWithoutRef<"li">>(
({ className, ...props }, ref) => (
<li ref={ref} className={cn("inline-flex items-center gap-1.5", className)} {...props} />
),
);
BreadcrumbItem.displayName = "BreadcrumbItem";
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean;
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a";
return <Comp ref={ref} className={cn("transition-colors hover:text-foreground", className)} {...props} />;
});
BreadcrumbLink.displayName = "BreadcrumbLink";
const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWithoutRef<"span">>(
({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
),
);
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
);
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,47 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
export type CalendarProps = React.ComponentProps<typeof DayPicker>;
function Calendar({ className, classNames, showOutsideDays = true, ...props }: CalendarProps) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
month: "space-y-4",
caption: "flex justify-center pt-1 relative items-center",
caption_label: "text-sm font-medium",
nav: "space-x-1 flex items-center",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100",
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-y-1",
head_row: "flex",
head_cell: "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
day: cn(buttonVariants({ variant: "ghost" }), "h-9 w-9 p-0 font-normal aria-selected:opacity-100"),
day_range_end: "day-range-end",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle: "aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
}}
{...props}
/>
);
}
Calendar.displayName = "Calendar";
export { Calendar };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)} {...props} />
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,224 @@
import * as React from "react";
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
({ orientation = "horizontal", opts, setApi, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
},
);
Carousel.displayName = "Carousel";
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
{...props}
/>
</div>
);
},
);
CarouselContent.displayName = "CarouselContent";
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
{...props}
/>
);
},
);
CarouselItem.displayName = "CarouselItem";
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Previous slide</span>
</Button>
);
},
);
CarouselPrevious.displayName = "CarouselPrevious";
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Next slide</span>
</Button>
);
},
);
CarouselNext.displayName = "CarouselNext";
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext };

303
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,303 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> });
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item.dataKey || item.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>;
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn("shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]", {
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent": indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
})}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey }, ref) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn("flex items-center justify-center gap-4", verticalAlign === "top" ? "pb-3" : "pt-3", className)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn("flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground")}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
});
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
}
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
}
export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle };

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className={cn("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };

View File

@@ -0,0 +1,9 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
const Collapsible = CollapsiblePrimitive.Root;
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent;
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,132 @@
import * as React from "react";
import { type DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import { Search } from "lucide-react";
import { cn } from "@/lib/utils";
import { Dialog, DialogContent } from "@/components/ui/dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
interface CommandDialogProps extends DialogProps {}
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator ref={ref} className={cn("-mx-1 h-px bg-border", className)} {...props} />
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50",
className,
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,178 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const ContextMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent data-[state=open]:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
));
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
));
ContextMenuCheckboxItem.displayName = ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
));
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold text-foreground", inset && "pl-8", className)}
{...props}
/>
));
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} />
));
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />;
};
ContextMenuShortcut.displayName = "ContextMenuShortcut";
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,153 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
/**
* Walks `children` looking for any element whose component is
* `DialogPrimitive.Description` (or our re-exported `DialogDescription`).
* Radix emits an a11y warning when a `DialogContent` has neither a
* `Description` nor an explicit `aria-describedby`, so we use this to decide
* whether to inject a visually-hidden fallback description.
*/
function hasDialogDescription(children: React.ReactNode): boolean {
let found = false;
React.Children.forEach(children, (child) => {
if (found || !React.isValidElement(child)) return;
const type = child.type as { displayName?: string } | undefined;
if (
type === DialogPrimitive.Description ||
(type && type.displayName === DialogPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasDialogDescription(nested)) {
found = true;
}
});
return found;
}
type DialogContentProps = React.ComponentPropsWithoutRef<
typeof DialogPrimitive.Content
> & {
/**
* Optional accessible description. When supplied, a visually-hidden
* `DialogDescription` is rendered so screen readers announce it without
* affecting layout. Prefer this for simple dialogs; for richer descriptions
* continue to place a `<DialogDescription>` inside `<DialogHeader>`.
*/
description?: React.ReactNode;
};
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
>(({ className, children, description, ...props }, ref) => {
const hasExplicitDescribedBy = !!props["aria-describedby"];
const needsFallback =
!hasExplicitDescribedBy &&
description === undefined &&
!hasDialogDescription(children);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
{...props}
>
{description !== undefined && (
<DialogPrimitive.Description className="sr-only">
{description}
</DialogPrimitive.Description>
)}
{needsFallback && (
<DialogPrimitive.Description className="sr-only">
Dialog content
</DialogPrimitive.Description>
)}
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-accent data-[state=open]:text-muted-foreground hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,87 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
const Drawer = ({ shouldScaleBackground = true, ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay ref={ref} className={cn("fixed inset-0 z-50 bg-black/80", className)} {...props} />
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
className,
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
));
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)} {...props} />
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,179 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[state=open]:bg-accent focus:bg-accent",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50 focus:bg-accent focus:text-accent-foreground",
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />;
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

129
src/components/ui/form.tsx Normal file
View File

@@ -0,0 +1,129 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>({} as FormFieldContextValue);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>({} as FormItemContextValue);
const FormItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
},
);
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
return <Label ref={ref} className={cn(error && "text-destructive", className)} htmlFor={formItemId} {...props} />;
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<React.ElementRef<typeof Slot>, React.ComponentPropsWithoutRef<typeof Slot>>(
({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-invalid={!!error}
{...props}
/>
);
},
);
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
return <p ref={ref} id={formDescriptionId} className={cn("text-sm text-muted-foreground", className)} {...props} />;
},
);
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
return (
<p ref={ref} id={formMessageId} className={cn("text-sm font-medium text-destructive", className)} {...props}>
{body}
</p>
);
},
);
FormMessage.displayName = "FormMessage";
export { useFormField, Form, FormItem, FormLabel, FormControl, FormDescription, FormMessage, FormField };

View File

@@ -0,0 +1,27 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
const HoverCard = HoverCardPrimitive.Root;
const HoverCardTrigger = HoverCardPrimitive.Trigger;
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
));
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { Dot } from "lucide-react";
import { cn } from "@/lib/utils";
const InputOTP = React.forwardRef<React.ElementRef<typeof OTPInput>, React.ComponentPropsWithoutRef<typeof OTPInput>>(
({ className, containerClassName, ...props }, ref) => (
<OTPInput
ref={ref}
containerClassName={cn("flex items-center gap-2 has-[:disabled]:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
),
);
InputOTP.displayName = "InputOTP";
const InputOTPGroup = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ className, ...props }, ref) => <div ref={ref} className={cn("flex items-center", className)} {...props} />,
);
InputOTPGroup.displayName = "InputOTPGroup";
const InputOTPSlot = React.forwardRef<
React.ElementRef<"div">,
React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
return (
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink h-4 w-px bg-foreground duration-1000" />
</div>
)}
</div>
);
});
InputOTPSlot.displayName = "InputOTPSlot";
const InputOTPSeparator = React.forwardRef<React.ElementRef<"div">, React.ComponentPropsWithoutRef<"div">>(
({ ...props }, ref) => (
<div ref={ref} role="separator" {...props}>
<Dot />
</div>
),
);
InputOTPSeparator.displayName = "InputOTPSeparator";
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

Some files were not shown because too many files have changed in this diff Show More