18 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-11 14:21:40 +04:00
222 changed files with 26127 additions and 3060 deletions

View File

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

View File

@@ -18,13 +18,25 @@ server {
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;
proxy_read_timeout 300s;
# 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 / {

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 });
});

View File

@@ -6,12 +6,18 @@
<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=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" />
<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>

162
package-lock.json generated
View File

@@ -42,6 +42,8 @@
"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",
@@ -49,6 +51,7 @@
"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",
@@ -60,6 +63,7 @@
},
"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",
@@ -76,6 +80,7 @@
"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",
@@ -905,6 +910,22 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -5480,6 +5501,15 @@
"node": ">=12"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/http-proxy-agent": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz",
@@ -5509,6 +5539,46 @@
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "26.0.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.6.tgz",
"integrity": "sha512-A4U6eCXodIbrhf8EarRurB9/4ebyaurH4+fu4gig9bqxmpSt+fCAFm/GpRQDcN1Xzu/LdFCx4nYHsnM1edIIbg==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2"
},
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -6215,6 +6285,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
@@ -6547,6 +6664,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "17.0.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz",
"integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.0.1",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
@@ -7157,6 +7301,13 @@
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tailwindcss-rtl": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/tailwindcss-rtl/-/tailwindcss-rtl-0.9.0.tgz",
"integrity": "sha512-y7yC8QXjluDBEFMSX33tV6xMYrf0B3sa+tOB5JSQb6/G6laBU313a+Z+qxu55M1Qyn8tDMttjomsA8IsJD+k+w==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
@@ -7369,7 +7520,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -8164,6 +8315,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/w3c-xmlserializer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz",

View File

@@ -10,7 +10,9 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:e2e": "playwright test",
"test:e2e:install": "playwright install --with-deps chromium"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
@@ -47,6 +49,8 @@
"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",
@@ -54,6 +58,7 @@
"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",
@@ -65,9 +70,10 @@
},
"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",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
@@ -81,6 +87,7 @@
"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",

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"] },
},
],
});

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 554 B

BIN
public/logo-icon.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

View File

@@ -1,3 +1,5 @@
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";
@@ -8,143 +10,174 @@ import ProtectedRoute from "@/components/ProtectedRoute";
import StudentLayout from "@/components/StudentLayout";
import TeacherLayout from "@/components/TeacherLayout";
import AdminLmsLayout from "@/components/AdminLmsLayout";
import Login from "@/pages/Login";
import Register from "@/pages/Register";
import EmailVerification from "@/pages/EmailVerification";
import OnboardingWizard from "@/pages/OnboardingWizard";
import ForgotPassword from "@/pages/ForgotPassword";
import ResetPassword from "@/pages/ResetPassword";
import ScoreVerification from "@/pages/ScoreVerification";
// Original platform pages
import AdminDashboard from "@/pages/AdminDashboard";
import UsersPage from "@/pages/UsersPage";
import EntitiesPage from "@/pages/EntitiesPage";
import AssignmentsPage from "@/pages/AssignmentsPage";
import ExamsListPage from "@/pages/ExamsListPage";
import ExamStructuresPage from "@/pages/ExamStructuresPage";
import RubricsPage from "@/pages/RubricsPage";
import GenerationPage from "@/pages/GenerationPage";
import ApprovalWorkflowsPage from "@/pages/ApprovalWorkflowsPage";
import ClassroomsPage from "@/pages/ClassroomsPage";
import StudentPerformancePage from "@/pages/StudentPerformancePage";
import StatsCorporatePage from "@/pages/StatsCorporatePage";
import RecordPage from "@/pages/RecordPage";
import VocabularyPage from "@/pages/VocabularyPage";
import GrammarPage from "@/pages/GrammarPage";
import PaymentRecordPage from "@/pages/PaymentRecordPage";
import TicketsPage from "@/pages/TicketsPage";
import SettingsPage from "@/pages/SettingsPage";
import ProfilePage from "@/pages/ProfilePage";
import ExamPage from "@/pages/ExamPage";
// Student pages
import StudentDashboard from "@/pages/student/StudentDashboard";
import StudentCourses from "@/pages/student/StudentCourses";
import StudentCourseDetail from "@/pages/student/StudentCourseDetail";
import StudentAssignments from "@/pages/student/StudentAssignments";
import StudentGrades from "@/pages/student/StudentGrades";
import StudentAttendance from "@/pages/student/StudentAttendance";
import StudentTimetable from "@/pages/student/StudentTimetable";
import StudentProfile from "@/pages/student/StudentProfile";
// Adaptive learning pages
import SubjectSelection from "@/pages/student/SubjectSelection";
import DiagnosticTest from "@/pages/student/DiagnosticTest";
import ProficiencyProfile from "@/pages/student/ProficiencyProfile";
import LearningPlanPage from "@/pages/student/LearningPlan";
import TopicLearning from "@/pages/student/TopicLearning";
// Teacher pages
import TeacherDashboard from "@/pages/teacher/TeacherDashboard";
import TeacherCourses from "@/pages/teacher/TeacherCourses";
import CourseBuilder from "@/pages/teacher/CourseBuilder";
import TeacherAssignments from "@/pages/teacher/TeacherAssignments";
import TeacherAssignmentDetail from "@/pages/teacher/TeacherAssignmentDetail";
import TeacherAttendance from "@/pages/teacher/TeacherAttendance";
import TeacherStudents from "@/pages/teacher/TeacherStudents";
import TeacherTimetable from "@/pages/teacher/TeacherTimetable";
import TeacherProfile from "@/pages/teacher/TeacherProfile";
import AdaptiveSettings from "@/pages/teacher/AdaptiveSettings";
// Admin LMS pages
import AdminLmsDashboard from "@/pages/admin/AdminLmsDashboard";
import AdminCourses from "@/pages/admin/AdminCourses";
import AdminStudents from "@/pages/admin/AdminStudents";
import AdminTeachers from "@/pages/admin/AdminTeachers";
import AdminBatches from "@/pages/admin/AdminBatches";
import AdminBatchDetail from "@/pages/admin/AdminBatchDetail";
import AdminTimetable from "@/pages/admin/AdminTimetable";
import AdminReports from "@/pages/admin/AdminReports";
import AdminSettings from "@/pages/admin/AdminSettings";
import AdminProfileLms from "@/pages/admin/AdminProfile";
import TaxonomyManager from "@/pages/admin/TaxonomyManager";
import ResourceManager from "@/pages/admin/ResourceManager";
import AcademicYearManager from "@/pages/admin/AcademicYearManager";
import DepartmentManager from "@/pages/admin/DepartmentManager";
import AdmissionList from "@/pages/admin/AdmissionList";
import AdmissionDetail from "@/pages/admin/AdmissionDetail";
import AdmissionRegisterPage from "@/pages/admin/AdmissionRegisterPage";
import InstitutionalExamSessions from "@/pages/admin/InstitutionalExamSessions";
import MarksheetManager from "@/pages/admin/MarksheetManager";
import AdminStudentLeave from "@/pages/admin/AdminStudentLeave";
import AdminFees from "@/pages/admin/AdminFees";
import AdminLessons from "@/pages/admin/AdminLessons";
import AdminGradebook from "@/pages/admin/AdminGradebook";
import AdminStudentProgress from "@/pages/admin/AdminStudentProgress";
import AdminLibrary from "@/pages/admin/AdminLibrary";
import AdminActivities from "@/pages/admin/AdminActivities";
import AdminFacilities from "@/pages/admin/AdminFacilities";
import AdmissionApplication from "@/pages/AdmissionApplication";
import SubjectRegistrationPage from "@/pages/student/SubjectRegistrationPage";
// Courseware pages
import CourseChapters from "@/pages/teacher/CourseChapters";
import ChapterDetail from "@/pages/teacher/ChapterDetail";
import AiWorkbench from "@/pages/teacher/AiWorkbench";
import TeacherDiscussionBoard from "@/pages/teacher/TeacherDiscussionBoard";
import TeacherAnnouncements from "@/pages/teacher/TeacherAnnouncements";
import StudentChapterView from "@/pages/student/StudentChapterView";
import StudentDiscussionBoard from "@/pages/student/StudentDiscussionBoard";
import StudentAnnouncements from "@/pages/student/StudentAnnouncements";
import StudentMessages from "@/pages/student/StudentMessages";
import StudentJourney from "@/pages/student/StudentJourney";
import AiEnglishCourse from "@/pages/student/AiEnglishCourse";
import AiIeltsCourse from "@/pages/student/AiIeltsCourse";
import ExamSession from "@/pages/student/ExamSession";
import ExamStatus from "@/pages/student/ExamStatus";
import ExamResults from "@/pages/student/ExamResults";
import GapAnalysis from "@/pages/student/GapAnalysis";
import CourseDelivery from "@/pages/student/CourseDelivery";
import GradingQueue from "@/pages/admin/GradingQueue";
import CourseConfig from "@/pages/admin/CourseConfig";
import ModuleBuilder from "@/pages/admin/ModuleBuilder";
import CourseProgress from "@/pages/teacher/CourseProgress";
import PlacementBriefing from "@/pages/student/PlacementBriefing";
import PlacementTest from "@/pages/student/PlacementTest";
import PlacementResults from "@/pages/student/PlacementResults";
import PlacementAccess from "@/pages/student/PlacementAccess";
import FaqManager from "@/pages/admin/FaqManager";
import NotificationRules from "@/pages/admin/NotificationRules";
import ApprovalWorkflowConfig from "@/pages/admin/ApprovalWorkflowConfig";
import RolesPermissions from "@/pages/admin/RolesPermissions";
import AuthorityMatrix from "@/pages/admin/AuthorityMatrix";
import UserRoles from "@/pages/admin/UserRoles";
import BulkStudentUpload from "@/pages/admin/BulkStudentUpload";
import CredentialDashboard from "@/pages/admin/CredentialDashboard";
import AiEnglishQuality from "@/pages/admin/AiEnglishQuality";
import AiEnglishTaxonomy from "@/pages/admin/AiEnglishTaxonomy";
import AiIeltsValidation from "@/pages/admin/AiIeltsValidation";
import AdaptiveDashboard from "@/pages/admin/AdaptiveDashboard";
import AdaptiveStudentDetail from "@/pages/admin/AdaptiveStudentDetail";
import LevelMappingConfig from "@/pages/admin/LevelMappingConfig";
import WhiteLabelBranding from "@/pages/admin/WhiteLabelBranding";
import ScoreApprovalQueue from "@/pages/admin/ScoreApprovalQueue";
import ExamTemplateSelection from "@/pages/admin/ExamTemplateSelection";
import IeltsExamCreate from "@/pages/admin/IeltsExamCreate";
import IeltsSkillConfig from "@/pages/admin/IeltsSkillConfig";
import IeltsContentPool from "@/pages/admin/IeltsContentPool";
import IeltsExamValidation from "@/pages/admin/IeltsExamValidation";
import CustomExamCreate from "@/pages/admin/CustomExamCreate";
import OfficialExamAccess from "@/pages/OfficialExamAccess";
import FaqPage from "@/pages/FaqPage";
import NotFound from "@/pages/NotFound";
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 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 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();
@@ -156,13 +189,35 @@ function StudentSubscriptionPlaceholder() {
);
}
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>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<AuthProvider>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Auth routes */}
<Route path="/login" element={<Login />} />
@@ -197,6 +252,7 @@ const App = () => (
<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 />} />
@@ -220,10 +276,12 @@ const App = () => (
</Route>
{/* Teacher routes */}
<Route element={<ProtectedRoute allowedRoles={["teacher"]} />}>
<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 />} />
@@ -231,12 +289,13 @@ const App = () => (
<Route path="/teacher/attendance" element={<TeacherAttendance />} />
<Route path="/teacher/students" element={<TeacherStudents />} />
<Route path="/teacher/timetable" element={<TeacherTimetable />} />
<Route path="/teacher/courses/:id/chapters" element={<CourseChapters />} />
<Route path="/teacher/courses/:id/chapters/:chapterId" element={<ChapterDetail />} />
<Route path="/teacher/courses/:id/workbench" element={<AiWorkbench />} />
<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>
@@ -247,6 +306,14 @@ const App = () => (
<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 */}
@@ -259,6 +326,7 @@ const App = () => (
<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 />} />
@@ -281,13 +349,16 @@ const App = () => (
<Route path="/admin/payment-record" element={<PaymentRecordPage />} />
<Route path="/admin/tickets" element={<TicketsPage />} />
<Route path="/admin/settings-platform" element={<SettingsPage />} />
<Route path="/admin/exam" element={<ExamPage />} />
<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 */}
@@ -336,10 +407,13 @@ const App = () => (
<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

@@ -1,4 +1,5 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -9,6 +10,8 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/component
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";
@@ -29,115 +32,128 @@ import {
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog,
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
// ============= Navigation Config =============
interface NavItem { title: string; url: string; icon: LucideIcon }
// 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[] = [
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
{ 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[] = [
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
{ title: "Students", url: "/admin/students", icon: Users },
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
{ title: "Batches", url: "/admin/batches", icon: Layers },
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
{ 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.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[] = [
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
{ 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[] = [
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
{ title: "Library", url: "/admin/library", icon: Library },
{ title: "Activities", url: "/admin/activities", icon: Activity },
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
{ 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[] = [
{ title: "Users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 },
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
];
const reportItems: NavItem[] = [
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
{ title: "Record", url: "/admin/record", icon: History },
{ 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[] = [
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
];
const configItems: NavItem[] = [
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
{ 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[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
{ 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({ label, items }: { label: string; items: NavItem[] }) {
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<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>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{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>
@@ -145,40 +161,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
}
// ============= Breadcrumbs =============
const routeLabels: Record<string, string> = {
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "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-platform": "Settings", settings: "Settings",
profile: "Profile", exam: "Exam",
"academic-years": "Academic Years", departments: "Departments",
admissions: "Admissions", "admission-register": "Admission Register",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
faq: "FAQ Manager", "notification-rules": "Notification Rules",
"approval-config": "Approval Config",
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
gradebook: "Gradebook", "student-progress": "Student Progress",
library: "Library", activities: "Activities", facilities: "Facilities",
// 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", 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">Home</Link></BreadcrumbLink>
<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 label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
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}>
@@ -194,10 +222,25 @@ function AppBreadcrumbs() {
);
}
// ============= 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 } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -213,13 +256,15 @@ export default function AdminLmsLayout() {
<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 />
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs />
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<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>
@@ -232,36 +277,38 @@ export default function AdminLmsLayout() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
<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="mr-2 h-4 w-4" /> Profile
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
<Settings className="mr-2 h-4 w-4" /> Settings
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
<Link
to="/admin/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
</SidebarProvider>
);
@@ -273,67 +320,78 @@ function AdminSidebar() {
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 collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
{!collapsed && (
<span className="text-lg font-bold tracking-tight text-sidebar-foreground">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
{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 label="Overview" items={overviewItems} />
<SidebarNavGroup label="LMS" items={lmsItems} />
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
<SidebarNavGroup label="Institutional" items={institutionalItems} />
<SidebarNavGroup label="Academic" items={academicItems} />
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
<SidebarNavGroup label="Configuration" items={configItems} />
<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">
Training
{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) => (
<SidebarMenuItem key={item.url}>
<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>
))}
{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 label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="p-3">
@@ -343,7 +401,7 @@ function AdminSidebar() {
</div>
{!collapsed && (
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
<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>
)}

View File

@@ -1,4 +1,5 @@
import { Outlet, useLocation, Link, useNavigate } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar";
import {
@@ -77,6 +78,16 @@ function AppBreadcrumbs() {
);
}
// 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();
@@ -127,7 +138,9 @@ export default function AppLayout() {
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import {
} 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,
@@ -81,9 +82,11 @@ 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 collapsible="icon">
<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">

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

@@ -1,4 +1,4 @@
import { NavLink as RouterNavLink, NavLinkProps } from "react-router-dom";
import { NavLink as RouterNavLink, NavLinkProps, useNavigate } from "react-router-dom";
import { forwardRef } from "react";
import { cn } from "@/lib/utils";
@@ -9,7 +9,14 @@ interface NavLinkCompatProps extends Omit<NavLinkProps, "className"> {
}
const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
({ className, activeClassName, pendingClassName, to, ...props }, ref) => {
({ 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}
@@ -17,6 +24,31 @@ const NavLink = forwardRef<HTMLAnchorElement, NavLinkCompatProps>(
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}
/>
);

View File

@@ -1,35 +1,50 @@
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" className="relative text-muted-foreground hover:text-foreground">
<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 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
<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">Notifications</div>
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
<DropdownMenuSeparator />
{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>
))}
{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,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

@@ -1,4 +1,6 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
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,
@@ -8,22 +10,28 @@ import {
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, Settings, LucideIcon } from "lucide-react";
import React from "react";
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 {
title: string;
titleKey: string;
url: string;
icon: LucideIcon;
}
export interface NavGroup {
label: string;
labelKey: string;
items: NavItem[];
}
@@ -34,30 +42,34 @@ interface RoleLayoutProps {
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<>
{navGroups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroup key={group.labelKey}>
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<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>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{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>
@@ -66,9 +78,21 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
);
}
// 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() ?? "??";
@@ -77,17 +101,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
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 collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<img src="/logo.png" alt="EnCoach" className="h-8 w-8 rounded-lg shrink-0 object-contain" />
<span className="text-lg font-bold tracking-tight text-sidebar-foreground group-data-[collapsible=icon]:hidden">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span>Coach</span>
</span>
<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 />
@@ -101,7 +133,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<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}</span>
<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>
@@ -111,10 +145,12 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
<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 />
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
<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>
@@ -126,23 +162,25 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name}</p>
<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="mr-2 h-4 w-4" /> Profile
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" /> Logout
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>

View File

@@ -1,51 +1,62 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
} 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[] = [
{
label: "Learning",
labelKey: "sidebarGroup.learning",
items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
],
},
{
label: "Adaptive Learning",
labelKey: "sidebarGroup.adaptiveLearning",
items: [
{ title: "My Subjects", url: "/student/subjects", icon: Target },
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
],
},
{
label: "Progress",
labelKey: "sidebarGroup.progress",
items: [
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
{ 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 },
],
},
{
label: "Communication",
labelKey: "sidebarGroup.communication",
items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
],
},
{
label: "Account",
labelKey: "sidebarGroup.account",
items: [
{ title: "Profile", url: "/student/profile", icon: User },
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
],
},
];
export default function StudentLayout() {
return <RoleLayout navGroups={navGroups} role="student" />;
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

@@ -1,38 +1,41 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, FileText,
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2,
Megaphone, Library, Sparkles,
} from "lucide-react";
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
const navGroups: NavGroup[] = [
{
label: "Teaching",
labelKey: "sidebarGroup.teaching",
items: [
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
{ 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 },
],
},
{
label: "Management",
labelKey: "sidebarGroup.management",
items: [
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ title: "Students", url: "/teacher/students", icon: Users },
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
],
},
{
label: "Communication",
labelKey: "sidebarGroup.communication",
items: [
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
],
},
{
label: "Account",
labelKey: "sidebarGroup.account",
items: [
{ title: "Profile", url: "/teacher/profile", icon: User },
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
],
},
];

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

@@ -1,5 +1,6 @@
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";
@@ -7,32 +8,34 @@ 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: alerts, isLoading, isError, error } = useQuery({
const { data: resp, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"],
queryFn: () => analyticsService.getAlerts(),
});
const visible = alerts?.filter((a) => !dismissedIds.has(a.id)) ?? [];
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">Loading alerts</p>
<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">
<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">
<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" /> Alerts unavailable
<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 : "Could not load alerts."}</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" />
@@ -43,11 +46,11 @@ export default function AiAlertBanner() {
if (isError && errorDismissed) return null;
if (!alerts?.length) {
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">No AI alerts right now.</p>
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
</div>
);
}
@@ -56,12 +59,12 @@ export default function AiAlertBanner() {
return (
<div className="space-y-3">
{visible.map((alert) => (
<div key={alert.id} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-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">
<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" /> {alert.title}
<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>
@@ -69,7 +72,7 @@ export default function AiAlertBanner() {
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setDismissedIds((prev) => new Set(prev).add(alert.id))}
onClick={() => setDismissedIds((prev) => new Set(prev).add(String(idx)))}
>
<X className="h-4 w-4" />
</Button>

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
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";
@@ -8,38 +9,43 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
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.message }]);
setMessages((prev) => [...prev, { role: "ai", text: data.reply }]);
},
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not get a reply",
description: err.message || "Something went wrong. Try again.",
title: t("ai.replyErrorTitle"),
description: err.message || t("ai.replyErrorDesc"),
});
setMessages((prev) => [
...prev,
{
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
{ role: "ai", text: t("ai.fallbackReply") },
]);
},
});
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-20 right-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="AI Assistant"
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>
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant
{t("ai.assistantTitle")}
</SheetTitle>
</SheetHeader>
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
{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>Ask me anything about the platform,</p>
<p>or click a quick action above.</p>
<p>{t("ai.emptyLine1")}</p>
<p>{t("ai.emptyLine2")}</p>
</div>
)}
{messages.map((msg, i) => (
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
key={i}
className={`rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground ml-8"
: "bg-muted mr-8"
? "bg-primary text-primary-foreground ms-8"
: "bg-muted me-8"
}`}
>
{msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
<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 mr-8 flex items-center gap-2">
<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">Thinking...</span>
<span className="text-muted-foreground">{t("ai.thinking")}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t mt-auto">
<Input
placeholder="Ask anything..."
placeholder={t("ai.placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
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>

View File

@@ -25,6 +25,8 @@ export default function AiBatchOptimizer({ batchId }: Props) {
},
});
type OptResult = Awaited<ReturnType<typeof analyticsService.getBatchOptimization>>;
const handleOpen = () => {
if (batchId == null) {
toast({
@@ -39,9 +41,23 @@ export default function AiBatchOptimizer({ batchId }: Props) {
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 = () => {
toast({ title: "Suggestion Applied", description: "Batch split recommendation has been saved successfully." });
setOpen(false);
applyMutation.mutate();
};
const onOpenChange = (next: boolean) => {
@@ -49,9 +65,10 @@ export default function AiBatchOptimizer({ batchId }: Props) {
if (!next) mutation.reset();
};
const suggestions = mutation.data ?? [];
const showResults = !mutation.isPending && !mutation.isError && suggestions.length > 0;
const showEmpty = !mutation.isPending && !mutation.isError && mutation.isSuccess && suggestions.length === 0;
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 (
<>
@@ -71,20 +88,28 @@ export default function AiBatchOptimizer({ batchId }: Props) {
</div>
) : mutation.isError ? (
<p className="text-sm text-muted-foreground py-4 text-center">Something went wrong. Try again.</p>
) : showResults ? (
) : showResults && optData ? (
<div className="space-y-4">
<div className="space-y-3 max-h-[50vh] overflow-y-auto">
{suggestions.map((s, i) => (
<div key={i} 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">{s.impact} impact</p>
<p className="text-sm font-medium">{s.suggestion}</p>
{s.details ? <p className="text-sm text-muted-foreground mt-2 leading-relaxed">{s.details}</p> : null}
</div>
))}
<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}>
Apply Suggestion
<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

View File

@@ -40,8 +40,9 @@ export default function AiGeneratorModal() {
difficulty,
count,
}),
onSuccess: (res) => {
setLocalExercises(Array.isArray(res.exercises) ? res.exercises : []);
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({
@@ -57,6 +58,21 @@ export default function AiGeneratorModal() {
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) => {
@@ -188,7 +204,17 @@ export default function AiGeneratorModal() {
);
})}
<div className="flex gap-2">
<Button className="flex-1">Save All</Button>
<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={() => {

View File

@@ -19,8 +19,8 @@ export default function AiGradeExplainer({
const explainMutation = useMutation({
mutationFn: () =>
coachingService.explain({
context: `IELTS / course grades for student: ${studentName}. Summarize what the scores mean and what to focus on next.`,
scores,
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({

View File

@@ -26,9 +26,8 @@ export default function AiGradingAssistant({
const gradeMutation = useMutation({
mutationFn: () =>
analyticsService.getGradingSuggestion({
submission_id: submissionId,
text: submissionText,
...(rubricId !== undefined ? { rubric_id: rubricId } : {}),
submission_text: submissionText,
skill: "writing",
}),
onError: (err: Error) => {
toast({
@@ -45,7 +44,7 @@ export default function AiGradingAssistant({
}, [submissionId, submissionText, rubricId]);
const data = gradeMutation.data;
const marks = data ? Math.round(data.overall_score) : 0;
const marks = data ? Math.round(data.overall_band * 100 / 9) : 0;
const feedbackBlock = data
? [
data.feedback,

View File

@@ -1,32 +1,32 @@
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, Trophy, Loader2 } from "lucide-react";
import { analyticsService } from "@/services/analytics.service";
import type { AiInsight } from "@/types";
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(type: AiInsight["type"]) {
switch (type) {
case "positive":
return Trophy;
case "warning":
function insightIcon(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return AlertTriangle;
default:
case "warning":
return TrendingUp;
default:
return Info;
}
}
function insightColor(type: AiInsight["type"]) {
switch (type) {
case "positive":
return "text-primary";
function insightColor(severity: AiInsightItem["severity"]) {
switch (severity) {
case "critical":
return "text-destructive";
case "warning":
return "text-warning";
default:
return "text-success";
return "text-primary";
}
}
@@ -36,14 +36,15 @@ interface Props {
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: "Insights unavailable",
description: err.message || "Could not load AI insights.",
title: t("ai.insightsUnavailable"),
description: err.message || t("ai.couldNotLoadInsights"),
variant: "destructive",
});
},
@@ -51,47 +52,47 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
useEffect(() => {
mutation.mutate(data);
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when serialized payload changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [payloadKey]);
const items = mutation.data ?? [];
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" />
AI Platform Insights
{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" />
Loading insights
{t("ai.loadingInsights")}
</div>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
<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">No insights available for this view.</p>
<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) => {
const Icon = insightIcon(item.type);
const color = insightColor(item.type);
{items.map((item, idx) => {
const Icon = insightIcon(item.severity);
const color = insightColor(item.severity);
return (
<div key={item.id} className="rounded-lg border bg-muted/30 p-4">
<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 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span>
<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.metric != null && item.value != null && (
<p className="text-xs text-muted-foreground mt-2">
{item.metric}: {item.value}
{item.recommendation && (
<p className="text-xs text-muted-foreground mt-2 italic">
{item.recommendation}
</p>
)}
</div>

View File

@@ -1,5 +1,6 @@
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";
@@ -10,14 +11,15 @@ 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: "Search failed",
description: err.message || "Could not complete AI search.",
title: t("chrome.aiSearchFailedTitle"),
description: err.message || t("chrome.aiSearchFailedDesc"),
});
},
});
@@ -27,16 +29,16 @@ export default function AiSearchBar() {
searchMutation.mutate(query.trim());
};
const results = searchMutation.data;
const result = searchMutation.data;
return (
<div className="relative max-w-md w-full">
<div className="relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<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="Ask anything... e.g. 'Show students with low attendance'"
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
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);
@@ -50,50 +52,56 @@ export default function AiSearchBar() {
setQuery("");
searchMutation.reset();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
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 || results !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{(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" />
AI is searching...
{t("chrome.aiSearching")}
</div>
) : results && results.length > 0 ? (
) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
{results.map((r, i) => (
<div
key={`${r.title}-${i}`}
className="flex items-start gap-2 border-b border-border/60 pb-2 last:border-0 last:pb-0"
>
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="font-medium">{r.title}</p>
<p className="text-muted-foreground text-xs mt-0.5">{r.description}</p>
{r.url && (
<button
type="button"
className="text-xs text-primary mt-1 hover:underline"
onClick={() => navigate(r.url!)}
>
Go to {r.url}
</button>
)}
</div>
<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>
No results for &quot;{query}&quot;. Try a different question or keyword.
</p>
<p>{t("chrome.aiNoResults", { q: query })}</p>
</div>
)}
</div>

View File

@@ -29,8 +29,11 @@ export default function AiStudyCoach() {
suggestMutation.mutate();
};
const suggestions = suggestMutation.data?.suggestions ?? [];
const planTips = suggestMutation.data?.study_plan_tips ?? [];
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">

View File

@@ -1,5 +1,6 @@
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";
@@ -12,6 +13,7 @@ interface Props {
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],
@@ -22,23 +24,30 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
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">Loading AI tip</span>
<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`}>
<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">
<span className="text-xs font-semibold text-primary">AI Tip</span>
<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 : "Could not load tip.") : "No tip available."}
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
</p>
</div>
{dismissible && (
@@ -50,26 +59,28 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
);
}
if (!data.content?.trim() && !data.title?.trim()) {
if (!data.tip?.trim()) {
return (
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
<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">
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
<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`}>
<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">
<span className="text-xs font-semibold text-primary">
{data.title?.trim() || `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`}
</span>
<p className="text-sm text-muted-foreground mt-0.5">{data.content}</p>
<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)}>

View File

@@ -22,8 +22,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
const mutation = useMutation({
mutationFn: (mode: NonNullable<Mode>) =>
coachingService.writingHelp({
text: text.trim(),
task_type: `${task_type}:${mode}`,
task: task_type,
draft: text.trim(),
help_type: mode,
}),
onSuccess: () => setShowResult(true),
onError: (err: Error) => {
@@ -84,20 +85,20 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
{showResult && !loading && mutation.data && activeMode === "improve" && (
<div className="space-y-3">
{mutation.data.feedback && (
{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.feedback}</p>
<p className="text-sm text-muted-foreground">{mutation.data.tips.join(" ")}</p>
</div>
)}
{mutation.data.improved && (
{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}</p>
<p className="text-sm">{mutation.data.improved_text}</p>
</div>
)}
</div>
@@ -108,17 +109,17 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
<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.grammar_notes?.length ?? 0) > 0 ? (
mutation.data.grammar_notes!.map((note, i) => (
{(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">{note}</p>
<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.feedback ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.feedback}</p>
{mutation.data.tips?.length > 0 ? (
<p className="text-xs text-muted-foreground pt-2 border-t">{mutation.data.tips.join("; ")}</p>
) : null}
</div>
)}
@@ -128,9 +129,9 @@ export default function AiWritingHelper({ text, task_type = "ielts_writing" }: P
<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.feedback}</p>
{mutation.data.improved ? (
<p className="text-sm mt-2 pt-2 border-t">{mutation.data.improved}</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>
)}

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

@@ -25,22 +25,72 @@ const AlertDialogOverlay = React.forwardRef<
));
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>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<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}
/>
</AlertDialogPortal>
));
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>) => (

View File

@@ -22,8 +22,11 @@ const badgeVariants = cva(
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
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

@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
BreadcrumbPage.displayName = "BreadcrumbPage";
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);

View File

@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
...classNames,
}}
components={{
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
}}
{...props}
/>

View File

@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Previous slide</span>
</Button>
);
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
<span className="sr-only">Next slide</span>
</Button>
);

View File

@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</ContextMenuPrimitive.SubTrigger>
));
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;

View File

@@ -27,28 +27,86 @@ const DialogOverlay = React.forwardRef<
));
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>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<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}
>
{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>
));
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>) => (

View File

@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;

View File

@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
</MenubarPrimitive.SubTrigger>
));
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;

View File

@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4" />
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";

View File

@@ -49,21 +49,61 @@ const sheetVariants = cva(
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
VariantProps<typeof sheetVariants> {
/**
* Optional accessible description rendered visually-hidden. Falls back to a
* generic string when no `SheetDescription` is found in the subtree, which
* keeps Radix happy without forcing every call-site to add one.
*/
description?: React.ReactNode;
}
function hasSheetDescription(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 === SheetPrimitive.Description ||
(type && type.displayName === SheetPrimitive.Description.displayName)
) {
found = true;
return;
}
const nested = (child.props as { children?: React.ReactNode } | undefined)
?.children;
if (nested !== undefined && hasSheetDescription(nested)) {
found = true;
}
});
return found;
}
const SheetContent = React.forwardRef<React.ElementRef<typeof SheetPrimitive.Content>, SheetContentProps>(
({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary 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>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
),
({ side = "right", className, children, description, ...props }, ref) => {
const needsFallback =
!props["aria-describedby"] &&
description === undefined &&
!hasSheetDescription(children);
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
{description !== undefined && (
<SheetPrimitive.Description className="sr-only">{description}</SheetPrimitive.Description>
)}
{needsFallback && (
<SheetPrimitive.Description className="sr-only">Sheet content</SheetPrimitive.Description>
)}
{children}
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity data-[state=open]:bg-secondary 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>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
},
);
SheetContent.displayName = SheetPrimitive.Content.displayName;

View File

@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
Sidebar.displayName = "Sidebar";
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
({ className, onClick, ...props }, ref) => {
({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
const { toggleSidebar } = useSidebar();
return (
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
data-sidebar="trigger"
variant="ghost"
size="icon"
aria-label={ariaLabel ?? "Toggle sidebar"}
className={cn("h-7 w-7", className)}
onClick={(event) => {
onClick?.(event);
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
}}
{...props}
>
<PanelLeft />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeft className="rtl:rotate-180" />
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
</Button>
);
},

View File

@@ -0,0 +1,234 @@
import { ReactNode, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ArrowLeft, ArrowRight, Check, ChevronLeft } from "lucide-react";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
/**
* Generic multi-step wizard shell.
*
* A wizard is defined as an ordered array of {@link WizardStepDef} items.
* Each step owns its own piece of the accumulated state and returns a node
* that renders the form. The shell handles:
*
* - rendering the numbered stepper (with `completed` / `active` styles)
* - Back / Next navigation
* - a final "Finish" button that calls `onFinish(state)`
* - per-step validation via `step.validate(state)` → return an error
* message or `null` when valid
* - a persistent progress bar
*
* Keeping this shell free of domain logic means every scenario wizard
* (rubric, course, structure, …) is just a tiny file that defines steps
* and wires the final submit to the right service.
*/
export interface WizardStepDef<TState> {
id: string;
/** i18n key for the step title. */
titleKey: string;
/** Optional i18n key for a short description shown under the title. */
descriptionKey?: string;
/**
* Render the step's form. Call `update(patch)` to merge fields into the
* shared state. Do **not** call `onNext` directly — the shell handles
* Next/Back; the render function should just update local fields.
*/
render: (props: StepRenderProps<TState>) => ReactNode;
/**
* Synchronous validator. Return a human-readable error message that will
* be displayed under the form and block Next, or `null` when valid.
*/
validate?: (state: TState) => string | null;
}
export interface StepRenderProps<TState> {
state: TState;
update: (patch: Partial<TState>) => void;
error: string | null;
}
export interface StepWizardProps<TState> {
/** i18n key for the wizard heading. */
titleKey: string;
/** i18n key for the short lead paragraph shown under the heading. */
subtitleKey?: string;
/** Optional back-link to the hub. */
backTo?: string;
backLabelKey?: string;
steps: WizardStepDef<TState>[];
initialState: TState;
/** Called once the user clicks Finish on the last step. */
onFinish: (state: TState) => Promise<void> | void;
/** i18n key for the Finish button label. Defaults to "wizard.finish". */
finishLabelKey?: string;
/** Disable all form controls (used by consumers while submitting). */
submitting?: boolean;
}
export function StepWizard<TState>({
titleKey,
subtitleKey,
backTo,
backLabelKey = "wizard.backToHub",
steps,
initialState,
onFinish,
finishLabelKey = "wizard.finish",
submitting = false,
}: StepWizardProps<TState>) {
const { t } = useTranslation();
const [index, setIndex] = useState(0);
const [state, setState] = useState<TState>(initialState);
const [error, setError] = useState<string | null>(null);
const [submittingInternal, setSubmittingInternal] = useState(false);
const busy = submitting || submittingInternal;
const current = steps[index];
const isLast = index === steps.length - 1;
const progress = Math.round(((index + 1) / steps.length) * 100);
const update = (patch: Partial<TState>) => {
setState((prev) => ({ ...prev, ...patch }));
// Clear validation error as soon as the user starts typing again.
if (error) setError(null);
};
const validateAndAdvance = async () => {
const msg = current.validate?.(state) ?? null;
if (msg) {
setError(msg);
return;
}
setError(null);
if (!isLast) {
setIndex((i) => i + 1);
return;
}
// Last step: submit.
try {
setSubmittingInternal(true);
await onFinish(state);
} finally {
setSubmittingInternal(false);
}
};
const stepStatus = useMemo(
() =>
steps.map((_, i) => {
if (i < index) return "done" as const;
if (i === index) return "active" as const;
return "upcoming" as const;
}),
[steps, index],
);
return (
<div className="mx-auto max-w-3xl space-y-6">
{/* Heading + back link */}
<div className="space-y-2">
{backTo && (
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to={backTo} className="flex items-center gap-1">
<ChevronLeft className="h-4 w-4" />
{t(backLabelKey)}
</Link>
</Button>
)}
<h1 className="text-2xl font-semibold tracking-tight">{t(titleKey)}</h1>
{subtitleKey && <p className="text-muted-foreground">{t(subtitleKey)}</p>}
</div>
{/* Stepper */}
<ol className="flex items-center gap-2 overflow-x-auto pb-2" role="list">
{steps.map((step, i) => {
const s = stepStatus[i];
return (
<li key={step.id} className="flex items-center gap-2 shrink-0">
<div
className={cn(
"flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold",
s === "done" && "bg-primary text-primary-foreground",
s === "active" && "bg-primary/10 text-primary ring-2 ring-primary",
s === "upcoming" && "bg-muted text-muted-foreground",
)}
aria-current={s === "active" ? "step" : undefined}
>
{s === "done" ? <Check className="h-4 w-4" /> : i + 1}
</div>
<span
className={cn(
"text-sm whitespace-nowrap",
s === "active" ? "font-medium" : "text-muted-foreground",
)}
>
{t(step.titleKey)}
</span>
{i < steps.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
</li>
);
})}
</ol>
{/* Progress bar */}
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all"
style={{ width: `${progress}%` }}
aria-hidden
/>
</div>
{/* Active step */}
<Card>
<CardHeader className="pb-2">
<h2 className="text-lg font-semibold">{t(current.titleKey)}</h2>
{current.descriptionKey && (
<p className="text-sm text-muted-foreground">{t(current.descriptionKey)}</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{current.render({ state, update, error })}
{error && (
<div
role="alert"
className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{error}
</div>
)}
</CardContent>
</Card>
{/* Navigation */}
<div className="flex items-center justify-between gap-2">
<Button
variant="outline"
onClick={() => setIndex((i) => Math.max(0, i - 1))}
disabled={busy || index === 0}
>
<ArrowLeft className="mr-1 h-4 w-4" />
{t("wizard.back")}
</Button>
<div className="text-xs text-muted-foreground tabular-nums">
{t("wizard.stepOf", { current: index + 1, total: steps.length })}
</div>
<Button onClick={validateAndAdvance} disabled={busy}>
{isLast ? t(finishLabelKey) : t("wizard.next")}
{!isLast && <ArrowRight className="ml-1 h-4 w-4" />}
</Button>
</div>
</div>
);
}
export default StepWizard;

View File

@@ -4,241 +4,246 @@ export const queryKeys = {
},
users: {
all: ["users"] as const,
list: (params: Record<string, unknown>) => ["users", "list", params] as const,
list: (params: object) => ["users", "list", params] as const,
detail: (id: number) => ["users", id] as const,
},
entities: {
all: ["entities"] as const,
list: (params?: Record<string, unknown>) => ["entities", "list", params] as const,
list: (params?: object) => ["entities", "list", params] as const,
detail: (id: number) => ["entities", id] as const,
roles: (entityId: number) => ["entities", entityId, "roles"] as const,
permissions: (entityId: number) => ["entities", entityId, "permissions"] as const,
},
exams: {
all: ["exams"] as const,
list: (module: string, params?: Record<string, unknown>) => ["exams", module, params] as const,
list: (module: string, params?: object) => ["exams", module, params] as const,
detail: (module: string, id: number) => ["exams", module, id] as const,
rubrics: (params?: Record<string, unknown>) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: Record<string, unknown>) => ["exams", "rubric-groups", params] as const,
structures: (params?: Record<string, unknown>) => ["exams", "structures", params] as const,
rubrics: (params?: object) => ["exams", "rubrics", params] as const,
rubricGroups: (params?: object) => ["exams", "rubric-groups", params] as const,
structures: (params?: object) => ["exams", "structures", params] as const,
avatars: ["exams", "avatars"] as const,
reviewQueue: (params?: object) => ["exams", "review", "queue", params] as const,
reviewDetail: (id: number) => ["exams", "review", "detail", id] as const,
},
assignments: {
all: ["assignments"] as const,
list: (params?: Record<string, unknown>) => ["assignments", "list", params] as const,
list: (params?: object) => ["assignments", "list", params] as const,
detail: (id: number) => ["assignments", id] as const,
},
classrooms: {
all: ["classrooms"] as const,
list: (params?: Record<string, unknown>) => ["classrooms", "list", params] as const,
list: (params?: object) => ["classrooms", "list", params] as const,
detail: (id: number) => ["classrooms", id] as const,
},
stats: {
sessions: (params?: Record<string, unknown>) => ["stats", "sessions", params] as const,
stats: (params?: Record<string, unknown>) => ["stats", "stats", params] as const,
statistical: (params?: Record<string, unknown>) => ["stats", "statistical", params] as const,
performance: (params?: Record<string, unknown>) => ["stats", "performance", params] as const,
sessions: (params?: object) => ["stats", "sessions", params] as const,
stats: (params?: object) => ["stats", "stats", params] as const,
statistical: (params?: object) => ["stats", "statistical", params] as const,
performance: (params?: object) => ["stats", "performance", params] as const,
},
training: {
all: ["training"] as const,
tips: (params?: Record<string, unknown>) => ["training", "tips", params] as const,
tips: (params?: object) => ["training", "tips", params] as const,
walkthroughs: (module?: string) => ["training", "walkthroughs", module] as const,
},
subscriptions: {
packages: ["subscriptions", "packages"] as const,
payments: (params?: Record<string, unknown>) => ["subscriptions", "payments", params] as const,
discounts: (params?: Record<string, unknown>) => ["subscriptions", "discounts", params] as const,
payments: (params?: object) => ["subscriptions", "payments", params] as const,
discounts: (params?: object) => ["subscriptions", "discounts", params] as const,
},
tickets: {
all: ["tickets"] as const,
list: (params?: Record<string, unknown>) => ["tickets", "list", params] as const,
list: (params?: object) => ["tickets", "list", params] as const,
assigned: ["tickets", "assigned"] as const,
},
approvals: {
all: ["approvals"] as const,
list: (params?: Record<string, unknown>) => ["approvals", "list", params] as const,
list: (params?: object) => ["approvals", "list", params] as const,
},
taxonomy: {
subjects: ["taxonomy", "subjects"] as const,
subject: (id: number) => ["taxonomy", "subjects", id] as const,
tree: (subjectId: number) => ["taxonomy", "tree", subjectId] as const,
domains: (params?: Record<string, unknown>) => ["taxonomy", "domains", params] as const,
topics: (params?: Record<string, unknown>) => ["taxonomy", "topics", params] as const,
domains: (params?: object) => ["taxonomy", "domains", params] as const,
topics: (params?: object) => ["taxonomy", "topics", params] as const,
},
adaptive: {
proficiency: (params?: Record<string, unknown>) => ["adaptive", "proficiency", params] as const,
proficiency: (params?: object) => ["adaptive", "proficiency", params] as const,
summary: ["adaptive", "proficiency", "summary"] as const,
plan: (params?: Record<string, unknown>) => ["adaptive", "plan", params] as const,
plan: (params?: object) => ["adaptive", "plan", params] as const,
topicContent: (topicId: number) => ["adaptive", "topic", topicId, "content"] as const,
diagnostic: (sessionId: number) => ["adaptive", "diagnostic", sessionId] as const,
},
resources: {
all: ["resources"] as const,
list: (params?: Record<string, unknown>) => ["resources", "list", params] as const,
list: (params?: object) => ["resources", "list", params] as const,
},
lms: {
courses: (params?: Record<string, unknown>) => ["lms", "courses", params] as const,
courses: (params?: object) => ["lms", "courses", params] as const,
course: (id: number) => ["lms", "courses", id] as const,
students: (params?: Record<string, unknown>) => ["lms", "students", params] as const,
teachers: (params?: Record<string, unknown>) => ["lms", "teachers", params] as const,
batches: (params?: Record<string, unknown>) => ["lms", "batches", params] as const,
myCourses: ["lms", "my-courses"] as const,
students: (params?: object) => ["lms", "students", params] as const,
teachers: (params?: object) => ["lms", "teachers", params] as const,
batches: (params?: object) => ["lms", "batches", params] as const,
batch: (id: number) => ["lms", "batches", id] as const,
timetable: (params?: Record<string, unknown>) => ["lms", "timetable", params] as const,
attendance: (params?: Record<string, unknown>) => ["lms", "attendance", params] as const,
grades: (params?: Record<string, unknown>) => ["lms", "grades", params] as const,
timetable: (params?: object) => ["lms", "timetable", params] as const,
attendance: (params?: object) => ["lms", "attendance", params] as const,
grades: (params?: object) => ["lms", "grades", params] as const,
},
analytics: {
student: (params?: Record<string, unknown>) => ["analytics", "student", params] as const,
class: (params?: Record<string, unknown>) => ["analytics", "class", params] as const,
subject: (params?: Record<string, unknown>) => ["analytics", "subject", params] as const,
contentGaps: (params?: Record<string, unknown>) => ["analytics", "content-gaps", params] as const,
student: (params?: object) => ["analytics", "student", params] as const,
class: (params?: object) => ["analytics", "class", params] as const,
subject: (params?: object) => ["analytics", "subject", params] as const,
contentGaps: (params?: object) => ["analytics", "content-gaps", params] as const,
alerts: ["analytics", "alerts"] as const,
},
academicYears: {
all: ["academic-years"] as const,
list: (params?: Record<string, unknown>) => ["academic-years", "list", params] as const,
list: (params?: object) => ["academic-years", "list", params] as const,
detail: (id: number) => ["academic-years", id] as const,
},
academicTerms: {
all: ["academic-terms"] as const,
list: (params?: Record<string, unknown>) => ["academic-terms", "list", params] as const,
list: (params?: object) => ["academic-terms", "list", params] as const,
detail: (id: number) => ["academic-terms", id] as const,
},
departments: {
all: ["departments"] as const,
list: (params?: Record<string, unknown>) => ["departments", "list", params] as const,
list: (params?: object) => ["departments", "list", params] as const,
detail: (id: number) => ["departments", id] as const,
},
admissionRegisters: {
all: ["admission-registers"] as const,
list: (params?: Record<string, unknown>) => ["admission-registers", "list", params] as const,
list: (params?: object) => ["admission-registers", "list", params] as const,
detail: (id: number) => ["admission-registers", id] as const,
},
admissions: {
all: ["admissions"] as const,
list: (params?: Record<string, unknown>) => ["admissions", "list", params] as const,
list: (params?: object) => ["admissions", "list", params] as const,
detail: (id: number) => ["admissions", id] as const,
},
instExamSessions: {
all: ["inst-exam-sessions"] as const,
list: (params?: Record<string, unknown>) => ["inst-exam-sessions", "list", params] as const,
list: (params?: object) => ["inst-exam-sessions", "list", params] as const,
detail: (id: number) => ["inst-exam-sessions", id] as const,
},
examTypes: {
all: ["exam-types"] as const,
list: (params?: Record<string, unknown>) => ["exam-types", "list", params] as const,
list: (params?: object) => ["exam-types", "list", params] as const,
},
gradeConfigs: {
all: ["grade-configs"] as const,
list: (params?: Record<string, unknown>) => ["grade-configs", "list", params] as const,
list: (params?: object) => ["grade-configs", "list", params] as const,
},
resultTemplates: {
all: ["result-templates"] as const,
list: (params?: Record<string, unknown>) => ["result-templates", "list", params] as const,
list: (params?: object) => ["result-templates", "list", params] as const,
},
marksheets: {
all: ["marksheets"] as const,
list: (params?: Record<string, unknown>) => ["marksheets", "list", params] as const,
list: (params?: object) => ["marksheets", "list", params] as const,
detail: (id: number) => ["marksheets", id] as const,
},
courseAssignments: {
all: ["course-assignments"] as const,
list: (params?: Record<string, unknown>) => ["course-assignments", "list", params] as const,
list: (params?: object) => ["course-assignments", "list", params] as const,
detail: (id: number) => ["course-assignments", id] as const,
},
assignmentTypes: {
all: ["assignment-types"] as const,
list: (params?: Record<string, unknown>) => ["assignment-types", "list", params] as const,
list: (params?: object) => ["assignment-types", "list", params] as const,
},
subjectRegistrations: {
all: ["subject-registrations"] as const,
list: (params?: Record<string, unknown>) => ["subject-registrations", "list", params] as const,
available: (params?: Record<string, unknown>) => ["subject-registrations", "available", params] as const,
list: (params?: object) => ["subject-registrations", "list", params] as const,
available: (params?: object) => ["subject-registrations", "available", params] as const,
},
courseCompletion: (courseId: number) => ["course-completion", courseId] as const,
chapters: (courseId: number) => ["chapters", "list", courseId] as const,
chapter: (id: number) => ["chapters", "detail", id] as const,
chapterMaterials: (chapterId: number) => ["chapter-materials", chapterId] as const,
chapterProgress: (chapterId: number) => ["chapter-progress", chapterId] as const,
discussionBoards: (params?: Record<string, unknown>) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: Record<string, unknown>) => ["posts", boardId, params] as const,
announcements: (params?: Record<string, unknown>) => ["announcements", params] as const,
messages: (params?: Record<string, unknown>) => ["messages", params] as const,
sentMessages: (params?: Record<string, unknown>) => ["messages", "sent", params] as const,
discussionBoards: (params?: object) => ["discussion-boards", params] as const,
posts: (boardId: number, params?: object) => ["posts", boardId, params] as const,
announcements: (params?: object) => ["announcements", params] as const,
messages: (params?: object) => ["messages", params] as const,
sentMessages: (params?: object) => ["messages", "sent", params] as const,
unreadMessages: ["messages", "unread-count"] as const,
notifications: (params?: Record<string, unknown>) => ["notifications", params] as const,
notifications: (params?: object) => ["notifications", params] as const,
unreadNotifications: ["notifications", "unread-count"] as const,
notificationRules: ["notification-rules"] as const,
notificationPreferences: ["notification-preferences"] as const,
faqCategories: (audience?: string) => ["faq", "categories", audience] as const,
faqItems: (params?: Record<string, unknown>) => ["faq", "items", params] as const,
faqItems: (params?: object) => ["faq", "items", params] as const,
studentLeaves: {
all: ["student-leaves"] as const,
list: (params?: Record<string, unknown>) => ["student-leaves", "list", params] as const,
list: (params?: object) => ["student-leaves", "list", params] as const,
},
studentLeaveTypes: {
all: ["student-leave-types"] as const,
list: (params?: Record<string, unknown>) => ["student-leave-types", "list", params] as const,
list: (params?: object) => ["student-leave-types", "list", params] as const,
},
feesPlans: {
all: ["fees-plans"] as const,
list: (params?: Record<string, unknown>) => ["fees-plans", "list", params] as const,
list: (params?: object) => ["fees-plans", "list", params] as const,
detail: (id: number) => ["fees-plans", id] as const,
},
studentFees: {
all: ["student-fees"] as const,
list: (params?: Record<string, unknown>) => ["student-fees", "list", params] as const,
list: (params?: object) => ["student-fees", "list", params] as const,
},
feesTerms: {
all: ["fees-terms"] as const,
list: (params?: Record<string, unknown>) => ["fees-terms", "list", params] as const,
list: (params?: object) => ["fees-terms", "list", params] as const,
},
lessons: {
all: ["lessons"] as const,
list: (params?: Record<string, unknown>) => ["lessons", "list", params] as const,
list: (params?: object) => ["lessons", "list", params] as const,
},
gradebooks: {
all: ["gradebooks"] as const,
list: (params?: Record<string, unknown>) => ["gradebooks", "list", params] as const,
list: (params?: object) => ["gradebooks", "list", params] as const,
},
gradebookLines: {
all: ["gradebook-lines"] as const,
list: (params?: Record<string, unknown>) => ["gradebook-lines", "list", params] as const,
list: (params?: object) => ["gradebook-lines", "list", params] as const,
},
gradingAssignments: {
all: ["grading-assignments"] as const,
list: (params?: Record<string, unknown>) => ["grading-assignments", "list", params] as const,
list: (params?: object) => ["grading-assignments", "list", params] as const,
},
studentProgress: {
all: ["student-progress"] as const,
list: (params?: Record<string, unknown>) => ["student-progress", "list", params] as const,
list: (params?: object) => ["student-progress", "list", params] as const,
detail: (id: number) => ["student-progress", "detail", id] as const,
},
libraryMedia: {
all: ["library-media"] as const,
list: (params?: Record<string, unknown>) => ["library-media", "list", params] as const,
list: (params?: object) => ["library-media", "list", params] as const,
},
libraryMovements: {
all: ["library-movements"] as const,
list: (params?: Record<string, unknown>) => ["library-movements", "list", params] as const,
list: (params?: object) => ["library-movements", "list", params] as const,
},
libraryCards: {
all: ["library-cards"] as const,
list: (params?: Record<string, unknown>) => ["library-cards", "list", params] as const,
list: (params?: object) => ["library-cards", "list", params] as const,
},
activities: {
all: ["activities"] as const,
list: (params?: Record<string, unknown>) => ["activities", "list", params] as const,
list: (params?: object) => ["activities", "list", params] as const,
},
activityTypes: {
all: ["activity-types"] as const,
list: (params?: Record<string, unknown>) => ["activity-types", "list", params] as const,
list: (params?: object) => ["activity-types", "list", params] as const,
},
facilities: {
all: ["facilities"] as const,
list: (params?: Record<string, unknown>) => ["facilities", "list", params] as const,
list: (params?: object) => ["facilities", "list", params] as const,
},
assets: {
all: ["assets"] as const,
list: (params?: Record<string, unknown>) => ["assets", "list", params] as const,
list: (params?: object) => ["assets", "list", params] as const,
},
signup: {
goals: ["signup", "goals"] as const,
@@ -255,7 +260,7 @@ export const queryKeys = {
},
ieltsExam: {
skills: (examId: number) => ["ielts-exam", examId, "skills"] as const,
contentPool: (examId: number, filters?: Record<string, unknown>) => ["ielts-exam", examId, "content-pool", filters] as const,
contentPool: (examId: number, filters?: object) => ["ielts-exam", examId, "content-pool", filters] as const,
validation: (examId: number) => ["ielts-exam", examId, "validation"] as const,
},
examSession: {
@@ -263,7 +268,7 @@ export const queryKeys = {
status: (examId: number) => ["exam-session", examId, "status"] as const,
},
grading: {
queue: (params?: Record<string, unknown>) => ["grading", "queue", params] as const,
queue: (params?: object) => ["grading", "queue", params] as const,
response: (attemptId: number, skill: string) => ["grading", attemptId, "response", skill] as const,
rubric: (attemptId: number, skill: string) => ["grading", attemptId, "rubric", skill] as const,
},
@@ -279,11 +284,11 @@ export const queryKeys = {
englishTaxonomy: ["ai-course", "english", "taxonomy"] as const,
},
entityOnboarding: {
credentials: (params?: Record<string, unknown>) => ["entity-onboarding", "credentials", params] as const,
credentials: (params?: object) => ["entity-onboarding", "credentials", params] as const,
},
adaptiveEngine: {
dashboard: ["adaptive-engine", "dashboard"] as const,
students: (params?: Record<string, unknown>) => ["adaptive-engine", "students", params] as const,
students: (params?: object) => ["adaptive-engine", "students", params] as const,
signals: (studentId: number) => ["adaptive-engine", "signals", studentId] as const,
ability: (studentId: number) => ["adaptive-engine", "ability", studentId] as const,
settings: ["adaptive-engine", "settings"] as const,
@@ -296,7 +301,7 @@ export const queryKeys = {
current: ["branding", "current"] as const,
},
scoreRelease: {
pending: (params?: Record<string, unknown>) => ["score-release", "pending", params] as const,
pending: (params?: object) => ["score-release", "pending", params] as const,
},
verification: {
verify: (hash: string) => ["verification", hash] as const,

View File

@@ -0,0 +1,70 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiFeedbackService } from "@/services/ai-feedback.service";
import type {
AIFeedbackStatus,
AIFeedbackSubjectType,
AIFeedbackSubmitInput,
} from "@/types/ai-feedback";
const KEY_ROOT = ["ai", "feedback"] as const;
export function useAIFeedbackSummary(
subjectType: AIFeedbackSubjectType | undefined,
subjectId: number | undefined,
) {
return useQuery({
enabled: !!subjectType && !!subjectId,
queryKey: [...KEY_ROOT, "summary", subjectType, subjectId] as const,
queryFn: () =>
aiFeedbackService.summary(
subjectType as AIFeedbackSubjectType,
subjectId as number,
),
});
}
export function useSubmitAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIFeedbackSubmitInput) => aiFeedbackService.submit(input),
onSuccess: (_res, vars) => {
qc.invalidateQueries({
queryKey: [...KEY_ROOT, "summary", vars.subject_type, vars.subject_id],
});
qc.invalidateQueries({ queryKey: [...KEY_ROOT, "list"] });
},
});
}
export function useAIFeedbackList(
params: {
status?: AIFeedbackStatus;
rating?: "up" | "down";
subject_type?: AIFeedbackSubjectType;
prompt_key?: string;
page?: number;
size?: number;
} = {},
) {
return useQuery({
queryKey: [...KEY_ROOT, "list", params] as const,
queryFn: () => aiFeedbackService.list(params),
});
}
export function useResolveAIFeedback() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
status,
notes,
}: {
id: number;
status: Exclude<AIFeedbackStatus, "open">;
notes?: string;
}) => aiFeedbackService.resolve(id, status, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}

View File

@@ -0,0 +1,52 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiPromptService } from "@/services/ai-prompt.service";
import type { AIPromptCreateInput } from "@/types/ai-prompt";
const KEY_ROOT = ["ai", "prompts"] as const;
export function useAIPromptKeys(params: { search?: string; page?: number; size?: number } = {}) {
return useQuery({
queryKey: [...KEY_ROOT, "keys", params] as const,
queryFn: () => aiPromptService.listKeys(params),
});
}
export function useAIPromptVersions(key: string | undefined) {
return useQuery({
enabled: !!key,
queryKey: [...KEY_ROOT, "versions", key] as const,
queryFn: () => aiPromptService.listVersions(key as string),
});
}
export function useAIPrompt(id: number | undefined) {
return useQuery({
enabled: !!id,
queryKey: [...KEY_ROOT, "detail", id] as const,
queryFn: () => aiPromptService.get(id as number),
});
}
export function useCreateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (input: AIPromptCreateInput) => aiPromptService.create(input),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useActivateAIPrompt() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => aiPromptService.activate(id),
onSuccess: () => qc.invalidateQueries({ queryKey: KEY_ROOT }),
});
}
export function useRenderAIPrompt() {
return useMutation({
mutationFn: ({ id, variables }: { id: number; variables: Record<string, string> }) =>
aiPromptService.render(id, variables),
});
}

View File

@@ -66,7 +66,10 @@ export function useGenerateTerms() {
const qc = useQueryClient();
return useMutation({
mutationFn: (yearId: number) => academicService.generateTerms(yearId),
onSuccess: () => qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.academicTerms.all });
qc.invalidateQueries({ queryKey: queryKeys.academicYears.all });
},
});
}

View File

@@ -1,7 +1,10 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { aiCourseService } from "@/services/ai-course.service";
import type { ExaminerReview } from "@/types";
import {
aiCourseService,
type AiCourseCreateEnglishRequest,
type AiCourseCreateIeltsRequest,
} from "@/services/ai-course.service";
export function useAiCourse(courseId: number | undefined) {
return useQuery({
@@ -22,7 +25,7 @@ export function useAiCourseTracks(courseId: number | undefined) {
export function useCreateEnglishCourse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { current_level: string; target_level: string; learning_style: string[] }) =>
mutationFn: (data: AiCourseCreateEnglishRequest) =>
aiCourseService.createEnglish(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
@@ -33,7 +36,7 @@ export function useCreateEnglishCourse() {
export function useCreateIeltsCourse() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { exam_type: string; target_band: number; skills: string[] }) =>
mutationFn: (data: AiCourseCreateIeltsRequest) =>
aiCourseService.createIelts(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
@@ -63,8 +66,8 @@ export function useApproveQuality() {
export function useRejectQuality() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, notes }: { courseId: number; notes: string }) =>
aiCourseService.rejectQuality(courseId, notes),
mutationFn: ({ courseId, reason }: { courseId: number; reason: string }) =>
aiCourseService.rejectQuality(courseId, reason),
onSuccess: (_d, { courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.quality(courseId) });
},
@@ -89,7 +92,8 @@ export function useIeltsValidation(courseId: number | undefined) {
export function useSubmitExaminerReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: ExaminerReview) => aiCourseService.submitExaminerReview(data),
mutationFn: (data: { logId: number; action: string; examiner_notes?: string }) =>
aiCourseService.submitExaminerReview(data.logId, { action: data.action, examiner_notes: data.examiner_notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["ai-course"] });
},

View File

@@ -120,6 +120,19 @@ export function useUploadMaterial() {
});
}
export function useCreateMaterialFromResource() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ chapterId, resourceId, name }: { chapterId: number; resourceId: number; name?: string }) =>
coursewareService.createMaterialFromResource(chapterId, resourceId, name),
onSuccess: (_, { chapterId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterMaterials(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
},
});
}
export function useDeleteMaterial() {
const qc = useQueryClient();
return useMutation({
@@ -145,13 +158,24 @@ export function useMarkMaterialViewed() {
});
}
export function useCourseCompletion(courseId: number) {
return useQuery({
queryKey: queryKeys.courseCompletion(courseId),
queryFn: () => coursewareService.getCourseCompletion(courseId),
enabled: !!courseId,
});
}
export function useCompleteChapter() {
const qc = useQueryClient();
return useMutation({
mutationFn: (chapterId: number) => coursewareService.markChapterComplete(chapterId),
onSuccess: (_, chapterId) => {
mutationFn: ({ chapterId, courseId }: { chapterId: number; courseId: number }) =>
coursewareService.markChapterComplete(chapterId),
onSuccess: (_, { chapterId, courseId }) => {
qc.invalidateQueries({ queryKey: queryKeys.chapterProgress(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.chapter(chapterId) });
qc.invalidateQueries({ queryKey: queryKeys.courseCompletion(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}

View File

@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
examReviewService,
type ExamReviewQueueParams,
} from "@/services/exam-review.service";
import { queryKeys } from "./keys";
export function useExamReviewQueue(params: ExamReviewQueueParams = {}) {
return useQuery({
queryKey: queryKeys.exams.reviewQueue(params),
queryFn: () => examReviewService.queue(params),
});
}
export function useExamReviewDetail(examId: number | undefined) {
return useQuery({
enabled: !!examId,
queryKey: queryKeys.exams.reviewDetail(examId ?? -1),
queryFn: () => examReviewService.detail(examId as number),
});
}
export function useApproveExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes?: string }) =>
examReviewService.approve(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}
export function useRejectExamReview() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ examId, notes }: { examId: number; notes: string }) =>
examReviewService.reject(examId, notes),
onSuccess: (_data, { examId }) => {
qc.invalidateQueries({ queryKey: ["exams", "review"] });
qc.invalidateQueries({ queryKey: queryKeys.exams.reviewDetail(examId) });
},
});
}

View File

@@ -29,6 +29,7 @@ export function useExamAutoSave() {
export function useExamSubmit() {
return useMutation({
mutationFn: (examId: number) => examSessionService.submit(examId),
mutationFn: (data: { examId: number; attempt_id: number; answers: { question_id: number; answer: unknown }[] }) =>
examSessionService.submit(data.examId, { attempt_id: data.attempt_id, answers: data.answers }),
});
}

View File

@@ -47,7 +47,7 @@ export function useIeltsAutoAssemble() {
});
}
export function useIeltsValidation(examId: number) {
export function useIeltsExamValidation(examId: number) {
return useQuery({
queryKey: queryKeys.ieltsExam.validation(examId),
queryFn: () => ieltsExamService.validate(examId),

View File

@@ -1,9 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { feesService } from "@/services/fees.service";
import { feesService, type FeesPlanFilters } from "@/services/fees.service";
import type { PaginationParams } from "@/types";
export function useFeesPlans(params?: PaginationParams) {
export function useFeesPlans(params?: FeesPlanFilters) {
return useQuery({
queryKey: queryKeys.feesPlans.list(params),
queryFn: () => feesService.listFeesPlans(params),
@@ -18,7 +18,7 @@ export function useFeesPlan(id: number) {
});
}
export function useStudentFees(params?: PaginationParams) {
export function useStudentFees(params?: FeesPlanFilters) {
return useQuery({
queryKey: queryKeys.studentFees.list(params),
queryFn: () => feesService.listStudentFees(params),

View File

@@ -11,10 +11,11 @@ export function useGradebooks(params?: PaginationParams) {
});
}
export function useGradebookLines(params?: PaginationParams) {
export function useGradebookLines(params?: PaginationParams & { gradebook_id?: number }) {
return useQuery({
queryKey: queryKeys.gradebookLines.list(params),
queryFn: () => gradebookService.listGradebookLines(params),
enabled: params?.gradebook_id ? !!params.gradebook_id : true,
});
}

View File

@@ -1,7 +1,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { lmsService } from "@/services";
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest } from "@/types";
import type { PaginationParams, CourseCreateRequest, LmsStudentCreateRequest, LmsTeacherCreateRequest, EnrollStudentRequest, BulkEnrollRequest } from "@/types";
export function useCourses(params?: PaginationParams & { status?: string }) {
return useQuery({
@@ -26,6 +26,38 @@ export function useCreateCourse() {
});
}
export function useMyEnrolledCourses() {
return useQuery({
queryKey: queryKeys.lms.myCourses,
queryFn: () => lmsService.getMyEnrolledCourses(),
});
}
export function useEnrollStudent() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ studentId, data }: { studentId: number; data: EnrollStudentRequest }) =>
lmsService.enrollStudent(studentId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useBulkEnroll() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ courseId, data }: { courseId: number; data: BulkEnrollRequest }) =>
lmsService.bulkEnrollInCourse(courseId, data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "students"] });
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
qc.invalidateQueries({ queryKey: queryKeys.lms.myCourses });
},
});
}
export function useStudents(params?: PaginationParams & { search?: string; batch_id?: number }) {
return useQuery({
queryKey: queryKeys.lms.students(params),

View File

@@ -1,11 +1,18 @@
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "./keys";
import { studentProgressService } from "@/services/student-progress.service";
import type { PaginationParams } from "@/types";
import { studentProgressService, type StudentProgressFilters } from "@/services/student-progress.service";
export function useStudentProgressList(params?: PaginationParams) {
export function useStudentProgressList(params?: StudentProgressFilters) {
return useQuery({
queryKey: queryKeys.studentProgress.list(params),
queryFn: () => studentProgressService.listProgress(params),
});
}
export function useStudentProgressDetail(id: number | null | undefined) {
return useQuery({
queryKey: queryKeys.studentProgress.detail(id ?? 0),
queryFn: () => studentProgressService.getProgress(id as number),
enabled: !!id,
});
}

80
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* EnCoach i18n bootstrap.
*
* We keep the translation strings in TypeScript modules (one per locale) so
* they go through type-checking and tree-shaking, and so non-translators
* can't ship a broken JSON file that blocks the build.
*
* Language selection order (product decision: English is the default UI
* language at startup; Arabic is opt-in via the language toggle):
* 1. localStorage key ``encoach-lang`` (explicit user pick, persisted)
* 2. ``en`` — fallback used on first visit, regardless of browser locale
*
* We intentionally do **not** read ``navigator.language`` anymore: an
* Arabic-locale browser should still land on the English UI the first time
* a user visits, because the QA team and the product manager both sign off
* against the English baseline. Users that actively want Arabic flip the
* toggle once and the preference is remembered from then on.
*
* RTL handling: whenever the active language switches to one of RTL_LANGS,
* we flip ``document.documentElement.dir`` so Tailwind's RTL utilities +
* Radix primitives pick up the change automatically.
*/
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import ar from "./locales/ar";
import en from "./locales/en";
const RTL_LANGS = new Set(["ar", "he", "fa", "ur"]);
export const SUPPORTED_LANGS = [
{ code: "en", label: "English" },
{ code: "ar", label: "العربية" },
] as const;
export type SupportedLang = (typeof SUPPORTED_LANGS)[number]["code"];
export const DEFAULT_LANG: SupportedLang = "en";
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources: {
en: { translation: en },
ar: { translation: ar },
},
// Explicit default — english is the baseline UI language at startup.
lng: DEFAULT_LANG,
fallbackLng: DEFAULT_LANG,
supportedLngs: SUPPORTED_LANGS.map((l) => l.code),
interpolation: { escapeValue: false },
detection: {
// Only honour the user's saved pick. Navigator/htmlTag detection was
// removed so Arabic-locale browsers don't silently boot the UI in
// Arabic before the user has chosen.
order: ["localStorage"],
caches: ["localStorage"],
lookupLocalStorage: "encoach-lang",
},
returnEmptyString: false,
});
export function applyDirectionForLang(lang: string) {
const base = lang.split("-")[0];
const dir = RTL_LANGS.has(base) ? "rtl" : "ltr";
if (typeof document !== "undefined") {
document.documentElement.dir = dir;
document.documentElement.lang = base;
}
}
if (typeof window !== "undefined") {
applyDirectionForLang(i18n.language || "en");
i18n.on("languageChanged", applyDirectionForLang);
}
export default i18n;

876
src/i18n/locales/ar.ts Normal file
View File

@@ -0,0 +1,876 @@
import type { Translations } from "./en";
/** Arabic (RTL) translations. Keep key parity with `en.ts`. */
const ar: Translations = {
common: {
cancel: "إلغاء",
save: "حفظ",
delete: "حذف",
edit: "تعديل",
close: "إغلاق",
back: "رجوع",
next: "التالي",
search: "بحث",
loading: "جارٍ التحميل…",
error: "خطأ",
retry: "إعادة المحاولة",
yes: "نعم",
no: "لا",
actions: "إجراءات",
status: "الحالة",
settings: "الإعدادات",
signOut: "تسجيل الخروج",
viewAll: "عرض الكل",
dismiss: "تجاهل",
pending: "قيد الانتظار",
active: "نشط",
inactive: "غير نشط",
available: "متاح",
unavailable: "غير متاح",
name: "الاسم",
email: "البريد الإلكتروني",
user: "المستخدم",
home: "الرئيسية",
saving: "جارٍ الحفظ…",
disabled: "معطّل",
},
auth: {
signIn: "تسجيل الدخول",
signInTitle: "تسجيل الدخول إلى EnCoach",
welcomeBack: "مرحباً بعودتك",
signInDescription: "سجّل الدخول إلى حسابك للمتابعة",
email: "البريد الإلكتروني",
emailPlaceholder: "you@example.com",
password: "كلمة المرور",
rememberMe: "تذكرني",
forgotPassword: "هل نسيت كلمة المرور؟",
needAccount: "ليس لديك حساب؟",
signUp: "إنشاء حساب",
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
missingCredentials: "يرجى إدخال البريد الإلكتروني وكلمة المرور",
loginFailedTitle: "فشل تسجيل الدخول",
errorTitle: "خطأ",
},
nav: {
smartWizard: "المعالج الذكي",
quickSetup: "الإعداد السريع",
coursePlans: "خطط المقررات (الذكاء الاصطناعي)",
adminDashboard: "لوحة الإدارة",
platformDashboard: "لوحة المنصّة",
dashboard: "لوحة التحكم",
courses: "الدورات",
myCourses: "دوراتي",
students: "الطلاب",
teachers: "المعلمون",
batches: "الدفعات",
timetable: "الجدول الدراسي",
reports: "التقارير",
assignments: "الواجبات",
examsList: "قائمة الاختبارات",
examStructures: "هياكل الاختبارات",
rubrics: "معايير التقييم",
generation: "التوليد",
reviewQueue: "قائمة المراجعة",
aiPrompts: "وكلاء الذكاء الاصطناعي والأدوات",
aiFeedback: "ملاحظات الذكاء الاصطناعي",
approvalWorkflows: "سير عمل الموافقات",
taxonomy: "التصنيف",
resources: "الموارد",
resourceLibrary: "مكتبة الموارد",
academicYears: "السنوات الأكاديمية",
departments: "الأقسام",
admissions: "القبول",
admissionRegister: "سجل القبول",
examSessions: "جلسات الاختبارات",
marksheets: "كشوفات الدرجات",
studentLeave: "إجازات الطلاب",
fees: "الرسوم والمدفوعات",
lessons: "الدروس",
gradebook: "سجل الدرجات",
studentProgress: "تقدم الطالب",
library: "المكتبة",
activities: "الأنشطة",
facilities: "المرافق",
users: "المستخدمون",
entities: "الكيانات",
classrooms: "الفصول",
userRoles: "أدوار المستخدمين",
rolesPermissions: "الأدوار والصلاحيات",
authorityMatrix: "مصفوفة الصلاحيات",
studentPerformance: "أداء الطلاب",
statsCorporate: "إحصاءات الشركات",
record: "السجل",
vocabulary: "المفردات",
grammar: "القواعد",
faqManager: "إدارة الأسئلة الشائعة",
notificationRules: "قواعد الإشعارات",
approvalConfig: "إعدادات الموافقات",
paymentRecord: "سجل المدفوعات",
tickets: "التذاكر",
settings: "الإعدادات",
profile: "الملف الشخصي",
privacy: "مركز الخصوصية",
subjectRegistration: "تسجيل المواد",
mySubjects: "موادي",
grades: "الدرجات",
attendance: "الحضور",
myJourney: "رحلتي",
discussions: "المناقشات",
messages: "الرسائل",
announcements: "الإعلانات",
},
sidebarGroup: {
overview: "نظرة عامة",
lms: "نظام التعلم",
adaptiveLearning: "التعلم التكيّفي",
institutional: "المؤسسي",
academic: "الأكاديمي",
management: "الإدارة",
reports: "التقارير",
configuration: "الإعدادات",
training: "التدريب",
support: "الدعم",
learning: "التعلم",
progress: "التقدم",
communication: "التواصل",
account: "الحساب",
teaching: "التدريس",
},
breadcrumb: {
home: "الرئيسية",
admin: "الإدارة",
dashboard: "لوحة التحكم",
platform: "المنصّة",
exam: "الاختبار",
review: "مراجعة",
ai: "الذكاء الاصطناعي",
feedback: "ملاحظات",
},
userMenu: {
profile: "الملف الشخصي",
settings: "الإعدادات",
logout: "تسجيل الخروج",
userFallback: "مستخدم",
},
chrome: {
needHelp: "هل تحتاج مساعدة؟",
ticketsTooltip: "تذاكر الدعم",
aiSearchPlaceholder: "اسأل عن أي شيء… مثال: «اعرض الطلاب ذوي الحضور المنخفض»",
aiSearching: "الذكاء الاصطناعي يبحث…",
aiRelatedQueries: "استفسارات ذات صلة",
aiNoResults: "لا توجد نتائج لـ «{{q}}». جرّب سؤالاً أو كلمة مفتاحية مختلفة.",
aiSearchFailedTitle: "فشل البحث",
aiSearchFailedDesc: "تعذّر إكمال بحث الذكاء الاصطناعي.",
portalSuffix: "البوابة",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — أطلق إمكاناتك مع منصّة مدعومة بالذكاء الاصطناعي",
toggleSidebar: "إظهار/إخفاء الشريط الجانبي",
},
notifications: {
title: "الإشعارات",
empty: "ليست لديك إشعارات جديدة.",
ariaLabel: "الإشعارات",
},
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
toggleLabel: "تبديل المظهر",
},
language: {
change: "تغيير اللغة",
label: "اللغة",
},
roles: {
student: "طالب",
teacher: "معلّم",
admin: "مسؤول",
developer: "مطوّر",
corporate: "شركة",
mastercorporate: "شركة رئيسية",
agent: "وكيل",
},
dashboard: {
greetingFallback: "الطالب",
},
studentDash: {
welcome: "مرحباً بعودتك، {{name}}!",
subtitle: "إليك نظرة عامة على تقدّمك الدراسي.",
enrolledCourses: "الدورات المسجّلة",
overallProgress: "التقدم العام",
averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة",
enrollToStart: "سجّل في دورة للبدء.",
continue: "متابعة",
start: "ابدأ",
chaptersDone: "{{done}}/{{total}} فصل مكتمل",
recentGrades: "آخر الدرجات",
noGradesYet: "لا توجد درجات بعد.",
},
teacherDash: {
title: "لوحة المعلّم",
subtitle: "نظرة عامة على نشاطك التدريسي.",
activeCourses: "الدورات النشطة",
totalStudents: "إجمالي الطلاب",
pendingGrading: "تقييمات قيد الانتظار",
avgPassRate: "متوسط نسبة النجاح",
myCourses: "دوراتي",
colCourse: "الدورة",
colStudents: "الطلاب",
colStatus: "الحالة",
recentActivity: "النشاط الأخير",
submitted: "تم الإرسال {{when}}",
pending: "قيد الانتظار",
},
adminDash: {
title: "لوحة الإدارة",
subtitle: "نظرة عامة على المنصّة والمؤشرات الرئيسية.",
addStudent: "إضافة طالب",
newCourse: "دورة جديدة",
totalStudents: "إجمالي الطلاب",
activeCourses: "الدورات النشطة",
teachers: "المعلمون",
activeBatches: "الدفعات النشطة",
openTickets: "تذاكر مفتوحة",
revenue: "الإيرادات",
departments: "الأقسام",
classrooms: "الفصول",
subjects: "المواد",
resources: "الموارد",
coursesOverview: "نظرة عامة على الدورات",
batchCapacity: "سعة الدفعات",
noCourseData: "لا توجد بيانات دورات متاحة.",
noBatchData: "لا توجد بيانات دفعات متاحة.",
quickSummary: "ملخّص سريع",
colModule: "الوحدة",
colCount: "العدد",
colStatus: "الحالة",
exams: "الاختبارات",
examSessionsCount: "{{n}} جلسة",
assignments: "الواجبات",
acrossCourses: "عبر جميع الدورات",
supportTickets: "تذاكر الدعم",
ticketsOpenCount: "{{n}} مفتوحة",
payments: "المدفوعات",
revenueTotal: "{{amount}} إجمالاً",
chartCapacity: "السعة",
chartEnrolled: "المسجّلون",
},
examPopup: {
title: "اختبارات قادمة ({{n}})",
activeNow: "متاح الآن",
from: "من:",
to: "إلى:",
startExam: "ابدأ الاختبار",
notAvailableYet: "غير متاح بعد",
willBeAvailable: "سيتاح الاختبار عندما يصبح نشطاً",
notActive: "الاختبار غير نشط حالياً",
},
errors: {
somethingWrong: "حدث خطأ ما",
unexpectedDescription: "حدث خطأ غير متوقع. يرجى تحديث الصفحة والمحاولة مرة أخرى.",
errorDetails: "تفاصيل الخطأ",
refreshPage: "تحديث الصفحة",
notFoundCode: "404",
notFoundMessage: "عذراً! الصفحة غير موجودة",
returnHome: "العودة إلى الرئيسية",
},
ai: {
assistantLabel: "مساعد الذكاء الاصطناعي",
assistantTitle: "مساعد EnCoach الذكي",
placeholder: "اسأل عن أي شيء…",
send: "إرسال",
thinking: "يفكّر…",
emptyLine1: "اسألني عن أي شيء يخص المنصّة،",
emptyLine2: "أو اختر إجراءً سريعاً من الأعلى.",
fallbackReply: "عذراً، تعذّر الوصول إلى المساعد. يرجى المحاولة مرة أخرى بعد قليل.",
replyErrorTitle: "تعذّر الحصول على ردّ",
replyErrorDesc: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
quickHealth: "ملخّص حالة المنصّة",
quickDropouts: "عرض مخاطر التسرّب",
quickCompare: "مقارنة أداء الدورات",
quickBatch: "اقتراحات لتحسين الدفعات",
tipLabel: "نصيحة الذكاء الاصطناعي",
insightLabel: "رؤية الذكاء الاصطناعي",
recommendationLabel: "توصية الذكاء الاصطناعي",
categoryTipLabel: "نصيحة {{category}} من الذكاء الاصطناعي",
loadingTip: "جارٍ تحميل النصيحة…",
loadingInsights: "جارٍ تحميل الرؤى…",
loadingAlerts: "جارٍ تحميل التنبيهات…",
couldNotLoadTip: "تعذّر تحميل النصيحة.",
couldNotLoadInsights: "تعذّر تحميل الرؤى.",
couldNotLoadAlerts: "تعذّر تحميل التنبيهات.",
noTipAvailable: "لا توجد نصيحة متاحة.",
noTipForContext: "لا توجد نصيحة لهذا السياق بعد.",
noInsightsAvailable: "لا توجد رؤى متاحة لهذا العرض.",
noAlertsRightNow: "لا توجد تنبيهات ذكاء اصطناعي حالياً.",
alertsUnavailable: "التنبيهات غير متاحة",
insightsUnavailable: "الرؤى غير متاحة",
platformInsightsTitle: "رؤى المنصّة من الذكاء الاصطناعي",
},
privacy: {
title: "مركز الخصوصية",
description:
"إدارة بياناتك الشخصية لدى EnCoach وفقاً للائحة حماية البيانات العامة وما يماثلها.",
exportTitle: "تنزيل بياناتك",
exportDescription:
"سنقوم بتجميع ملفك الشخصي، عضويات الكيانات، محاولات الاختبارات، الإجابات، ملاحظات الذكاء الاصطناعي، والتذاكر في ملف JSON واحد.",
exportButton: "تنزيل بياناتي",
exportPreparing: "جارٍ تحضير التصدير…",
exportSuccess: "تم تنزيل البيانات بنجاح",
deleteTitle: "حذف حسابي",
deleteDescription:
"سيتم إخفاء هوية ملفك الشخصي وإزالة الحقول الشخصية من سجلاتنا. سيتم الاحتفاظ بمحاولات الاختبارات (دون معلومات تعريفية) لسلامة الإحصائيات. لا يمكن التراجع عن هذا الإجراء.",
deleteButton: "محو حسابي",
deleteErasing: "جارٍ المحو…",
confirmTitle: "هل تريد محو حسابك؟",
confirmDescription:
"سنقوم بإخفاء هوية بياناتك الشخصية وتعطيل تسجيل الدخول. ستبقى التحليلات المجمعة لكنها لن تكون مرتبطة بك.",
confirmTypeDelete: "اكتب DELETE للتأكيد",
confirmErased: "تم محو حسابك. جارٍ تسجيل الخروج…",
},
feedback: {
thumbsUp: "إعجاب",
thumbsDown: "عدم إعجاب",
whatWentWrong: "ما الخطأ الذي حدث؟",
commentPlaceholder: "مثال: إجابة خاطئة، صياغة مربكة، خارج الموضوع…",
commentRequired: "من فضلك أخبرنا بالخطأ.",
submit: "إرسال الملاحظات",
},
quickSetup: {
adminTitle: "الإعداد السريع",
adminSubtitle:
"كل ما تحتاجه لإطلاق منصّة جاهزة للامتحانات بالترتيب الموصى به. يتم تعليم كل خطوة تلقائياً عند اكتمالها.",
teacherTitle: "الإعداد السريع",
teacherSubtitle: "ابدأ دورة جديدة من البداية إلى النهاية، ثم انتقل إلى المهام اليومية الشائعة.",
progressLabel: "التقدم",
recommendedFlow: "المسار الموصى به",
otherQuickCreates: "إنشاء سريع آخر",
ready: "جاهز",
start: "ابدأ",
review: "مراجعة",
open: "فتح",
helpAria: "عرض المساعدة",
admin: {
step1: {
title: "أنشئ معيار تقييم (Rubric)",
description: "حدّد معايير التقييم لمهام الكتابة والتحدّث. تُستخدم هذه المعايير لاحقاً عند توليد الامتحانات واعتمادها.",
help: "المعيار هو شبكة تقييم (درجات × عناصر). يمكنك البدء من قالب جاهز أو تركيبه من عناصر محدّدة مسبقاً.",
},
step2: {
title: "عرّف بنية الامتحان",
description: "حدّد الأقسام والمهام والأجزاء التي يجب أن يحتويها كل امتحان من هذا النوع سواء مولّداً أو مخصّصاً.",
help: "البنى تضمن الاتساق. مثلاً: بنية IELTS للكتابة تحتوي على المهمة ١ (١٥٠ كلمة) والمهمة ٢ (٢٥٠ كلمة)؛ سيرفض النظام الإرسال إن نقصت إحداهما.",
},
step3: {
title: "ولّد أو أنشئ امتحاناً",
description: "استخدم التوليد بالذكاء الاصطناعي (الأسرع) أو أنشئ امتحاناً مخصّصاً يدوياً. احفظ كمسودة أو أرسله للموافقة.",
help: "التوليد يختار بنية ومعيار تقييم ثم يُنتج الأسئلة. المنشئ المخصّص يتيح لك تحكّماً كاملاً للامتحانات التجريبية.",
},
step4: {
title: "راجع واعتمد",
description: "يعتمد المصدّقون الامتحانات قبل ظهورها للطلاب. اضبط سير العمل مرة واحدة ليتم توجيه الطلبات تلقائياً.",
help: "قائمة الاعتماد تعرض كل امتحان في انتظار المصادقة. الرفض يعيده للمؤلّف، والقبول ينشره.",
},
step5: {
title: "عيّن للطلاب",
description: "جدول الامتحان المنشور، اختر الدفعة، وأرسله. يراه الطلاب فوراً في بوّابتهم.",
help: "يمكن استهداف طلاب أفراد أو دفعات أو صفوف كاملة مع مراعاة المنطقة الزمنية.",
},
quick: {
course: { title: "دورة جديدة", description: "أنشئ هيكل دورة ليملأها المعلّمون بالفصول." },
resource: { title: "رفع مورد", description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة المشتركة." },
student: { title: "إضافة طالب", description: "أنشئ حساب طالب وعيّنه إلى دفعة." },
teacher: { title: "إضافة معلّم", description: "ادعُ معلّماً وامنحه صلاحيات التدريس." },
classroom: { title: "صف جديد", description: "جمّع الطلاب لأغراض الجدولة والحضور." },
examSession: { title: "جدولة جلسة امتحان", description: "أنشئ جلسة امتحان مراقبة لامتحان مؤسسي." },
customExam: { title: "امتحان مخصّص", description: "أنشئ امتحاناً يدوياً من الصفر بتحكّم كامل." },
ticket: { title: "فتح تذكرة", description: "افتح تذكرة دعم نيابة عن مستخدم." },
},
},
teacher: {
step1: {
title: "أنشئ دورة",
description: "امنح دورتك اسماً، اختر المادّة، وحدّد مستواها. يمكنك تعديل الفصول بعد الإنشاء.",
help: "الدورات هي الحاوية للفصول والمواد والواجبات.",
},
step2: {
title: "أضف الفصول والمحتوى",
description: "افتح دورتك وأضف فصولاً تحتوي على دروس وفيديوهات واختبارات ومهام تدريب.",
help: "الفصول تنظّم أهداف التعلّم. استخدم الورشة الذكية لإنشاء المحتوى تلقائياً.",
},
step3: {
title: "ارفع الموارد",
description: "شارك مع طلابك ملفات PDF أو صوت أو فيديو داعمة عبر المكتبة.",
help: "الملفات الكبيرة مدعومة — يقبل الخادم حتى ١٢٨ ميغابايت للرفعة الواحدة.",
},
step4: {
title: "أنشئ واجباً",
description: "حوّل امتحاناً منشوراً أو مهمّة إلى واجب بتاريخ تسليم ودفعة مستهدفة.",
help: "تظهر الواجبات تلقائياً في لوحة كل طالب وفي جدوله.",
},
step5: {
title: "تابع تقدّم الطلاب",
description: "راقب التسليمات والحضور ورؤى التعلّم التكيّفي لصفّك.",
help: "يحدّد محرك التعلّم التكيّفي الطلاب المعرّضين للخطر لتتدخّل مبكّراً.",
},
quick: {
discussion: { title: "نقاش جديد", description: "ابدأ موضوع نقاش لصفّك." },
announcement: { title: "إعلان جديد", description: "أرسل رسالة لجميع طلابك." },
attendance: { title: "تسجيل الحضور", description: "سجّل حضور اليوم لإحدى الجلسات." },
},
},
},
wizardHub: {
title: "المعالج الذكي",
subtitle:
"اختر أي سيناريو وسيرشدك المعالج خطوة بخطوة. لا حاجة للبحث في الإعدادات — كلّ ضغطة على \"التالي\" تقرّبك أكثر من الإنجاز.",
recommendedOrder: "الترتيب الموصى به",
order: {
rubric: "أنشئ معايير التقييم (الكتابة / المحادثة)",
structure: "حدّد هيكل الامتحان (الأقسام والمهام والمدد)",
generate: "أنشئ امتحاناً تلقائياً أو يدوياً",
approve: "راجع الامتحانات المعلّقة واعتمدها",
assign: "أسند الامتحانات إلى الطلاب",
},
guided: "معالجات موجّهة",
advanced: "الصفحات الكاملة (متقدّم)",
advancedBadge: "متقدّم",
aiBadge: "ذكاء اصطناعي",
startWizard: "ابدأ المعالج",
openPage: "افتح الصفحة",
cards: {
rubric: {
title: "إنشاء معيار تقييم",
description: "الاسم ← المهارة ← المعايير ← الوصف ← المراجعة. للكتابة والمحادثة فقط.",
},
examStructure: {
title: "تعريف هيكل امتحان",
description: "الاسم ← الوحدات ← مهام الكتابة ← المراجعة. قالب قابل لإعادة الاستخدام.",
},
course: {
title: "إنشاء مساق",
description: "العنوان ← المستوى والسعة ← المراجعة. الفصول تُضاف من صفحة المساق.",
},
coursePlan: {
title: "توليد خطة مقرر (ذكاء اصطناعي)",
description: "اوصف المقرر فيكتب الذكاء الاصطناعي الأهداف ونتائج التعلّم لكل مهارة ونطاق القواعد وخطة التسليم أسبوعياً، ثم يولّد المواد التعليمية الجاهزة لكل أسبوع.",
},
generation: {
title: "توليد امتحان",
description: "صفحة التوليد الكاملة بخيارات المعايير والهيكل وتحكّم في كل وحدة.",
},
approval: {
title: "مسارات الموافقة",
description: "حدّد من يراجع الامتحانات، وتابع الطلبات المعلّقة.",
},
assign: {
title: "إسناد إلى الطلاب",
description: "صفحة الجدولة الكاملة مع منتقي الطلاب والنوافذ الزمنية والمنطقة الزمنية.",
},
resource: {
title: "رفع مورد",
description: "أضف ملفات PDF أو صوت أو فيديو أو روابط إلى المكتبة.",
},
student: {
title: "إضافة طالب",
description: "أنشئ حساب طالب وسجّله في دفعة.",
},
},
},
wizard: {
back: "السابق",
next: "التالي",
finish: "إنهاء",
backToHub: "العودة إلى المعالجات",
stepOf: "الخطوة {{current}} من {{total}}",
rubric: {
title: "إنشاء معيار تقييم",
subtitle: "شبكة تقييم لمهام الكتابة أو المحادثة، يُرجَع إليها عند توليد الامتحانات أو اعتمادها.",
finish: "إنشاء المعيار",
toastSuccess: "تم إنشاء المعيار",
toastError: "تعذّر إنشاء المعيار",
addCriterion: "إضافة معيار",
removeCriterion: "حذف المعيار",
unnamedCriterion: "(معيار بلا اسم)",
descriptorHint: "الوصف اختياري. يظهر للمصحّحين كإرشاد لكل معيار.",
maxLabel: "الحد الأقصى",
moduleHint: "تنطبق المعايير على الكتابة والمحادثة فقط. الاستماع والقراءة يُصحَّحان تلقائياً.",
step1: {
title: "الأساسيات",
description: "امنح المعيار اسماً وحدّد المهارة التي يطبّق عليها.",
},
step2: {
title: "المعايير",
description: "أضف المعايير التي سيقوم المصحّحون بتقييمها. لكل معيار حد أقصى (مثل 9 لامتحان IELTS).",
},
step3: {
title: "الأوصاف",
description: "اختياري: صف ما يقيسه كل معيار. يراها المصحّحون كتلميحات.",
},
step4: {
title: "المراجعة",
description: "راجع وأنشئ.",
},
labels: {
name: "اسم المعيار",
module: "المهارة",
description: "الوصف",
criterionName: "اسم المعيار الفرعي",
maxScore: "الحد الأقصى",
},
placeholders: {
name: "مثال: IELTS مهمة كتابة 2",
description: "وصف قصير يظهر للمصحّحين.",
criterionName: "مثال: الاستجابة للمهمة",
descriptor: "ماذا يجب على المصحّح أن يتحقق منه لهذا المعيار.",
},
modules: {
writing: "الكتابة",
speaking: "المحادثة",
},
errors: {
nameRequired: "يرجى إدخال اسم للمعيار.",
moduleRestricted: "المعايير تنطبق على الكتابة أو المحادثة فقط.",
criterionRequired: "أضف معياراً واحداً على الأقل.",
criterionName: "كل معيار يحتاج إلى اسم.",
criterionScore: "الحد الأقصى يجب أن يكون بين 1 و 100.",
},
},
structure: {
title: "تعريف هيكل امتحان",
subtitle: "قالب يحدّد الأقسام والمهام التي يجب أن يحتويها كل امتحان من هذا النوع.",
finish: "إنشاء الهيكل",
toastSuccess: "تم إنشاء هيكل الامتحان",
toastError: "تعذّر إنشاء هيكل الامتحان",
industryHint: "اختياري: مثل \"الإنجليزية التجارية\"، \"الرعاية الصحية\".",
writingSkipped: "الكتابة غير مُحدَّدة ضمن الوحدات — تم تخطّي هذه الخطوة.",
wordsSuffix: "كلمة",
addTask: "إضافة مهمة",
removeTask: "حذف المهمة",
step1: {
title: "الأساسيات",
description: "سمِّ الهيكل وحدّد نوع الامتحان.",
},
step2: {
title: "الوحدات",
description: "اختر الوحدات التي يغطّيها هذا الهيكل. سيراها الطلاب كأقسام.",
},
step3: {
title: "مهام الكتابة",
description: "للكتابة، حدّد الحد الأدنى لعدد الكلمات لكل مهمة.",
},
step4: {
title: "المراجعة",
description: "راجع وأنشئ.",
},
labels: {
name: "اسم الهيكل",
industry: "المجال / السياق",
examType: "نوع الامتحان",
modules: "الوحدات",
taskLabel: "اسم المهمة",
minWords: "أدنى كلمات",
},
placeholders: {
name: "مثال: IELTS أكاديمي كتابة",
industry: "مثال: الإنجليزية التجارية",
},
examTypes: {
academic: "أكاديمي",
general: "عام",
},
modules: {
listening: {
title: "الاستماع",
description: "أسئلة مبنيّة على الصوت (تصحيح تلقائي).",
},
reading: {
title: "القراءة",
description: "أسئلة مبنيّة على مقاطع (تصحيح تلقائي).",
},
writing: {
title: "الكتابة",
description: "مهام مقاليّة تُصحَّح باستخدام معيار تقييم.",
},
speaking: {
title: "المحادثة",
description: "مهام شفهيّة تُصحَّح باستخدام معيار تقييم.",
},
},
errors: {
nameRequired: "يرجى إدخال اسم للهيكل.",
moduleRequired: "اختر وحدة واحدة على الأقل.",
writingTaskRequired: "الكتابة تحتاج إلى مهمة واحدة على الأقل.",
writingTaskLabel: "كل مهمة كتابة تحتاج إلى اسم.",
writingTaskWords: "الحد الأدنى للكلمات يجب أن يكون 1 على الأقل.",
},
},
course: {
title: "إنشاء مساق",
subtitle: "هيكل مساق خفيف. يمكنك إضافة الفصول والمواد وتسجيل الطلاب بعد الإنشاء.",
finish: "إنشاء المساق",
toastSuccess: "تم إنشاء المساق",
toastError: "تعذّر إنشاء المساق",
codeHint: "اتركه فارغاً لتوليده تلقائياً من العنوان.",
difficultyHint: "ما مدى تحدّي هذا المساق إجمالاً؟ يُستخدم للبحث والتصفية.",
cefrHint: "إن كان المساق يستهدف مستوى CEFR محدداً، اختره هنا.",
step1: {
title: "الأساسيات",
description: "امنح المساق اسماً ووصفاً قصيراً.",
},
step2: {
title: "المستوى والسعة",
description: "حدّد الصعوبة ومستوى CEFR المستهدف والحد الأقصى لعدد الطلاب.",
},
step3: {
title: "المراجعة",
description: "راجع وأنشئ.",
},
labels: {
title: "عنوان المساق",
code: "رمز المساق",
description: "الوصف",
difficulty: "الصعوبة",
cefrLevel: "مستوى CEFR",
capacity: "السعة القصوى",
},
placeholders: {
title: "مثال: إنجليزية تأسيسية المستوى 1",
code: "سيتم توليده تلقائياً إذا تُرك فارغاً",
description: "لمن هذا المساق؟ ماذا سيتعلّمون؟",
difficulty: "اختر مستوى صعوبة",
cefr: "اختر مستوى CEFR",
},
difficulty: {
beginner: "مبتدئ",
intermediate: "متوسط",
advanced: "متقدّم",
},
errors: {
titleRequired: "يرجى إدخال عنوان المساق.",
capacityRequired: "السعة القصوى يجب أن تكون 1 على الأقل.",
},
},
},
coursePlan: {
listTitle: "خطط المقررات",
listSubtitle:
"خطط مناهج يُنشئها الذكاء الاصطناعي: الأهداف، ونتائج التعلّم لكل مهارة، ونطاق القواعد، وخطة التسليم أسبوعياً، مع توليد المواد التعليمية لكل أسبوع عند الطلب.",
generateNew: "توليد خطة جديدة",
searchPlaceholder: "ابحث عن خطة بالاسم…",
loadFailed: "تعذّر تحميل الخطط.",
deleted: "تم حذف الخطة.",
deleteFailed: "تعذّر حذف الخطة.",
delete: "حذف",
confirmDelete: "حذف الخطة \"{{name}}\"؟ سيؤدي ذلك إلى إزالة جميع الأسابيع والمواد.",
emptyTitle: "لا توجد خطط بعد",
emptySubtitle: "استخدم معالج الذكاء الاصطناعي لتوليد أول خطة — يستغرق الأمر نحو دقيقة.",
open: "فتح",
backToList: "العودة إلى الخطط",
noDescription: "لا يوجد وصف.",
weeksCount_one: "{{count}} أسبوع",
weeksCount_other: "{{count}} أسابيع",
materialsCount_one: "{{count}} مادة",
materialsCount_other: "{{count}} مواد",
hoursPerWeek: "{{count}} ساعة/أسبوع",
weekN: "الأسبوع {{n}}",
generateMaterials: "توليد مواد الأسبوع (ذكاء اصطناعي)",
regenerateMaterials: "إعادة توليد مواد الأسبوع",
generating: "جاري التوليد…",
generateHint:
"سيُنتج الذكاء الاصطناعي نصّ قراءة وسيناريو استماع ومحفّزات محادثة وسؤال كتابة ودرس قواعد وقائمة مفردات لهذا الأسبوع.",
weekMaterialsGenerated: "تم توليد مواد الأسبوع.",
weekMaterialsFailed: "تعذّر توليد مواد الأسبوع.",
generateSuccess: "تم توليد خطة المقرر.",
generateFailed: "تعذّر توليد خطة المقرر.",
deliveryHint: "افتح أي أسبوع لعرض النتائج المخطَّطة وتوليد المواد الجاهزة للاستخدام.",
status: {
draft: "مسودة",
generated: "مُولّدة",
approved: "معتمدة",
archived: "مؤرشفة",
},
sections: {
objectives: "أهداف المقرر",
outcomes: "نتائج التعلّم حسب المهارة",
grammar: "نطاق القواعد",
assessment: "التقييم",
resources: "المصادر",
delivery: "خطة التسليم الأسبوعية",
},
skill: {
reading: "القراءة",
writing: "الكتابة",
listening: "الاستماع",
speaking: "المحادثة",
grammar: "القواعد",
vocabulary: "المفردات",
integrated: "متكامل",
},
materialType: {
reading_text: "نص قراءة",
listening_script: "نص استماع",
speaking_prompt: "محفّز محادثة",
writing_prompt: "مهمّة كتابة",
grammar_lesson: "درس قواعد",
vocabulary_list: "قائمة مفردات",
practice: "تمرين",
other: "مادة",
},
table: {
skill: "المهارة",
outcomes: "النتائج",
remarks: "ملاحظات",
},
wizard: {
title: "توليد خطة مقرر",
subtitle:
"اوصف المقرر مرّة واحدة، ويكتب الذكاء الاصطناعي الخطة كاملة — الأهداف والنتائج والقواعد والخطة الأسبوعية — ويمكنك توليد مواد الأسبوع الأول بضغطة واحدة.",
finish: "توليد الخطة",
reviewHint: "اضغط على \"توليد الخطة\" لبدء الذكاء الاصطناعي. يستغرق عادةً 3060 ثانية، وستنتقل إلى صفحة الخطة عند الانتهاء.",
steps: {
basics: "الأساسيات",
basicsDesc: "سمِّ المقرر وحدّد المستوى والمدة وعدد الساعات الأسبوعية.",
coverage: "التغطية",
coverageDesc: "أخبر الذكاء الاصطناعي بتوزيع الساعات على المهارات ومن هم المتعلّمون.",
scope: "النطاق",
scopeDesc: "اختياري: تركيز القواعد، والمصادر، وملاحظات حرّة.",
review: "المراجعة",
reviewDesc: "راجع البيانات قبل بدء التوليد.",
},
fields: {
title: "عنوان المقرر",
cefr: "مستوى CEFR",
cefrPlaceholder: "اختر مستوى",
totalWeeks: "إجمالي الأسابيع",
contactHours: "عدد الساعات أسبوعياً",
skillsDivision: "توزيع المهارات",
skillsDivisionHint:
"صيغة حرّة — مثال: \"10 س/أسبوع قراءة وكتابة + 8 س/أسبوع استماع ومحادثة\". اتركه فارغاً ليقرّر الذكاء الاصطناعي.",
learnerProfile: "ملف المتعلّمين",
learnerProfilePlaceholder: "من هم المتعلّمون؟ الفئة العمرية، اللغة الأم، الخلفية الدراسية، الأهداف…",
grammarFocus: "تركيز القواعد (Enter للإضافة)",
grammarFocusPlaceholder: "مثال: المضارع البسيط",
resources: "المصادر (Enter للإضافة)",
resourcesPlaceholder: "مثال: Pathways 1 (National Geographic)",
notes: "ملاحظات إضافية",
notesPlaceholder: "أي شيء آخر يحتاج الذكاء الاصطناعي معرفته.",
},
errors: {
titleRequired: "يرجى إدخال عنوان المقرر.",
cefrRequired: "يرجى اختيار مستوى CEFR.",
weeksRange: "عدد الأسابيع يجب أن يكون 1 على الأقل.",
},
},
},
aiAdmin: {
title: "وكلاء الذكاء الاصطناعي والأدوات",
subtitle:
"هيّئ الوكلاء المبنيين على LangGraph الذين يشغّلون تخطيط المقررات وتوليد الاختبارات والتمارين والمدرّس داخل LMS والتصحيح. الإعدادات الافتراضية جاهزة للاستخدام مباشرة.",
tabs: {
agents: "الوكلاء",
tools: "الأدوات",
prompts: "التعليمات",
},
},
agents: {
list: {
title: "الوكلاء",
subtitle: "مهيّأون مسبقاً لكل ركيزة في المنصة — يمكن تعديل الإعدادات الافتراضية أدناه.",
search: "ابحث عن وكيل…",
empty: "لا توجد وكلاء مطابقون لبحثك.",
},
detail: {
configure: "ضبط الإعدادات",
empty: "اختر وكيلاً لعرض إعداداته.",
graph: "نوع الرسم البياني",
model: "النموذج",
temperature: "درجة الإبداع",
tokens: "أقصى عدد رموز",
format: "صيغة المخرجات",
fallback: "الاحتياطي",
revisions: "أقصى عدد مراجعات",
promptKey: "مفتاح القالب",
toolsTitle: "الأدوات المفعّلة",
noTools: "لا توجد أدوات مفعّلة لهذا الوكيل.",
systemPrompt: "تعليمات النظام",
noPrompt: "(فارغ)",
},
config: {
title: "ضبط الإعدادات",
saved: "تم حفظ إعدادات الوكيل",
name: "اسم العرض",
promptKey: "مفتاح القالب (مع نسخ)",
description: "الوصف",
systemPrompt: "تعليمات النظام",
systemPromptHint:
"إذا تم تعيين مفتاح قالب أعلاه، فإن النسخة النشطة من ذلك القالب تستبدل هذا الحقل وقت التشغيل.",
model: "النموذج",
fallbackModel: "النموذج الاحتياطي",
responseFormat: "صيغة المخرجات",
text: "نص",
temperature: "درجة الإبداع",
maxTokens: "أقصى عدد رموز",
maxRevisions: "أقصى عدد مراجعات",
graphType: "بنية الرسم البياني",
qualityChecks: "فحوصات الجودة (مفاتيح أدوات مفصولة بفواصل)",
tools: "الأدوات المفعّلة",
mutates: "كتابة",
active: "الوكيل مفعّل",
},
test: {
title: "محرّك الاختبار",
subtitle:
"أرسل طلباً صغيراً وافحص مخرجات الوكيل واستدعاءات الأدوات وتنبيهات الجودة.",
variables: "المتغيّرات (JSON)",
payload: "الطلب (نص أو JSON)",
payloadPlaceholder:
"ماذا يجب أن يفعل الوكيل؟ مثال: «أنشئ 5 أسئلة اختيار من متعدد للقراءة لمستوى B1.»",
run: "تشغيل الوكيل",
running: "جارٍ التشغيل…",
ok: "تم تنفيذ الوكيل بنجاح",
output: "المخرجات",
toolTrace: "سجلّ استدعاءات الأدوات",
iterations: "التكرارات",
revisions: "المراجعات",
toolCalls: "استدعاءات الأدوات",
retrievalHits: "نتائج الاسترجاع",
qualityIssues: "تنبيهات الجودة",
badVarsJson: "يجب أن تكون المتغيّرات بصيغة JSON صحيحة.",
},
graph: {
simple: "بسيط",
planReviewRevise: "تخطيط • مراجعة • تنقيح",
rag: "استرجاع وتوليد",
react: "ReAct (استدعاء أدوات)",
},
},
tools: {
title: "أدوات الوكلاء",
subtitle:
"القدرات التي يمكن للوكلاء استدعاؤها. أوقف أداة لإخراجها من جميع الوكلاء دون تعديل كل واحد منهم.",
search: "ابحث عن أداة…",
empty: "لا توجد أدوات مطابقة لبحثك.",
writes: "كتابة",
toggle: {
enabled: "تم تفعيل الأداة",
disabled: "تم تعطيل الأداة",
},
col: {
key: "المفتاح",
name: "الاسم",
category: "الفئة",
description: "الوصف",
params: "المعاملات",
active: "نشط",
},
},
};
export default ar;

934
src/i18n/locales/en.ts Normal file
View File

@@ -0,0 +1,934 @@
/** English translations. Keep keys nested by feature area.
*
* We deliberately annotate the literal with `Translations` (not `as const`)
* so sibling locale files can widen their string values without fighting
* literal-string type mismatches.
*
* Adding a new key: add it here FIRST (English is the source of truth), then
* mirror it in `ar.ts`. The `Translations` interface is intentionally
* permissive (`Record<string, string>` per group) so we don't have to update
* a giant type every time we add a string.
*/
export interface Translations {
common: Record<string, string>;
auth: Record<string, string>;
nav: Record<string, string>;
sidebarGroup: Record<string, string>;
breadcrumb: Record<string, string>;
userMenu: Record<string, string>;
chrome: Record<string, string>;
notifications: Record<string, string>;
theme: Record<string, string>;
language: Record<string, string>;
roles: Record<string, string>;
dashboard: Record<string, string>;
studentDash: Record<string, string>;
teacherDash: Record<string, string>;
adminDash: Record<string, string>;
examPopup: Record<string, string>;
errors: Record<string, string>;
ai: Record<string, string>;
privacy: Record<string, string>;
feedback: Record<string, string>;
// quickSetup mixes flat strings (page chrome) and nested blocks
// ("admin.step1.title", "teacher.quick.discussion.title") so its values
// can be either strings or nested records. We intentionally use a loose
// shape here rather than maintain a hand-authored deep type.
quickSetup: Record<string, unknown>;
/** Smart Wizard Hub + per-scenario step-by-step wizards. */
wizardHub: Record<string, unknown>;
wizard: Record<string, unknown>;
/** AI course-plan generator — list, detail, wizard. */
coursePlan: Record<string, unknown>;
/** AI Agents & Tools configurator (the /admin/ai/prompts page). */
aiAdmin: Record<string, unknown>;
agents: Record<string, unknown>;
tools: Record<string, unknown>;
}
const en: Translations = {
common: {
cancel: "Cancel",
save: "Save",
delete: "Delete",
edit: "Edit",
close: "Close",
back: "Back",
next: "Next",
search: "Search",
loading: "Loading…",
error: "Error",
retry: "Retry",
yes: "Yes",
no: "No",
actions: "Actions",
status: "Status",
settings: "Settings",
signOut: "Sign out",
viewAll: "View All",
dismiss: "Dismiss",
pending: "Pending",
active: "Active",
inactive: "Inactive",
available: "Available",
unavailable: "Unavailable",
name: "Name",
email: "Email",
user: "User",
home: "Home",
saving: "Saving…",
disabled: "Disabled",
},
auth: {
signIn: "Sign in",
signInTitle: "Sign in to EnCoach",
welcomeBack: "Welcome back",
signInDescription: "Sign in to your account to continue",
email: "Email",
emailPlaceholder: "you@example.com",
password: "Password",
rememberMe: "Remember me",
forgotPassword: "Forgot password?",
needAccount: "Don't have an account?",
signUp: "Sign up",
invalidCredentials: "Invalid email or password",
missingCredentials: "Please enter email and password",
loginFailedTitle: "Login Failed",
errorTitle: "Error",
},
nav: {
smartWizard: "Smart Wizard",
quickSetup: "Smart Setup",
coursePlans: "Course Plans (AI)",
adminDashboard: "Admin Dashboard",
platformDashboard: "Platform Dashboard",
dashboard: "Dashboard",
courses: "Courses",
myCourses: "My Courses",
students: "Students",
teachers: "Teachers",
batches: "Batches",
timetable: "Timetable",
reports: "Reports",
assignments: "Assignments",
examsList: "Exams List",
examStructures: "Exam Structures",
rubrics: "Rubrics",
generation: "Generation",
reviewQueue: "Review Queue",
aiPrompts: "AI Agents & Tools",
aiFeedback: "AI Feedback",
approvalWorkflows: "Approval Workflows",
taxonomy: "Taxonomy",
resources: "Resources",
resourceLibrary: "Resource Library",
academicYears: "Academic Years",
departments: "Departments",
admissions: "Admissions",
admissionRegister: "Admission Register",
examSessions: "Exam Sessions",
marksheets: "Marksheets",
studentLeave: "Student Leave",
fees: "Fees & Payments",
lessons: "Lessons",
gradebook: "Gradebook",
studentProgress: "Student Progress",
library: "Library",
activities: "Activities",
facilities: "Facilities",
users: "Users",
entities: "Entities",
classrooms: "Classrooms",
userRoles: "User Roles",
rolesPermissions: "Roles & Permissions",
authorityMatrix: "Authority Matrix",
studentPerformance: "Student Performance",
statsCorporate: "Stats Corporate",
record: "Record",
vocabulary: "Vocabulary",
grammar: "Grammar",
faqManager: "FAQ Manager",
notificationRules: "Notification Rules",
approvalConfig: "Approval Config",
paymentRecord: "Payment Record",
tickets: "Tickets",
settings: "Settings",
profile: "Profile",
privacy: "Privacy Center",
subjectRegistration: "Subject Registration",
mySubjects: "My Subjects",
grades: "Grades",
attendance: "Attendance",
myJourney: "My Journey",
discussions: "Discussions",
messages: "Messages",
announcements: "Announcements",
},
sidebarGroup: {
overview: "Overview",
lms: "LMS",
adaptiveLearning: "Adaptive Learning",
institutional: "Institutional",
academic: "Academic",
management: "Management",
reports: "Reports",
configuration: "Configuration",
training: "Training",
support: "Support",
learning: "Learning",
progress: "Progress",
communication: "Communication",
account: "Account",
teaching: "Teaching",
},
breadcrumb: {
home: "Home",
admin: "Admin",
dashboard: "Dashboard",
platform: "Platform",
exam: "Exam",
review: "Review",
ai: "AI",
feedback: "Feedback",
},
userMenu: {
profile: "Profile",
settings: "Settings",
logout: "Logout",
userFallback: "User",
},
chrome: {
needHelp: "Need help?",
ticketsTooltip: "Support tickets",
aiSearchPlaceholder: "Ask anything... e.g. 'Show students with low attendance'",
aiSearching: "AI is searching…",
aiRelatedQueries: "Related queries",
aiNoResults: "No results for \"{{q}}\". Try a different question or keyword.",
aiSearchFailedTitle: "Search failed",
aiSearchFailedDesc: "Could not complete AI search.",
portalSuffix: "Portal",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — Unlock your potential with AI powered platform",
toggleSidebar: "Toggle sidebar",
},
notifications: {
title: "Notifications",
empty: "You're all caught up.",
ariaLabel: "Notifications",
},
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
toggleLabel: "Toggle theme",
},
language: {
change: "Change language",
label: "Language",
},
roles: {
student: "student",
teacher: "teacher",
admin: "admin",
developer: "developer",
corporate: "corporate",
mastercorporate: "master corporate",
agent: "agent",
},
dashboard: {
greetingFallback: "Student",
},
studentDash: {
welcome: "Welcome back, {{name}}!",
subtitle: "Here's an overview of your learning progress.",
enrolledCourses: "Enrolled Courses",
overallProgress: "Overall Progress",
averageGrade: "Average Grade",
totalChapters: "Total Chapters",
myCourses: "My Courses",
noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions",
enrollToStart: "Enroll in a course to get started.",
continue: "Continue",
start: "Start",
chaptersDone: "{{done}}/{{total}} chapters done",
recentGrades: "Recent Grades",
noGradesYet: "No grades yet.",
},
teacherDash: {
title: "Teacher Dashboard",
subtitle: "Overview of your teaching activities.",
activeCourses: "Active Courses",
totalStudents: "Total Students",
pendingGrading: "Pending Grading",
avgPassRate: "Avg. Pass Rate",
myCourses: "My Courses",
colCourse: "Course",
colStudents: "Students",
colStatus: "Status",
recentActivity: "Recent Activity",
submitted: "Submitted {{when}}",
pending: "Pending",
},
adminDash: {
title: "Admin Dashboard",
subtitle: "Platform overview and key metrics.",
addStudent: "Add Student",
newCourse: "New Course",
totalStudents: "Total Students",
activeCourses: "Active Courses",
teachers: "Teachers",
activeBatches: "Active Batches",
openTickets: "Open Tickets",
revenue: "Revenue",
departments: "Departments",
classrooms: "Classrooms",
subjects: "Subjects",
resources: "Resources",
coursesOverview: "Courses Overview",
batchCapacity: "Batch Capacity",
noCourseData: "No course data available.",
noBatchData: "No batch data available.",
quickSummary: "Quick Summary",
colModule: "Module",
colCount: "Count",
colStatus: "Status",
exams: "Exams",
examSessionsCount: "{{n}} sessions",
assignments: "Assignments",
acrossCourses: "across courses",
supportTickets: "Support Tickets",
ticketsOpenCount: "{{n}} open",
payments: "Payments",
revenueTotal: "{{amount}} total",
chartCapacity: "Capacity",
chartEnrolled: "Enrolled",
},
examPopup: {
title: "Upcoming Exams ({{n}})",
activeNow: "Active Now",
from: "From:",
to: "To:",
startExam: "Start Exam",
notAvailableYet: "Not Available Yet",
willBeAvailable: "Exam will be available once it becomes active",
notActive: "Exam is not currently active",
},
errors: {
somethingWrong: "Something went wrong",
unexpectedDescription: "An unexpected error occurred. Please try refreshing the page.",
errorDetails: "Error details",
refreshPage: "Refresh Page",
notFoundCode: "404",
notFoundMessage: "Oops! Page not found",
returnHome: "Return to Home",
},
ai: {
assistantLabel: "AI Assistant",
assistantTitle: "EnCoach AI Assistant",
placeholder: "Ask anything...",
send: "Send",
thinking: "Thinking…",
emptyLine1: "Ask me anything about the platform,",
emptyLine2: "or click a quick action above.",
fallbackReply: "Sorry, I could not reach the assistant. Please try again in a moment.",
replyErrorTitle: "Could not get a reply",
replyErrorDesc: "Something went wrong. Try again.",
quickHealth: "Platform health summary",
quickDropouts: "Show dropout risks",
quickCompare: "Compare course performance",
quickBatch: "Suggest batch improvements",
tipLabel: "AI Tip",
insightLabel: "AI Insight",
recommendationLabel: "AI Recommendation",
categoryTipLabel: "AI {{category}} Tip",
loadingTip: "Loading AI tip…",
loadingInsights: "Loading insights…",
loadingAlerts: "Loading alerts…",
couldNotLoadTip: "Could not load tip.",
couldNotLoadInsights: "Could not load insights.",
couldNotLoadAlerts: "Could not load alerts.",
noTipAvailable: "No tip available.",
noTipForContext: "No tip for this context yet.",
noInsightsAvailable: "No insights available for this view.",
noAlertsRightNow: "No AI alerts right now.",
alertsUnavailable: "Alerts unavailable",
insightsUnavailable: "Insights unavailable",
platformInsightsTitle: "AI Platform Insights",
},
privacy: {
title: "Privacy Center",
description:
"Manage your personal data held by EnCoach under GDPR and equivalent regulations.",
exportTitle: "Download your data",
exportDescription:
"We'll package your profile, entity memberships, exam attempts, answers, AI feedback, and tickets into a single JSON file.",
exportButton: "Download my data",
exportPreparing: "Preparing export…",
exportSuccess: "Data export downloaded",
deleteTitle: "Delete my account",
deleteDescription:
"This anonymises your profile and removes personal fields from our records. Exam attempts are retained (without identifying information) for statistical integrity. This action cannot be undone.",
deleteButton: "Erase my account",
deleteErasing: "Erasing…",
confirmTitle: "Erase your account?",
confirmDescription:
"We'll anonymise your personal data and deactivate your login. Aggregate analytics will remain but will no longer be linked to you.",
confirmTypeDelete: "Type DELETE to confirm",
confirmErased: "Your account has been erased. Signing out…",
},
feedback: {
thumbsUp: "Thumbs up",
thumbsDown: "Thumbs down",
whatWentWrong: "What went wrong?",
commentPlaceholder: "e.g. Wrong answer, confusing wording, off-topic…",
commentRequired: "Please tell us what was wrong.",
submit: "Submit feedback",
},
// Smart-setup wizard. Nested so i18next's default "." key separator
// resolves e.g. t("quickSetup.admin.step1.title").
quickSetup: {
adminTitle: "Smart Setup",
adminSubtitle:
"Everything you need to launch an exam-ready platform, in the recommended order. Tick each step off as you go — the wizard auto-detects progress.",
teacherTitle: "Smart Setup",
teacherSubtitle:
"Launch a new course end-to-end, then jump to common day-to-day tasks.",
progressLabel: "Progress",
recommendedFlow: "Recommended flow",
otherQuickCreates: "Other quick creates",
ready: "Ready",
start: "Start",
review: "Review",
open: "Open",
helpAria: "Show help",
admin: {
step1: {
title: "Create a rubric",
description:
"Define the grading criteria for Writing and Speaking tasks. Rubrics are referenced later when generating or approving exams.",
help: "A rubric is a scoring grid (bands × criteria). You can start from a template or compose one from predefined criteria.",
},
step2: {
title: "Define an exam structure",
description:
"Blueprint the sections, tasks, and parts that every generated or custom exam of this type must contain.",
help: "Structures enforce consistency. E.g. an IELTS Writing structure has Task 1 (150w) + Task 2 (250w); generation will refuse to submit if either is missing.",
},
step3: {
title: "Generate or create an exam",
description:
"Use AI generation (fastest) or hand-build a custom exam. Save as draft or submit for approval.",
help: "Generation picks a structure + rubric and produces questions. The custom builder gives you full control for pilot/test exams.",
},
step4: {
title: "Review & approve",
description:
"Approvers sign off on exams before students can see them. Configure the workflow once, then route submissions automatically.",
help: "The approval queue lists every exam waiting for sign-off. Reject sends it back to the author; approve publishes it.",
},
step5: {
title: "Assign to students",
description:
"Schedule the published exam, pick a cohort, and send it out. Students see it immediately in their portal.",
help: "Assignments can target individual students, batches, or classrooms. Timezone-aware windows are supported.",
},
quick: {
course: { title: "New course", description: "Stand up a course shell for teachers to fill with chapters." },
resource: { title: "Upload resource", description: "Add PDFs, audio, video, or links to the shared library." },
student: { title: "Add student", description: "Create a student account and assign them to a batch." },
teacher: { title: "Add teacher", description: "Invite a teacher and grant teaching permissions." },
classroom: { title: "New classroom", description: "Group students for scheduling and attendance." },
examSession: { title: "Schedule exam session", description: "Create a proctored sitting for an institutional exam." },
customExam: { title: "Custom exam", description: "Hand-build an exam from scratch with full control." },
ticket: { title: "Open ticket", description: "Raise a support ticket on behalf of a user." },
},
},
teacher: {
step1: {
title: "Create a course",
description: "Give your course a name, pick a subject, and set its level. You can edit chapters after creation.",
help: "Courses are the container for chapters, materials, and assignments.",
},
step2: {
title: "Add chapters & content",
description: "Open your course and add chapters with lessons, videos, quizzes, and practice tasks.",
help: "Chapters organise learning objectives. Use the AI workbench to auto-draft content.",
},
step3: {
title: "Upload resources",
description: "Share supporting PDFs, audio, or video with your students via the library.",
help: "Large files are fine — the server accepts up to 128 MB per upload.",
},
step4: {
title: "Create an assignment",
description: "Turn a published exam or task into an assignment with a due date and target cohort.",
help: "Assignments auto-surface in each student's dashboard and on their timetable.",
},
step5: {
title: "Track student progress",
description: "Monitor submissions, attendance, and adaptive learning insights for your class.",
help: "The adaptive engine flags at-risk students so you can intervene early.",
},
quick: {
discussion: { title: "New discussion", description: "Start a topic thread for your class." },
announcement: { title: "New announcement", description: "Broadcast a message to all your students." },
attendance: { title: "Mark attendance", description: "Record today's attendance for a session." },
},
},
},
wizardHub: {
title: "Smart Wizard",
subtitle:
"Pick any scenario and the wizard will walk you through it step by step. No need to hunt through settings — every Next button moves you closer to done.",
recommendedOrder: "Recommended order",
order: {
rubric: "Create rubrics (Writing / Speaking)",
structure: "Define exam structures (sections, tasks, durations)",
generate: "Generate or create exams",
approve: "Review & approve pending exams",
assign: "Assign exams to students",
},
guided: "Guided wizards",
advanced: "Full pages (advanced)",
advancedBadge: "Advanced",
aiBadge: "AI",
startWizard: "Start wizard",
openPage: "Open page",
cards: {
rubric: {
title: "Create a rubric",
description: "Name → skill → criteria → descriptors → review. Writing and Speaking only.",
},
examStructure: {
title: "Define an exam structure",
description: "Name → modules → writing tasks → review. Reusable blueprint for generation.",
},
course: {
title: "Create a course",
description: "Title → level & capacity → review. Chapters can be added from the course page.",
},
coursePlan: {
title: "Generate a course plan (AI)",
description: "Describe the course → AI writes objectives, per-skill outcomes, grammar scope and a week-by-week delivery plan, then produces real teaching materials per week.",
},
generation: {
title: "Generate an exam",
description: "Full AI generation page with rubric/structure pickers and per-module controls.",
},
approval: {
title: "Approval workflows",
description: "Configure who reviews which exams and see pending requests.",
},
assign: {
title: "Assign to students",
description: "Full scheduling page with student picker, windows, and timezone support.",
},
resource: {
title: "Upload resource",
description: "Add PDFs, audio, video, or links to the shared library.",
},
student: {
title: "Add student",
description: "Create a student account and enroll them in a batch.",
},
},
},
wizard: {
back: "Back",
next: "Next",
finish: "Finish",
backToHub: "Back to wizards",
stepOf: "Step {{current}} of {{total}}",
rubric: {
title: "Create a rubric",
subtitle: "Grading grid for Writing or Speaking tasks. Referenced later when generating or approving exams.",
finish: "Create rubric",
toastSuccess: "Rubric created",
toastError: "Could not create rubric",
addCriterion: "Add criterion",
removeCriterion: "Remove criterion",
unnamedCriterion: "(unnamed criterion)",
descriptorHint: "Descriptors are optional. They appear to graders as guidance for each criterion.",
maxLabel: "Max",
moduleHint:
"Rubrics only apply to Writing and Speaking. Listening and Reading are auto-graded and don't need one.",
step1: {
title: "Basics",
description: "Give the rubric a name and pick the skill it applies to.",
},
step2: {
title: "Criteria",
description: "Add the criteria you want graders to score. Each criterion has a max score (e.g. 9 for IELTS).",
},
step3: {
title: "Descriptors",
description: "Optional: describe what each criterion measures. Graders see these as hints.",
},
step4: {
title: "Review",
description: "Double-check and create.",
},
labels: {
name: "Rubric name",
module: "Skill",
description: "Description",
criterionName: "Criterion name",
maxScore: "Max score",
},
placeholders: {
name: "e.g. IELTS Writing Task 2",
description: "Short description shown to graders.",
criterionName: "e.g. Task Response",
descriptor: "What graders should check for this criterion.",
},
modules: {
writing: "Writing",
speaking: "Speaking",
},
errors: {
nameRequired: "Please enter a name for this rubric.",
moduleRestricted: "Rubrics apply only to Writing or Speaking.",
criterionRequired: "Add at least one criterion.",
criterionName: "Every criterion needs a name.",
criterionScore: "Max score must be between 1 and 100.",
},
},
structure: {
title: "Define an exam structure",
subtitle: "Blueprint the sections, tasks, and parts every generated or custom exam of this type must contain.",
finish: "Create structure",
toastSuccess: "Exam structure created",
toastError: "Could not create exam structure",
industryHint: "Optional: e.g. 'Business English', 'Healthcare'.",
writingSkipped: "Writing is not in the selected modules — this step is skipped.",
wordsSuffix: "words",
addTask: "Add task",
removeTask: "Remove task",
step1: {
title: "Basics",
description: "Name the structure and pick its exam type.",
},
step2: {
title: "Modules",
description: "Select which modules this structure covers. Students will see these sections.",
},
step3: {
title: "Writing tasks",
description: "For writing, define each task's minimum word count.",
},
step4: {
title: "Review",
description: "Double-check and create.",
},
labels: {
name: "Structure name",
industry: "Industry / context",
examType: "Exam type",
modules: "Modules",
taskLabel: "Task label",
minWords: "Min words",
},
placeholders: {
name: "e.g. IELTS Academic Writing",
industry: "e.g. Business English",
},
examTypes: {
academic: "Academic",
general: "General",
},
modules: {
listening: {
title: "Listening",
description: "Audio-based questions (auto-graded).",
},
reading: {
title: "Reading",
description: "Passage-based questions (auto-graded).",
},
writing: {
title: "Writing",
description: "Essay tasks graded with a rubric.",
},
speaking: {
title: "Speaking",
description: "Oral tasks graded with a rubric.",
},
},
errors: {
nameRequired: "Please enter a name for this structure.",
moduleRequired: "Select at least one module.",
writingTaskRequired: "Writing needs at least one task.",
writingTaskLabel: "Each writing task needs a label.",
writingTaskWords: "Minimum words must be at least 1.",
},
},
course: {
title: "Create a course",
subtitle: "A lightweight course skeleton. You can add chapters, materials and enroll students after creation.",
finish: "Create course",
toastSuccess: "Course created",
toastError: "Could not create course",
codeHint: "Leave blank to auto-generate from the title.",
difficultyHint: "How challenging is this course overall? Used for search & filtering.",
cefrHint: "If this course targets a specific CEFR band, pick it here.",
step1: {
title: "Basics",
description: "Give your course a name and a short description.",
},
step2: {
title: "Level & capacity",
description: "Set difficulty, target CEFR level, and how many students can enroll.",
},
step3: {
title: "Review",
description: "Double-check and create.",
},
labels: {
title: "Course title",
code: "Course code",
description: "Description",
difficulty: "Difficulty",
cefrLevel: "CEFR level",
capacity: "Max capacity",
},
placeholders: {
title: "e.g. Foundation English Level 1",
code: "Auto-generated if empty",
description: "Who is this course for? What will they learn?",
difficulty: "Select a difficulty",
cefr: "Select a CEFR level",
},
difficulty: {
beginner: "Beginner",
intermediate: "Intermediate",
advanced: "Advanced",
},
errors: {
titleRequired: "Please enter a course title.",
capacityRequired: "Max capacity must be at least 1.",
},
},
},
coursePlan: {
listTitle: "Course Plans",
listSubtitle:
"AI-generated curriculum outlines: objectives, per-skill learning outcomes, grammar scope, a week-by-week delivery plan, and on-demand teaching materials for each week.",
generateNew: "Generate new plan",
searchPlaceholder: "Search plans by name…",
loadFailed: "Couldn't load course plans.",
deleted: "Plan deleted.",
deleteFailed: "Couldn't delete plan.",
delete: "Delete",
confirmDelete: "Delete plan \"{{name}}\"? This removes all weeks and materials.",
emptyTitle: "No course plans yet",
emptySubtitle:
"Use the AI wizard to generate your first plan — it only takes about a minute.",
open: "Open",
backToList: "Back to course plans",
noDescription: "No description.",
weeksCount_one: "{{count}} week",
weeksCount_other: "{{count}} weeks",
materialsCount_one: "{{count}} material",
materialsCount_other: "{{count}} materials",
hoursPerWeek: "{{count}} hrs/week",
weekN: "Week {{n}}",
generateMaterials: "Generate Week materials (AI)",
regenerateMaterials: "Regenerate Week materials",
generating: "Generating…",
generateHint:
"AI will produce a reading text, listening script, speaking prompts, writing prompt, grammar mini-lesson and vocabulary for this week.",
weekMaterialsGenerated: "Week materials generated.",
weekMaterialsFailed: "Couldn't generate week materials.",
generateSuccess: "Course plan generated.",
generateFailed: "Couldn't generate course plan.",
deliveryHint:
"Expand any week to view the planned outcomes and generate ready-to-use teaching materials.",
status: {
draft: "Draft",
generated: "Generated",
approved: "Approved",
archived: "Archived",
},
sections: {
objectives: "Course objectives",
outcomes: "Learning outcomes by skill",
grammar: "Grammar scope",
assessment: "Assessment",
resources: "Resources",
delivery: "Weekly delivery plan",
},
skill: {
reading: "Reading",
writing: "Writing",
listening: "Listening",
speaking: "Speaking",
grammar: "Grammar",
vocabulary: "Vocabulary",
integrated: "Integrated",
},
materialType: {
reading_text: "Reading text",
listening_script: "Listening script",
speaking_prompt: "Speaking prompt",
writing_prompt: "Writing prompt",
grammar_lesson: "Grammar lesson",
vocabulary_list: "Vocabulary list",
practice: "Practice",
other: "Material",
},
table: {
skill: "Skill",
outcomes: "Outcomes",
remarks: "Remarks",
},
wizard: {
title: "Generate a course plan",
subtitle:
"Describe the course once. The AI writes the full outline — objectives, outcomes, grammar, weekly plan — and you can generate Week 1 teaching material in one click afterwards.",
finish: "Generate plan",
reviewHint:
"Click Generate plan to start the AI. This usually takes 3060 seconds; you'll land on the plan page once it's done.",
steps: {
basics: "Basics",
basicsDesc: "Name the course and set level, duration, and weekly hours.",
coverage: "Coverage",
coverageDesc: "Tell the AI how hours split across skills and who the learners are.",
scope: "Scope",
scopeDesc: "Optional: grammar focus, resources to reference, free-form notes.",
review: "Review",
reviewDesc: "Double-check your brief before kicking off the generation.",
},
fields: {
title: "Course title",
cefr: "CEFR level",
cefrPlaceholder: "Pick a level",
totalWeeks: "Total weeks",
contactHours: "Contact hours / week",
skillsDivision: "Skills division",
skillsDivisionHint:
"Free-form — e.g. \"10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking\". Leave blank to let the AI decide.",
learnerProfile: "Learner profile",
learnerProfilePlaceholder:
"Who are the learners? Age range, L1, prior study, goals…",
grammarFocus: "Grammar focus (press Enter to add)",
grammarFocusPlaceholder: "e.g. present simple",
resources: "Resources to reference (press Enter to add)",
resourcesPlaceholder: "e.g. Pathways 1 (National Geographic)",
notes: "Additional notes",
notesPlaceholder: "Anything else the AI should know.",
},
errors: {
titleRequired: "Please enter a course title.",
cefrRequired: "Please pick a CEFR level.",
weeksRange: "Total weeks must be at least 1.",
},
},
},
aiAdmin: {
title: "AI Agents & Tools",
subtitle:
"Configure the LangGraph-backed agents that power course planning, exam generation, exercise generation, the LMS tutor, and grading. Defaults are pre-seeded and ready to use.",
tabs: {
agents: "Agents",
tools: "Tools",
prompts: "Prompts",
},
},
agents: {
list: {
title: "Agents",
subtitle: "Pre-configured for every platform pillar — edit defaults below.",
search: "Search agents…",
empty: "No agents match your search.",
},
detail: {
configure: "Configure",
empty: "Select an agent to inspect its configuration.",
graph: "Graph",
model: "Model",
temperature: "Temperature",
tokens: "Max tokens",
format: "Output format",
fallback: "Fallback",
revisions: "Max revisions",
promptKey: "Prompt key",
toolsTitle: "Enabled tools",
noTools: "This agent has no tools enabled.",
systemPrompt: "System prompt",
noPrompt: "(empty)",
},
config: {
title: "Configure",
saved: "Agent configuration saved",
name: "Display name",
promptKey: "Prompt key (versioned)",
description: "Description",
systemPrompt: "System prompt",
systemPromptHint:
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
model: "Model",
fallbackModel: "Fallback model",
responseFormat: "Output format",
text: "Text",
temperature: "Temperature",
maxTokens: "Max tokens",
maxRevisions: "Max revisions",
graphType: "Graph topology",
qualityChecks: "Quality checks (comma-separated tool keys)",
tools: "Enabled tools",
mutates: "writes",
active: "Agent is active",
},
test: {
title: "Test runner",
subtitle:
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
variables: "Variables (JSON)",
payload: "Payload (text or JSON)",
payloadPlaceholder:
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
run: "Run agent",
running: "Running…",
ok: "Agent ran successfully",
output: "Output",
toolTrace: "Tool trace",
iterations: "Iterations",
revisions: "Revisions",
toolCalls: "Tool calls",
retrievalHits: "Retrieval hits",
qualityIssues: "Quality issues",
badVarsJson: "Variables must be valid JSON.",
},
graph: {
simple: "Simple",
planReviewRevise: "Plan • Review • Revise",
rag: "RAG",
react: "ReAct (tool-calling)",
},
},
tools: {
title: "Agent tools",
subtitle:
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
search: "Search tools…",
empty: "No tools match your search.",
writes: "writes",
toggle: {
enabled: "Tool enabled",
disabled: "Tool disabled",
},
col: {
key: "Key",
name: "Name",
category: "Category",
description: "Description",
params: "Params",
active: "Active",
},
},
};
export default en;

View File

@@ -51,6 +51,14 @@
--sidebar-accent-foreground: 8 40% 78%;
--sidebar-border: 240 18% 20%;
--sidebar-ring: 8 50% 58%;
/* Chart palette — used by Recharts via hsl(var(--chart-N)). Kept on the
warm/cool axis so pairs read well together when stacked. */
--chart-1: 8 50% 54%;
--chart-2: 220 70% 52%;
--chart-3: 152 60% 42%;
--chart-4: 38 92% 50%;
--chart-5: 280 55% 55%;
}
.dark {
@@ -99,6 +107,12 @@
--sidebar-accent-foreground: 8 40% 75%;
--sidebar-border: 240 18% 13%;
--sidebar-ring: 8 50% 55%;
--chart-1: 8 52% 62%;
--chart-2: 220 65% 65%;
--chart-3: 152 50% 55%;
--chart-4: 38 75% 60%;
--chart-5: 280 55% 68%;
}
}
@@ -120,3 +134,103 @@
font-family: 'JetBrains Mono', monospace;
}
}
/* -------------------------------------------------------------------------
* RTL / Arabic support
*
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
* position / border / rounded / text-align / space-x utilities are mirrored
* automatically when `html[dir="rtl"]` is active.
*
* Below we cover the things Tailwind can't: the webfont (Inter has weak
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
* own DOM, and a few icons that should NOT mirror (arrows used as pure
* visual affordance e.g. ChevronRight in a dropdown).
* ------------------------------------------------------------------------- */
html[dir="rtl"] body,
html[dir="rtl"] h1,
html[dir="rtl"] h2,
html[dir="rtl"] h3,
html[dir="rtl"] h4,
html[dir="rtl"] h5,
html[dir="rtl"] h6,
html[dir="rtl"] input,
html[dir="rtl"] textarea,
html[dir="rtl"] button,
html[dir="rtl"] select {
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
}
html[dir="rtl"] code,
html[dir="rtl"] pre {
font-family: 'JetBrains Mono', monospace;
}
html[dir="rtl"] .recharts-wrapper,
html[dir="rtl"] .recharts-legend-wrapper {
direction: ltr;
}
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
RTL the same arrow must become "<". We flip via CSS so no component has
to know about direction. */
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
transform: rotate(180deg);
}
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
dashes drift to the wrong side and the value becomes unreadable.
<bdi> already gives us this per-element; the helper classes below are
for places where adding a wrapper element is awkward. */
.ltr-nums,
.dir-ltr {
direction: ltr;
unicode-bidi: isolate;
}
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
but the digits themselves still read left-to-right. */
html[dir="rtl"] .numeric {
text-align: end;
direction: ltr;
unicode-bidi: isolate;
}
/* Some lucide icons are pure affordances (play, external-link, send) and
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
button that contains them. Authors opt-in with data-no-flip. */
html[dir="rtl"] [data-no-flip] {
transform: none !important;
}
/* Keep code snippets, identifiers, and file paths readable in RTL. */
html[dir="rtl"] code,
html[dir="rtl"] pre,
html[dir="rtl"] kbd,
html[dir="rtl"] samp,
html[dir="rtl"] [data-monospace] {
direction: ltr;
unicode-bidi: isolate;
text-align: start;
}
/* Scrollbars inside horizontally-scrollable tables already flip naturally
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
`right-0` / `left-0` positioning computed in JS. For safety, make sure
they inherit the document direction. */
html[dir="rtl"] [data-radix-popper-content-wrapper] {
direction: rtl;
}
/* Inputs with dir="ltr" (email, password, URL) override but keep the
placeholder aligned to the visual start (right in RTL, left in LTR). */
html[dir="rtl"] input[dir="ltr"]::placeholder,
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
text-align: right;
}

View File

@@ -1,4 +1,23 @@
/** Base path or absolute URL for Odoo JSON API (dev: `/api` + Vite proxy). */
/**
* Odoo REST client with transparent access-token rotation.
*
* Tokens live in ``localStorage`` under three keys:
* - ``encoach_token`` — short-lived access JWT (1h)
* - ``encoach_refresh_token`` — long-lived refresh JWT (7d)
* - ``encoach_token_exp`` — epoch seconds for the access token
*
* When a request receives ``401`` *and* a refresh token is present, the client
* silently rotates the pair via ``POST /api/auth/refresh`` and retries the
* original request exactly once. Multiple concurrent 401s coalesce onto the
* same refresh promise so we never fire more than one rotation in flight.
*
* Motivation: before this change, every expired access token forced a full
* re-login, which interrupted long admin dashboards (stats, reports) multiple
* times per hour. With the refresh loop, access tokens can be short-lived
* (1h) without hurting UX, giving us the security benefit of short access
* windows without an eager logout.
*/
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
const BASE_URL = API_BASE_URL;
@@ -21,120 +40,316 @@ export class ApiError extends Error {
}
}
function getToken(): string | null {
return localStorage.getItem("encoach_token");
/**
* Turn an arbitrary thrown value from an API mutation into a human-readable,
* actionable toast description. Deployment QA kept reporting generic
* "Submit failed" / "Upload failed" toasts — this helper surfaces the HTTP
* status and, when we recognise the code, a hint about what to do next
* (payload too big → ask ops to raise nginx, timeout → retry, etc.).
*
* @param err the error thrown by an `api.*` call (usually an ApiError)
* @param fallback text to show if we can't classify the error
* @param context short context word used in the 413/504 hints
* ("file", "request", "payload" …)
*/
export function describeApiError(
err: unknown,
fallback = "Something went wrong",
context = "request",
): string {
const e = err as { status?: number; message?: string; data?: unknown } | null;
const status = e?.status;
// Try to extract a server-side error body even if the generic ApiError
// message wasn't populated (nginx 413/504 respond with an HTML page so
// `data.error` is empty and `message` looks like "413 ").
const serverMsg =
e?.data && typeof e.data === "object" && "error" in (e.data as object)
? String((e.data as { error: unknown }).error || "").trim()
: "";
if (status === 413) {
return `The ${context} is too large for the server to accept (HTTP 413). Try a smaller file / fewer tasks, or ask an admin to raise the nginx \`client_max_body_size\` and Odoo \`limit_request\`.`;
}
if (status === 504) {
return `The server took too long to respond (HTTP 504). The ${context} may still be processing — wait a minute and reload before retrying.`;
}
if (status === 502) {
return "The backend is unreachable (HTTP 502). Odoo may be restarting — try again in a moment.";
}
if (status === 401) {
return "Your session expired — please sign in again.";
}
if (status === 403) {
return serverMsg || "You don't have permission to do that (HTTP 403).";
}
if (status === 404) {
return serverMsg || "The requested item no longer exists (HTTP 404).";
}
if (status === 422 || status === 400) {
return serverMsg || `The server rejected the ${context} as invalid (HTTP ${status}).`;
}
if (status && status >= 500) {
return `${serverMsg || "The server returned an error"} (HTTP ${status}). Check the Odoo logs for a stack trace.`;
}
// Network failure / fetch aborted / CORS — ApiError not thrown at all.
if (!status && e?.message) return e.message;
return fallback;
}
const ACCESS_KEY = "encoach_token";
const REFRESH_KEY = "encoach_refresh_token";
const EXP_KEY = "encoach_token_exp";
function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
}
function getRefreshToken(): string | null {
return localStorage.getItem(REFRESH_KEY);
}
export function setToken(token: string): void {
localStorage.setItem("encoach_token", token);
localStorage.setItem(ACCESS_KEY, token);
}
export function setRefreshToken(token: string | null | undefined): void {
if (token) {
localStorage.setItem(REFRESH_KEY, token);
} else {
localStorage.removeItem(REFRESH_KEY);
}
}
export function setTokenExpiry(epochSeconds: number | null | undefined): void {
if (epochSeconds && Number.isFinite(epochSeconds)) {
localStorage.setItem(EXP_KEY, String(Math.floor(epochSeconds)));
} else {
localStorage.removeItem(EXP_KEY);
}
}
/** Persist the full token bundle returned by /api/login or /api/auth/refresh. */
export function persistTokenBundle(bundle: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
}): void {
const access = bundle.access_token || bundle.token;
if (access) setToken(access);
setRefreshToken(bundle.refresh_token || null);
if (bundle.expires_in) {
setTokenExpiry(Math.floor(Date.now() / 1000) + bundle.expires_in);
}
}
export function clearToken(): void {
localStorage.removeItem("encoach_token");
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
localStorage.removeItem(EXP_KEY);
}
async function handleResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
// Shared refresh promise — any 401 that arrives while a refresh is in flight
// waits on the existing request instead of firing a duplicate.
let refreshPromise: Promise<boolean> | null = null;
if (response.status === 401) {
const hadToken = !!getToken();
clearToken();
// Login failure is also 401 — do not hard-redirect when no session existed.
if (hadToken) {
window.location.href = "/login";
}
throw new ApiError(401, response.statusText, data);
async function performRefresh(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
try {
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!res.ok) return false;
const data: {
access_token?: string;
token?: string;
refresh_token?: string;
expires_in?: number;
} = await res.json().catch(() => ({} as never));
if (!data.access_token && !data.token) return false;
persistTokenBundle(data);
return true;
} catch {
return false;
}
if (!response.ok) {
throw new ApiError(response.status, response.statusText, data);
}
return data as T;
}
function buildHeaders(extra?: Record<string, string>): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...extra,
};
const token = getToken();
if (token) {
headers["Authorization"] = `Bearer ${token}`;
function refreshOnce(): Promise<boolean> {
if (!refreshPromise) {
refreshPromise = performRefresh().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
function getCurrentLanguage(): string {
// Mirrors the i18n bootstrap in src/i18n/index.ts: the user's explicit
// pick wins; everything else falls back to English. We intentionally do
// not consult navigator.language here so AI-generated content stays in
// English on first visit (matching the UI default) until the user flips
// the language toggle.
try {
const stored = localStorage.getItem("encoach-lang");
if (stored) return stored;
} catch {
// localStorage unavailable (SSR, sandboxing, etc.)
}
return "en";
}
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
const headers: Record<string, string> = { ...extra };
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
const token = getAccessToken();
if (token) headers["Authorization"] = `Bearer ${token}`;
// Tell the backend which UI language the user is on so AI-generated content
// (coaching tips, insights, alerts, narratives) can be returned in the same
// language instead of always defaulting to English.
if (!headers["Accept-Language"]) {
headers["Accept-Language"] = getCurrentLanguage();
}
return headers;
}
function buildUrl(path: string, params?: Record<string, string | number | boolean | undefined>): string {
/**
* Query param values we know how to serialise into a URL. Arrays are joined
* with commas because that's what our Odoo controllers expect for multi-value
* filters (e.g. `?state=draft,confirmed`).
*/
export type QueryParamValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean>;
/**
* Accept any object-shaped bag of query params. We intentionally use `object`
* here rather than `Record<string, QueryParamValue>` because typed interfaces
* (e.g. `PaginationParams`) don't satisfy a `Record<...>` index signature by
* default, which would force every call-site to cast.
*/
export type QueryParams = object;
function buildUrl(path: string, params?: QueryParams): string {
const url = new URL(`${BASE_URL}${path}`, window.location.origin);
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
url.searchParams.set(key, String(value));
for (const [key, rawValue] of Object.entries(params as Record<string, unknown>)) {
if (rawValue === undefined || rawValue === null) continue;
if (Array.isArray(rawValue)) {
if (rawValue.length === 0) continue;
url.searchParams.set(key, rawValue.map(v => String(v)).join(","));
continue;
}
});
url.searchParams.set(key, String(rawValue));
}
}
return url.toString();
}
async function parseResponse<T>(response: Response): Promise<T> {
const data = await response.json().catch(() => null);
if (!response.ok) throw new ApiError(response.status, response.statusText, data);
return data as T;
}
type RequestInitWithSkip = RequestInit & { _skipRetry?: boolean };
async function performRequest<T>(url: string, init: RequestInitWithSkip): Promise<T> {
const response = await fetch(url, init);
// Auth/refresh endpoints opt out of the retry loop — otherwise a bad
// refresh token would recurse forever.
const isAuthEndpoint = url.includes("/auth/refresh") || url.includes("/login");
if (response.status === 401 && !init._skipRetry && !isAuthEndpoint) {
const hadRefresh = !!getRefreshToken();
if (hadRefresh) {
const rotated = await refreshOnce();
if (rotated) {
// Rebuild headers so the new access token is attached.
const newInit: RequestInitWithSkip = {
...init,
_skipRetry: true,
headers: {
...(init.headers as Record<string, string>),
Authorization: `Bearer ${getAccessToken()}`,
},
};
return performRequest<T>(url, newInit);
}
}
const hadAccess = !!getAccessToken();
clearToken();
if (hadAccess || hadRefresh) {
// Use SPA-style navigation when possible; fall back to a hard nav only
// when we're inside a worker / non-browser context. A full document
// reload here used to feel like "the browser refreshes on every click"
// whenever an access token silently expired.
if (typeof window !== "undefined" && !window.location.pathname.startsWith("/login")) {
window.history.pushState({}, "", "/login");
window.dispatchEvent(new PopStateEvent("popstate"));
}
}
throw new ApiError(401, response.statusText, await response.json().catch(() => null));
}
return parseResponse<T>(response);
}
export const api = {
async get<T>(path: string, params?: Record<string, string | number | boolean | undefined>): Promise<T> {
const res = await fetch(buildUrl(path, params), {
async get<T>(path: string, params?: QueryParams): Promise<T> {
return performRequest<T>(buildUrl(path, params), {
method: "GET",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async post<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "POST",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async patch<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "PATCH",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async put<T>(path: string, body?: unknown): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "PUT",
headers: buildHeaders(),
body: body ? JSON.stringify(body) : undefined,
});
return handleResponse<T>(res);
},
async delete<T>(path: string): Promise<T> {
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "DELETE",
headers: buildHeaders(),
});
return handleResponse<T>(res);
},
async upload<T>(path: string, formData: FormData): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(buildUrl(path), {
return performRequest<T>(buildUrl(path), {
method: "POST",
headers,
headers: buildHeaders(undefined, false),
body: formData,
});
return handleResponse<T>(res);
},
};

View File

@@ -1,5 +1,6 @@
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./i18n";
createRoot(document.getElementById("root")!).render(<App />);

View File

@@ -31,9 +31,10 @@ interface PlatformStats {
export default function AdminDashboard() {
const { data: stats } = useQuery<PlatformStats>({
queryKey: ["platform", "stats"],
queryFn: async () => {
const res = await api.get<{ data: PlatformStats }>("/stats");
return (res as { data: PlatformStats }).data ?? res;
queryFn: async (): Promise<PlatformStats> => {
const res = await api.get<{ data: PlatformStats } | PlatformStats>("/stats");
const wrapped = res as { data?: PlatformStats };
return wrapped.data ?? (res as PlatformStats);
},
staleTime: 30_000,
});

View File

@@ -1,119 +1,556 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Plus, CheckCircle, XCircle, Clock } from "lucide-react";
import AiGradingAssistant from "@/components/ai/AiGradingAssistant";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
CheckCircle,
XCircle,
Clock,
Loader2,
Trash2,
ChevronRight,
ShieldCheck,
FileText,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api-client";
const workflows = [
{
id: 1, name: "Exam Content Review", status: "In Progress",
steps: [
{ name: "Initial Review", assignee: "Dr. Smith", status: "Approved" },
{ name: "Quality Check", assignee: "Prof. Lee", status: "Pending" },
{ name: "Final Approval", assignee: "Admin", status: "Waiting" },
],
},
{
id: 2, name: "Rubric Approval", status: "Completed",
steps: [
{ name: "Draft Review", assignee: "Mr. Kim", status: "Approved" },
{ name: "Academic Board", assignee: "Dr. Smith", status: "Approved" },
],
},
{
id: 3, name: "New Exam Structure", status: "Rejected",
steps: [
{ name: "Content Review", assignee: "Prof. Lee", status: "Approved" },
{ name: "Standards Check", assignee: "Dr. Smith", status: "Rejected" },
],
},
];
interface WorkflowStep {
id: number;
sequence: number;
approver_id: number | null;
approver_name: string;
status: string;
comment: string;
auto_escalate: boolean;
max_days: number;
acted_at: string | null;
}
interface Workflow {
id: number;
name: string;
type: string;
entity_id: number | null;
entity_name: string | null;
allow_bypass: boolean;
active: boolean;
steps: WorkflowStep[];
created: string;
}
interface ApprovalRequest {
id: number;
workflow_id: number | null;
workflow_name: string;
res_model: string;
res_id: number;
state: string;
requester_id: number | null;
requester_name: string;
current_stage_id: number | null;
current_stage_sequence: number | null;
bypass_reason: string;
created_at: string | null;
}
interface UserItem {
id: number;
name: string;
login: string;
}
const statusIcon = (s: string) => {
if (s === "Approved") return <CheckCircle className="h-4 w-4 text-success" />;
if (s === "Rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-warning" />;
if (s === "approved") return <CheckCircle className="h-4 w-4 text-green-600" />;
if (s === "rejected") return <XCircle className="h-4 w-4 text-destructive" />;
return <Clock className="h-4 w-4 text-amber-500" />;
};
const stateBadge = (state: string) => {
const map: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
approved: "default",
in_progress: "secondary",
rejected: "destructive",
draft: "outline",
};
return (
<Badge variant={map[state] || "outline"} className="capitalize">
{state.replace(/_/g, " ")}
</Badge>
);
};
export default function ApprovalWorkflowsPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [formName, setFormName] = useState("");
const [formType, setFormType] = useState("custom");
const [formSteps, setFormSteps] = useState<{ approver_id: string }[]>([
{ approver_id: "" },
{ approver_id: "" },
]);
const [rejectDialog, setRejectDialog] = useState<number | null>(null);
const [rejectComment, setRejectComment] = useState("");
const workflowsQ = useQuery({
queryKey: ["approval-workflows"],
queryFn: () => api.get<{ items: Workflow[]; total: number }>("/approval-workflows"),
});
const requestsQ = useQuery({
queryKey: ["approval-requests"],
queryFn: () => api.get<{ items: ApprovalRequest[]; total: number }>("/approval-requests"),
});
const usersQ = useQuery({
queryKey: ["approval-users"],
queryFn: () => api.get<{ items: UserItem[] }>("/approval-users"),
});
const workflows = workflowsQ.data?.items ?? [];
const requests = requestsQ.data?.items ?? [];
const users = usersQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => api.post("/approval-workflows", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setCreateOpen(false);
resetForm();
toast({ title: "Workflow created" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Create failed", description: e.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => api.delete(`/approval-workflows/${id}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Workflow deleted" });
},
});
const approveMut = useMutation({
mutationFn: (id: number) => api.post(`/approval-requests/${id}/approve`, {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
toast({ title: "Request approved" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Approve failed", description: e.message }),
});
const rejectMut = useMutation({
mutationFn: ({ id, comment }: { id: number; comment: string }) =>
api.post(`/approval-requests/${id}/reject`, { comment }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["approval-requests"] });
qc.invalidateQueries({ queryKey: ["approval-workflows"] });
setRejectDialog(null);
setRejectComment("");
toast({ title: "Request rejected" });
},
onError: (e: Error) => toast({ variant: "destructive", title: "Reject failed", description: e.message }),
});
const resetForm = () => {
setFormName("");
setFormType("custom");
setFormSteps([{ approver_id: "" }, { approver_id: "" }]);
};
const handleCreate = () => {
createMut.mutate({
name: formName.trim(),
type: formType,
steps: formSteps
.filter((s) => s.approver_id)
.map((s) => ({ approver_id: Number(s.approver_id) })),
});
};
const pendingRequests = requests.filter((r) => r.state === "in_progress");
const completedRequests = requests.filter((r) => r.state !== "in_progress");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Approval Workflows</h1>
<p className="text-muted-foreground">Manage multi-step approval processes for exam content.</p>
<p className="text-muted-foreground">
Manage multi-step approval processes for exam content.
</p>
</div>
<Dialog>
<Dialog
open={createOpen}
onOpenChange={(open) => {
setCreateOpen(open);
if (!open) resetForm();
}}
>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Workflow</Button>
<Button size="sm">
<Plus className="h-4 w-4 mr-1" /> Create Workflow
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Workflow</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Workflow</DialogTitle>
<DialogDescription>Define a multi-step approval process.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Workflow Name</Label><Input placeholder="e.g. Exam Content Review" /></div>
<div className="space-y-2">
<Label>Step 1 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="kim">Mr. Kim</SelectItem></SelectContent>
</Select>
<Label>
Workflow Name <span className="text-destructive">*</span>
</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Exam Content Review"
/>
</div>
<div className="space-y-2">
<Label>Step 2 Assignee</Label>
<Select><SelectTrigger><SelectValue placeholder="Select assignee" /></SelectTrigger>
<SelectContent><SelectItem value="smith">Dr. Smith</SelectItem><SelectItem value="lee">Prof. Lee</SelectItem><SelectItem value="admin">Admin</SelectItem></SelectContent>
<Label>Type</Label>
<Select value={formType} onValueChange={setFormType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="custom">Custom</SelectItem>
<SelectItem value="exam_publish">Exam Publication</SelectItem>
<SelectItem value="assignment_publish">Assignment Publication</SelectItem>
<SelectItem value="content_publish">Content Publication</SelectItem>
</SelectContent>
</Select>
</div>
<Button className="w-full">Create</Button>
{formSteps.map((step, i) => (
<div key={i} className="space-y-1">
<div className="flex items-center justify-between">
<Label>Step {i + 1} Approver</Label>
{formSteps.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-destructive"
onClick={() =>
setFormSteps((s) => s.filter((_, idx) => idx !== i))
}
>
<Trash2 className="h-3 w-3" />
</Button>
)}
</div>
<Select
value={step.approver_id}
onValueChange={(v) =>
setFormSteps((s) =>
s.map((st, idx) => (idx === i ? { ...st, approver_id: v } : st))
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select approver" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.id} value={String(u.id)}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setFormSteps((s) => [...s, { approver_id: "" }])}
>
<Plus className="h-3 w-3 mr-1" /> Add Step
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!formName.trim() || createMut.isPending}
onClick={handleCreate}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Creating...
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
<div className="space-y-4">
{/* Loading */}
{(workflowsQ.isLoading || requestsQ.isLoading) && (
<div className="flex justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Pending Requests */}
{pendingRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Clock className="h-5 w-5 text-amber-500" />
Pending Approval ({pendingRequests.length})
</h2>
{pendingRequests.map((req) => {
const wf = workflows.find((w) => w.id === req.workflow_id);
return (
<Card key={req.id} className="border-amber-200 bg-amber-50/30">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="font-semibold">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
Requested by {req.requester_name} &middot;{" "}
{req.res_model.replace("encoach.", "")} #{req.res_id}
</p>
</div>
{stateBadge(req.state)}
</div>
{wf && (
<div className="flex items-center gap-2 flex-wrap mb-3">
{wf.steps.map((step, i) => {
const isActive = step.id === req.current_stage_id;
return (
<div key={step.id} className="flex items-center gap-2">
<div
className={`flex items-center gap-2 rounded-lg border p-3 min-w-[160px] transition-all ${
isActive
? "border-amber-400 bg-amber-50 ring-2 ring-amber-200"
: step.status === "approved"
? "border-green-200 bg-green-50/50"
: step.status === "rejected"
? "border-red-200 bg-red-50/50"
: "bg-muted/30"
}`}
>
{statusIcon(step.status)}
<div>
<p className="text-sm font-medium">Step {i + 1}</p>
<p className="text-xs text-muted-foreground">
{step.approver_name}
</p>
</div>
</div>
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
);
})}
</div>
)}
<div className="flex gap-2">
<Button
size="sm"
disabled={approveMut.isPending}
onClick={() => approveMut.mutate(req.id)}
>
{approveMut.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<CheckCircle className="h-3.5 w-3.5 mr-1" />
)}
Approve
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRejectDialog(req.id)}
>
<XCircle className="h-3.5 w-3.5 mr-1" />
Reject
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Workflow Templates */}
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<ShieldCheck className="h-5 w-5" />
Workflow Templates ({workflows.length})
</h2>
{workflows.length === 0 && !workflowsQ.isLoading && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No workflows defined yet. Create one to get started.
</CardContent>
</Card>
)}
{workflows.map((wf) => (
<Card key={wf.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant={wf.status === "Completed" ? "default" : wf.status === "Rejected" ? "destructive" : "secondary"}>{wf.status}</Badge>
<div className="flex items-center gap-3">
<CardTitle className="text-base font-semibold">{wf.name}</CardTitle>
<Badge variant="outline" className="text-xs capitalize">
{wf.type.replace(/_/g, " ")}
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={() => {
if (confirm(`Delete workflow "${wf.name}"?`)) deleteMut.mutate(wf.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{wf.steps.map((step, i) => (
<div key={i} className="flex items-center gap-2">
<div key={step.id} className="flex items-center gap-2">
<div className="flex items-center gap-2 rounded-lg border p-3 bg-muted/30 min-w-[160px]">
{statusIcon(step.status)}
<div className="h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{i + 1}
</div>
<div>
<p className="text-sm font-medium">{step.name}</p>
<p className="text-xs text-muted-foreground">{step.assignee}</p>
<p className="text-sm font-medium">{step.approver_name}</p>
<p className="text-xs text-muted-foreground">
{step.max_days}d limit
{step.auto_escalate ? " · auto-escalate" : ""}
</p>
</div>
</div>
{i < wf.steps.length - 1 && <div className="w-8 h-0.5 bg-border" />}
{i < wf.steps.length - 1 && (
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
))}
</div>
{wf.status === "In Progress" && (
<div className="space-y-4 mt-4">
<AiGradingAssistant onAccept={(marks, feedback) => {
toast({ title: "AI Grade Accepted", description: `Marks: ${marks}/100 applied with AI feedback.` });
}} />
<div className="flex gap-2">
<Button size="sm" variant="default">Approve</Button>
<Button size="sm" variant="outline">Reject</Button>
</div>
</div>
)}
</CardContent>
</Card>
))}
</div>
{/* Completed Requests */}
{completedRequests.length > 0 && (
<div className="space-y-3">
<h2 className="text-lg font-semibold flex items-center gap-2">
<FileText className="h-5 w-5" />
Recent Decisions ({completedRequests.length})
</h2>
{completedRequests.map((req) => (
<Card key={req.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-medium">{req.workflow_name}</p>
<p className="text-xs text-muted-foreground">
{req.requester_name} &middot; {req.res_model.replace("encoach.", "")} #
{req.res_id}
{req.created_at && (
<>
{" "}
&middot; {new Date(req.created_at).toLocaleDateString()}
</>
)}
</p>
</div>
{stateBadge(req.state)}
</CardContent>
</Card>
))}
</div>
)}
{/* Reject Dialog */}
<Dialog
open={rejectDialog !== null}
onOpenChange={(open) => {
if (!open) {
setRejectDialog(null);
setRejectComment("");
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject Request</DialogTitle>
<DialogDescription>Provide a reason for rejection.</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>Comment</Label>
<Textarea
value={rejectComment}
onChange={(e) => setRejectComment(e.target.value)}
placeholder="Reason for rejection..."
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={rejectMut.isPending}
onClick={() => {
if (rejectDialog !== null) {
rejectMut.mutate({ id: rejectDialog, comment: rejectComment });
}
}}
>
{rejectMut.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Reject
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,91 +1,341 @@
import { useState } from "react";
import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2 } from "lucide-react";
import { useAssignments, useCreateAssignment } from "@/hooks/queries";
import {
Search, Plus, Trash2, Loader2, Calendar, Users, Clock,
CheckCircle2, Archive, AlertTriangle, PlayCircle, Eye
} from "lucide-react";
import { assignmentsService } from "@/services/assignments.service";
import { api } from "@/lib/api-client";
import { useBatches, useStudents } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { ExamSchedule, ScheduleState, ExamScheduleCreateRequest } from "@/types";
interface CustomExam {
id: number;
title: string;
status: string;
}
interface Entity {
id: number;
name: string;
}
const STATE_TABS: { key: ScheduleState | "all"; label: string; icon: React.ReactNode }[] = [
{ key: "all", label: "All", icon: <Eye className="h-3.5 w-3.5" /> },
{ key: "active", label: "Active", icon: <PlayCircle className="h-3.5 w-3.5" /> },
{ key: "planned", label: "Planned", icon: <Clock className="h-3.5 w-3.5" /> },
{ key: "past", label: "Past", icon: <CheckCircle2 className="h-3.5 w-3.5" /> },
{ key: "start_expired", label: "Start Expired", icon: <AlertTriangle className="h-3.5 w-3.5" /> },
{ key: "archived", label: "Archived", icon: <Archive className="h-3.5 w-3.5" /> },
];
const OPTION_FIELDS: { key: keyof ExamScheduleCreateRequest; label: string }[] = [
{ key: "full_length", label: "Full length exams" },
{ key: "generate_different", label: "Generate different exams" },
{ key: "auto_release_results", label: "Auto release results" },
{ key: "auto_start", label: "Auto start exam" },
{ key: "official_exam", label: "Official Exam" },
{ key: "hide_assignee_details", label: "Hide Assignees Details from Teachers" },
];
const stateBadgeVariant = (s: ScheduleState): "default" | "secondary" | "outline" | "destructive" => {
switch (s) {
case "active": return "default";
case "planned": return "secondary";
case "past": return "outline";
case "start_expired": return "destructive";
case "archived": return "outline";
default: return "secondary";
}
};
type FormState = {
name: string;
exam_id: string;
entity_id: string;
start_date: string;
start_time: string;
end_date: string;
end_time: string;
assign_mode: "entity" | "batch" | "individual";
batch_ids: Set<number>;
student_ids: Set<number>;
full_length: boolean;
generate_different: boolean;
auto_release_results: boolean;
auto_start: boolean;
official_exam: boolean;
hide_assignee_details: boolean;
};
const _iso = (d: Date) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const emptyForm = (): FormState => {
const today = new Date();
const inOneWeek = new Date();
inOneWeek.setDate(today.getDate() + 7);
return {
name: "",
exam_id: "",
entity_id: "",
start_date: _iso(today),
start_time: "09:00",
end_date: _iso(inOneWeek),
end_time: "17:00",
assign_mode: "batch",
batch_ids: new Set(),
student_ids: new Set(),
full_length: true,
generate_different: false,
auto_release_results: false,
auto_start: false,
official_exam: false,
hide_assignee_details: false,
};
};
export default function AssignmentsPage() {
const [search, setSearch] = useState("");
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ title: "", entity_id: "", start_date: "", end_date: "" });
const [form, setForm] = useState<FormState>(emptyForm());
const [studentQuery, setStudentQuery] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
const assignmentsQ = useAssignments({ size: 200 });
const createMut = useCreateAssignment();
const schedulesQ = useQuery({
queryKey: ["exam-schedules", stateFilter],
queryFn: () => assignmentsService.listSchedules(
stateFilter === "all" ? {} : { state: stateFilter }
),
});
const schedules = schedulesQ.data?.items ?? [];
const items = assignmentsQ.data?.items ?? assignmentsQ.data?.data ?? [];
const assignments = Array.isArray(items) ? items : [];
const q = search.toLowerCase();
const filtered = assignments.filter(a =>
a.title?.toLowerCase().includes(q) || a.entity_name?.toLowerCase().includes(q),
const customExamsQ = useQuery({
queryKey: ["custom-exams-for-assign"],
queryFn: () => api.get<{ items: CustomExam[]; total: number }>("/exam/custom/list?per_page=200"),
});
const publishedExams = (customExamsQ.data?.items ?? []).filter(
(e) => e.status === "published" || e.status === "draft"
);
const loading = assignmentsQ.isLoading;
const entitiesQ = useQuery({
queryKey: ["entities-for-assign"],
queryFn: () => api.get<{ items: Entity[] }>("/entities"),
});
const entities = entitiesQ.data?.items ?? [];
const batchesQ = useBatches({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const studentsQ = useStudents({ size: 200 });
const students = studentsQ.data?.items ?? [];
const filteredStudents = useMemo(() => {
const q = studentQuery.trim().toLowerCase();
if (!q) return students;
return students.filter((s) =>
(s.name || "").toLowerCase().includes(q) ||
(s.email || "").toLowerCase().includes(q),
);
}, [students, studentQuery]);
const stateCounts = useMemo(() => {
const all = schedulesQ.data?.items ?? [];
const counts: Record<string, number> = { all: all.length };
for (const s of all) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [schedulesQ.data]);
const allSchedulesQ = useQuery({
queryKey: ["exam-schedules", "all"],
queryFn: () => assignmentsService.listSchedules({}),
enabled: stateFilter !== "all",
});
const totalCounts = useMemo(() => {
const items = stateFilter === "all" ? schedules : (allSchedulesQ.data?.items ?? []);
const counts: Record<string, number> = { all: items.length };
for (const s of items) {
counts[s.state] = (counts[s.state] || 0) + 1;
}
return counts;
}, [stateFilter, schedules, allSchedulesQ.data]);
const createMut = useMutation({
mutationFn: (data: ExamScheduleCreateRequest) => assignmentsService.createSchedule(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
setCreateOpen(false);
setForm(emptyForm());
toast({ title: "Exam scheduled successfully" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => assignmentsService.deleteSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Error", description: err.message }),
});
const archiveMut = useMutation({
mutationFn: (id: number) => assignmentsService.archiveSchedule(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-schedules"] });
toast({ title: "Schedule archived" });
},
});
const q = search.toLowerCase();
const filtered = schedules.filter(
(s) => s.name.toLowerCase().includes(q) || s.exam_title.toLowerCase().includes(q)
);
function handleCreate() {
createMut.mutate(
{ title: form.title, entity_id: Number(form.entity_id) || 0, start_date: form.start_date, end_date: form.end_date },
{
onSuccess: () => { setCreateOpen(false); setForm({ title: "", entity_id: "", start_date: "", end_date: "" }); toast({ title: "Assignment created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const startDt = form.start_date && form.start_time ? `${form.start_date}T${form.start_time}:00` : "";
const endDt = form.end_date && form.end_time ? `${form.end_date}T${form.end_time}:00` : "";
if (!form.name || !form.exam_id || !startDt || !endDt) {
toast({ variant: "destructive", title: "Missing fields", description: "Please fill in name, exam, start date/time, and end date/time." });
return;
}
createMut.mutate({
name: form.name,
exam_id: Number(form.exam_id),
entity_id: form.entity_id ? Number(form.entity_id) : undefined,
start_date: startDt,
end_date: endDt,
assign_mode: form.assign_mode,
batch_ids: [...form.batch_ids],
student_ids: [...form.student_ids],
full_length: form.full_length,
generate_different: form.generate_different,
auto_release_results: form.auto_release_results,
auto_start: form.auto_start,
official_exam: form.official_exam,
hide_assignee_details: form.hide_assignee_details,
});
}
const updateF = (patch: Partial<FormState>) => setForm((p) => ({ ...p, ...patch }));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Assignments</h1>
<p className="text-muted-foreground">Create and manage assignments.</p>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Calendar className="h-6 w-6 text-primary" /> Assignments
</h1>
<p className="text-muted-foreground">Schedule exams and assign them to entities, classes, or students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Assignment
<Button onClick={() => { setForm(emptyForm()); setCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Schedule Exam
</Button>
</div>
{/* State tabs */}
<div className="inline-flex items-center rounded-full border-2 border-primary bg-primary/5 p-1 gap-0">
{STATE_TABS.map((tab) => (
<button
key={tab.key}
className={`flex items-center gap-1.5 px-4 py-2 rounded-full text-sm font-medium transition-all ${
stateFilter === tab.key
? "bg-white text-primary shadow-sm"
: "text-primary/70 hover:text-primary"
}`}
onClick={() => setStateFilter(tab.key)}
>
{tab.icon}
{tab.label}
<span className={`text-xs ml-0.5 ${stateFilter === tab.key ? "text-primary" : "text-primary/50"}`}>
({totalCounts[tab.key] ?? 0})
</span>
</button>
))}
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search assignments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search schedules..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
{schedulesQ.isLoading ? (
<div className="flex justify-center py-12"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>
) : filtered.length === 0 ? (
<Card className="border-dashed">
<CardContent className="p-12 text-center text-muted-foreground">
<Calendar className="h-10 w-10 mx-auto mb-3 opacity-40" />
<p className="font-medium">No exam schedules found</p>
<p className="text-sm mt-1">Create a new schedule to assign exams to students.</p>
</CardContent>
</Card>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Schedule Name</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
<TableHead>Assignees</TableHead>
<TableHead>Completed</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No assignments found.</TableCell></TableRow>
)}
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.title}</TableCell>
<TableCell>{a.entity_name || "—"}</TableCell>
<TableCell>{a.start_date || "—"}</TableCell>
<TableCell>{a.end_date || "—"}</TableCell>
<TableCell><Badge variant={a.state === "active" ? "default" : "secondary"} className="capitalize">{a.state}</Badge></TableCell>
<TableCell>{a.assignee_count ?? 0}</TableCell>
<TableCell>{a.completed_count ?? 0}</TableCell>
{filtered.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.exam_title}</TableCell>
<TableCell>{s.entity_name || "—"}</TableCell>
<TableCell className="text-xs">{s.start_date ? new Date(s.start_date).toLocaleString() : "—"}</TableCell>
<TableCell className="text-xs">{s.end_date ? new Date(s.end_date).toLocaleString() : "—"}</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant(s.state)} className="capitalize">{s.state.replace("_", " ")}</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-1"><Users className="h-3.5 w-3.5 text-muted-foreground" />{s.assignee_count}</div>
</TableCell>
<TableCell>{s.completed_count}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
{s.state !== "archived" && (
<Button variant="ghost" size="icon" className="h-7 w-7" title="Archive"
onClick={() => archiveMut.mutate(s.id)}>
<Archive className="h-3.5 w-3.5" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive" title="Delete"
onClick={() => { if (confirm(`Delete schedule "${s.name}"?`)) deleteMut.mutate(s.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -94,21 +344,188 @@ export default function AssignmentsPage() {
</Card>
)}
{/* Create Schedule Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Title</Label><Input value={form.title} onChange={(e) => setForm(f => ({ ...f, title: e.target.value }))} placeholder="e.g. IELTS Prep Q2" /></div>
<div className="space-y-2"><Label>Entity ID</Label><Input type="number" value={form.entity_id} onChange={(e) => setForm(f => ({ ...f, entity_id: e.target.value }))} /></div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Schedule Exam</DialogTitle>
<DialogDescription>Select an exam, assign it to students, and set scheduling options.</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Basic info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label>Schedule Name <span className="text-destructive">*</span></Label>
<Input value={form.name} onChange={(e) => updateF({ name: e.target.value })} placeholder="e.g. Q2 IELTS Exam" />
</div>
<div className="space-y-1">
<Label>Select Exam <span className="text-destructive">*</span></Label>
<Select value={form.exam_id} onValueChange={(v) => updateF({ exam_id: v })}>
<SelectTrigger><SelectValue placeholder="Choose exam..." /></SelectTrigger>
<SelectContent>
{publishedExams.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.title} <span className="text-xs text-muted-foreground ml-1">({e.status})</span>
</SelectItem>
))}
{publishedExams.length === 0 && (
<SelectItem value="_none" disabled>No exams available</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Date/Time */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Schedule Date & Time</Label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="space-y-1">
<Label className="text-xs">Start Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.start_date} onChange={(e) => updateF({ start_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">Start Time</Label>
<Input type="time" value={form.start_time} onChange={(e) => updateF({ start_time: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Date <span className="text-destructive">*</span></Label>
<Input type="date" value={form.end_date} onChange={(e) => updateF({ end_date: e.target.value })} />
</div>
<div className="space-y-1">
<Label className="text-xs">End Time</Label>
<Input type="time" value={form.end_time} onChange={(e) => updateF({ end_time: e.target.value })} />
</div>
</div>
</div>
{/* Options checkboxes */}
<div className="space-y-2">
<Label className="text-sm font-semibold">Exam Options</Label>
<div className="flex flex-wrap gap-x-6 gap-y-2">
{OPTION_FIELDS.map((opt) => (
<label key={opt.key} className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={form[opt.key] as boolean}
onCheckedChange={(checked) => updateF({ [opt.key]: !!checked } as Partial<FormState>)}
className="h-4 w-4"
/>
{opt.label}
</label>
))}
</div>
</div>
{/* Assignment target */}
<div className="space-y-3">
<Label className="text-sm font-semibold">Assign To</Label>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Assignment Mode</Label>
<Select value={form.assign_mode} onValueChange={(v) => updateF({ assign_mode: v as FormState["assign_mode"] })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="entity">Entire Entity</SelectItem>
<SelectItem value="batch">Class / Batch</SelectItem>
<SelectItem value="individual">Individual Students</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Entity</Label>
<Select value={form.entity_id} onValueChange={(v) => updateF({ entity_id: v })}>
<SelectTrigger><SelectValue placeholder="Select entity..." /></SelectTrigger>
<SelectContent>
{entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>{e.name}</SelectItem>
))}
{entities.length === 0 && (
<SelectItem value="_none" disabled>No entities</SelectItem>
)}
</SelectContent>
</Select>
</div>
</div>
{/* Batch selection */}
{form.assign_mode === "batch" && (
<div className="space-y-1">
<Label className="text-xs">Select Classes</Label>
<div className="max-h-36 overflow-y-auto border rounded-md p-2 space-y-1">
{batches.map((b) => (
<label key={b.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.batch_ids.has(b.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.batch_ids);
checked ? next.add(b.id) : next.delete(b.id);
return { ...p, batch_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{b.name}
</label>
))}
{batches.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No classes available</p>}
</div>
</div>
)}
{/* Individual student selection */}
{form.assign_mode === "individual" && (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<Label className="text-xs">Select Students</Label>
<span className="text-[11px] text-muted-foreground">
{form.student_ids.size} selected · {students.length} total
</span>
</div>
{/* Search box — needed because QA reported long student lists
made bottom entries unreachable inside the inner scroll
container. Filtering keeps the list short and predictable. */}
<Input
placeholder="Search students by name or email…"
value={studentQuery}
onChange={(e) => setStudentQuery(e.target.value)}
className="h-8 text-xs"
/>
<div className="max-h-64 overflow-y-auto border rounded-md p-2 space-y-1">
{filteredStudents.map((s) => (
<label key={s.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox
checked={form.student_ids.has(s.id)}
onCheckedChange={(checked) => {
setForm((p) => {
const next = new Set(p.student_ids);
checked ? next.add(s.id) : next.delete(s.id);
return { ...p, student_ids: next };
});
}}
className="h-3.5 w-3.5"
/>
{s.name}
{s.email && <span className="text-xs text-muted-foreground ml-auto">{s.email}</span>}
</label>
))}
{filteredStudents.length === 0 && students.length > 0 && (
<p className="text-xs text-muted-foreground italic text-center py-2">
No students match {studentQuery}.
</p>
)}
{students.length === 0 && <p className="text-xs text-muted-foreground italic text-center py-2">No students available</p>}
</div>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.title} onClick={handleCreate}>
{createMut.isPending ? "Creating..." : "Create"}
<Button disabled={createMut.isPending || !form.name || !form.exam_id || !form.start_date || !form.end_date} onClick={handleCreate}>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Schedule Exam"}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -3,11 +3,12 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Trash2, Users } from "lucide-react";
import { useBatches, useCourses } from "@/hooks/queries";
import { Search, Plus, Trash2, Users, UserPlus, UserMinus, Pencil } from "lucide-react";
import { useBatches, useCourses, useStudents } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
@@ -17,49 +18,124 @@ export default function ClassroomsPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
const [editOpen, setEditOpen] = useState(false);
const [editBatch, setEditBatch] = useState<{ id: number; name: string; course_id: string; start_date: string; end_date: string } | null>(null);
const [studentsOpen, setStudentsOpen] = useState(false);
const [studentsBatchId, setStudentsBatchId] = useState<number | null>(null);
const [addStudentsOpen, setAddStudentsOpen] = useState(false);
const [selectedAddIds, setSelectedAddIds] = useState<number[]>([]);
const { toast } = useToast();
const qc = useQueryClient();
const batchesQ = useBatches({ size: 200 });
const coursesQ = useCourses({ size: 200 });
const studentsQ = useStudents({ size: 200 });
const batches = batchesQ.data?.items ?? [];
const courses = coursesQ.data?.items ?? [];
const allStudents = studentsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Parameters<typeof lmsService.createBatch>[0]) => lmsService.createBatch(data),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); setCreateOpen(false); setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" }); toast({ title: "Classroom created" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setCreateOpen(false);
setForm({ name: "", course_id: "", start_date: "", end_date: "", max_students: "" });
toast({ title: "Classroom created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) => lmsService.updateBatch(id, data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setEditOpen(false);
toast({ title: "Classroom updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => lmsService.deleteBatch(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["lms", "batches"] }); toast({ title: "Deleted" }); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Deleted" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const addStudentsMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.addStudentsToBatch(batchId, studentIds),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
setAddStudentsOpen(false);
setSelectedAddIds([]);
toast({ title: `Added ${res.total} student(s)` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const removeStudentMut = useMutation({
mutationFn: ({ batchId, studentIds }: { batchId: number; studentIds: number[] }) =>
lmsService.removeStudentsFromBatch(batchId, studentIds),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "batches"] });
toast({ title: "Student removed from classroom" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const q = search.toLowerCase();
const filtered = batches.filter(b => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q));
const filtered = batches.filter(
(b) => b.name.toLowerCase().includes(q) || b.course_name?.toLowerCase().includes(q),
);
const loading = batchesQ.isLoading;
const currentBatch = batches.find((b) => b.id === studentsBatchId);
const batchStudentIds = new Set(currentBatch?.student_ids ?? []);
const availableStudents = allStudents.filter((s) => !batchStudentIds.has(s.id));
function openStudents(batchId: number) {
setStudentsBatchId(batchId);
setStudentsOpen(true);
}
function openEdit(b: typeof batches[0]) {
setEditBatch({
id: b.id,
name: b.name,
course_id: String(b.course_id || ""),
start_date: b.start_date,
end_date: b.end_date,
});
setEditOpen(true);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Classrooms</h1>
<p className="text-muted-foreground">Create and manage batches (classrooms).</p>
<p className="text-muted-foreground">Create and manage classrooms with students.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Classroom
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search classrooms..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{batches.length} total</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
@@ -69,29 +145,67 @@ export default function ClassroomsPage() {
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Students</TableHead>
<TableHead>Capacity</TableHead>
<TableHead>Dates</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10" />
<TableHead className="w-[140px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No classrooms found.</TableCell></TableRow>
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No classrooms found.
</TableCell>
</TableRow>
)}
{filtered.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.name}</TableCell>
<TableCell>{b.course_name}</TableCell>
<TableCell><Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{b.student_count}</Badge></TableCell>
<TableCell>{b.capacity}</TableCell>
<TableCell className="text-sm">{b.start_date} {b.end_date}</TableCell>
<TableCell><Badge variant={b.status === "active" ? "default" : "secondary"} className="capitalize">{b.status}</Badge></TableCell>
<TableCell>{b.course_name || "—"}</TableCell>
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" onClick={() => { if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id); }}>
<Trash2 className="h-4 w-4" />
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-7 px-2"
onClick={() => openStudents(b.id)}
>
<Users className="h-3.5 w-3.5" />
{b.student_count} students
</Button>
</TableCell>
<TableCell className="text-sm">
{b.start_date && b.end_date
? `${b.start_date}${b.end_date}`
: "—"}
</TableCell>
<TableCell>
<Badge
variant={b.status === "active" ? "default" : "secondary"}
className="capitalize"
>
{b.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage students" onClick={() => openStudents(b.id)}>
<UserPlus className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit" onClick={() => openEdit(b)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
if (window.confirm(`Delete "${b.name}"?`)) deleteMut.mutate(b.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -100,34 +214,234 @@ export default function ClassroomsPage() {
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Classroom</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" /></div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. IELTS Prep Group A" />
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select value={form.course_id} onValueChange={(v) => setForm(f => ({ ...f, course_id: v }))}>
<Select value={form.course_id} onValueChange={(v) => setForm((f) => ({ ...f, course_id: v }))}>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map(c => <SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>)}
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Start Date</Label><Input type="date" value={form.start_date} onChange={(e) => setForm(f => ({ ...f, start_date: e.target.value }))} /></div>
<div className="space-y-2"><Label>End Date</Label><Input type="date" value={form.end_date} onChange={(e) => setForm(f => ({ ...f, end_date: e.target.value }))} /></div>
<div className="space-y-2">
<Label>Start Date</Label>
<Input type="date" value={form.start_date} onChange={(e) => setForm((f) => ({ ...f, start_date: e.target.value }))} />
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input type="date" value={form.end_date} onChange={(e) => setForm((f) => ({ ...f, end_date: e.target.value }))} />
</div>
</div>
<div className="space-y-2"><Label>Max Students</Label><Input type="number" value={form.max_students} onChange={(e) => setForm(f => ({ ...f, max_students: e.target.value }))} placeholder="30" /></div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={() => createMut.mutate({ name: form.name, course_id: Number(form.course_id) || undefined, start_date: form.start_date || undefined, end_date: form.end_date || undefined, capacity: Number(form.max_students) || undefined } as never)}>
<Button
disabled={createMut.isPending || !form.name}
onClick={() =>
createMut.mutate({
name: form.name,
course_id: Number(form.course_id) || undefined,
start_date: form.start_date || undefined,
end_date: form.end_date || undefined,
} as never)
}
>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Classroom</DialogTitle></DialogHeader>
{editBatch && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editBatch.name}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, name: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Course</Label>
<Select
value={editBatch.course_id}
onValueChange={(v) => setEditBatch((prev) => prev ? { ...prev, course_id: v } : prev)}
>
<SelectTrigger><SelectValue placeholder="Select course" /></SelectTrigger>
<SelectContent>
{courses.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.title || c.code}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Start Date</Label>
<Input
type="date"
value={editBatch.start_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, start_date: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>End Date</Label>
<Input
type="date"
value={editBatch.end_date}
onChange={(e) => setEditBatch((prev) => prev ? { ...prev, end_date: e.target.value } : prev)}
/>
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editBatch?.name}
onClick={() => {
if (!editBatch) return;
updateMut.mutate({
id: editBatch.id,
data: {
name: editBatch.name,
course_id: Number(editBatch.course_id) || undefined,
start_date: editBatch.start_date || undefined,
end_date: editBatch.end_date || undefined,
},
});
}}
>
{updateMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Students Management Dialog */}
<Dialog open={studentsOpen} onOpenChange={setStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
Classroom Students {currentBatch?.name}
</DialogTitle>
<DialogDescription>
{currentBatch?.course_name && `Course: ${currentBatch.course_name}`}
{currentBatch ? ` · ${currentBatch.student_count} student(s)` : ""}
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{currentBatch?.student_ids?.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-6">
No students in this classroom yet.
</p>
) : (
allStudents
.filter((s) => batchStudentIds.has(s.id))
.map((s) => (
<div key={s.id} className="flex items-center justify-between p-2 rounded hover:bg-muted/50">
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-destructive"
onClick={() => {
if (studentsBatchId && window.confirm(`Remove ${s.name} from this classroom?`))
removeStudentMut.mutate({ batchId: studentsBatchId, studentIds: [s.id] });
}}
>
<UserMinus className="h-3.5 w-3.5 mr-1" /> Remove
</Button>
</div>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setStudentsOpen(false)}>Close</Button>
<Button
onClick={() => {
setSelectedAddIds([]);
setAddStudentsOpen(true);
}}
>
<UserPlus className="h-4 w-4 mr-1" /> Add Students
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Students to Batch Dialog */}
<Dialog open={addStudentsOpen} onOpenChange={setAddStudentsOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Add Students to {currentBatch?.name}</DialogTitle>
<DialogDescription>
Select students to add to this classroom.
</DialogDescription>
</DialogHeader>
<div className="max-h-[320px] overflow-y-auto space-y-1">
{availableStudents.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
All students are already in this classroom.
</p>
) : (
availableStudents.map((s) => (
<label key={s.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50 cursor-pointer">
<Checkbox
checked={selectedAddIds.includes(s.id)}
onCheckedChange={() =>
setSelectedAddIds((prev) =>
prev.includes(s.id) ? prev.filter((x) => x !== s.id) : [...prev, s.id],
)
}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{s.name}</p>
<p className="text-xs text-muted-foreground">{s.email}</p>
</div>
</label>
))
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddStudentsOpen(false)}>Cancel</Button>
<Button
disabled={addStudentsMut.isPending || selectedAddIds.length === 0}
onClick={() => {
if (studentsBatchId)
addStudentsMut.mutate({ batchId: studentsBatchId, studentIds: selectedAddIds });
}}
>
{addStudentsMut.isPending
? "Adding..."
: `Add ${selectedAddIds.length} Student(s)`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -5,91 +5,280 @@ import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Building2, Trash2 } from "lucide-react";
import { useDepartments, useCreateDepartment, useDeleteDepartment } from "@/hooks/queries";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus, Building2, Trash2, Pencil, Users, Shield } from "lucide-react";
import { entitiesService } from "@/services/entities.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
const ENTITY_TYPES = [
{ value: "corporate", label: "Corporate" },
{ value: "university", label: "University" },
{ value: "school", label: "School" },
{ value: "government", label: "Government" },
{ value: "freelance", label: "Freelance" },
];
export default function EntitiesPage() {
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ name: "", code: "" });
const [editOpen, setEditOpen] = useState(false);
const [editEntity, setEditEntity] = useState<{ id: number; name: string; code: string; type: string } | null>(null);
const [form, setForm] = useState({ name: "", code: "", type: "" });
const { toast } = useToast();
const qc = useQueryClient();
const deptsQ = useDepartments({ size: 200 });
const createMut = useCreateDepartment();
const deleteMut = useDeleteDepartment();
const entitiesQ = useQuery({
queryKey: ["entities"],
queryFn: () => entitiesService.list({ size: 200 }),
});
const depts = deptsQ.data?.items ?? deptsQ.data?.data ?? [];
const departments = Array.isArray(depts) ? depts : [];
const filtered = departments.filter(d => d.name?.toLowerCase().includes(search.toLowerCase()));
const loading = deptsQ.isLoading;
const createMut = useMutation({
mutationFn: (data: { name: string; code?: string; type?: string }) =>
entitiesService.create(data as Partial<import("@/types").Entity>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
setCreateOpen(false);
setForm({ name: "", code: "", type: "" });
toast({ title: "Entity created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
createMut.mutate(
{ name: form.name, code: form.code || undefined },
{
onSuccess: () => { setCreateOpen(false); setForm({ name: "", code: "" }); toast({ title: "Department created" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
},
);
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
entitiesService.update(id, data as Partial<import("@/types").Entity>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
setEditOpen(false);
toast({ title: "Entity updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => entitiesService.delete(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["entities"] });
toast({ title: "Deleted" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const raw = entitiesQ.data as unknown;
const entities: { id: number; name: string; code: string; type: string; user_count: number; role_count: number; active: boolean }[] = (() => {
if (!raw) return [];
if (Array.isArray(raw)) return raw as never[];
const r = raw as { items?: unknown; data?: unknown };
const arr = (r.items ?? r.data ?? []) as never[];
return Array.isArray(arr) ? arr : [];
})();
const filtered = entities.filter(
(e) =>
e.name?.toLowerCase().includes(search.toLowerCase()) ||
e.code?.toLowerCase().includes(search.toLowerCase()),
);
const loading = entitiesQ.isLoading;
function openEdit(e: typeof entities[0]) {
setEditEntity({ id: e.id, name: e.name, code: e.code, type: e.type });
setEditOpen(true);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Entities / Departments</h1>
<p className="text-muted-foreground">Manage organizational departments.</p>
<h1 className="text-2xl font-bold tracking-tight">Entities</h1>
<p className="text-muted-foreground">Manage organizations and entities on the platform.</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Create Department
<Plus className="h-4 w-4 mr-1" /> Create Entity
</Button>
</div>
<div className="relative max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search departments..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<div className="flex gap-4 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search entities..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Badge variant="secondary">{entities.length} total</Badge>
</div>
{loading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filtered.length === 0 && <p className="col-span-full text-center text-muted-foreground py-8">No departments found.</p>}
{filtered.map((d) => (
<Card key={d.id} className="border-0 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
{d.name}
</CardTitle>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => { if (window.confirm(`Delete "${d.name}"?`)) deleteMut.mutate(d.id, { onSuccess: () => toast({ title: "Deleted" }), onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }) }); }}>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{d.code || "No code"}</p>
</CardContent>
</Card>
))}
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead>Users</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-24" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
No entities found.
</TableCell>
</TableRow>
)}
{filtered.map((e) => (
<TableRow key={e.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-primary" />
{e.name}
</div>
</TableCell>
<TableCell><code className="text-xs bg-muted px-1.5 py-0.5 rounded">{e.code}</code></TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">{e.type || "—"}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary"><Users className="h-3 w-3 mr-1" />{e.user_count ?? 0}</Badge>
</TableCell>
<TableCell>
<Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />{e.role_count ?? 0}</Badge>
</TableCell>
<TableCell>
<Badge variant={e.active !== false ? "default" : "secondary"}>
{e.active !== false ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(e)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => {
if (window.confirm(`Delete entity "${e.name}"?`))
deleteMut.mutate(e.id);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Create Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create Department</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>Create Entity</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Name</Label><Input value={form.name} onChange={(e) => setForm(f => ({ ...f, name: e.target.value }))} placeholder="Department name" /></div>
<div className="space-y-2"><Label>Code</Label><Input value={form.code} onChange={(e) => setForm(f => ({ ...f, code: e.target.value }))} placeholder="DEPT01" /></div>
<div className="space-y-2">
<Label>Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Organization name" />
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input value={form.code} onChange={(e) => setForm((f) => ({ ...f, code: e.target.value }))} placeholder="ORG_CODE (auto-generated if empty)" />
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select value={form.type} onValueChange={(v) => setForm((f) => ({ ...f, type: v }))}>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button disabled={createMut.isPending || !form.name} onClick={handleCreate}>
<Button
disabled={createMut.isPending || !form.name.trim()}
onClick={() =>
createMut.mutate({
name: form.name.trim(),
code: form.code.trim() || undefined,
type: form.type || undefined,
})
}
>
{createMut.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Edit Entity</DialogTitle></DialogHeader>
{editEntity && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editEntity.name}
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, name: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Code</Label>
<Input
value={editEntity.code}
onChange={(e) => setEditEntity((prev) => prev ? { ...prev, code: e.target.value } : prev)}
/>
</div>
<div className="space-y-2">
<Label>Type</Label>
<Select
value={editEntity.type}
onValueChange={(v) => setEditEntity((prev) => prev ? { ...prev, type: v } : prev)}
>
<SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
{ENTITY_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>{t.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditOpen(false)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editEntity?.name.trim()}
onClick={() => {
if (!editEntity) return;
updateMut.mutate({
id: editEntity.id,
data: { name: editEntity.name, code: editEntity.code, type: editEntity.type },
});
}}
>
{updateMut.isPending ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,54 +0,0 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { GraduationCap, Play, Sparkles } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function ExamPage() {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] gap-4 max-w-md mx-auto">
<AiTipBanner tip="Based on your practice history, focus on Reading Part 3 (sentence completion) — your accuracy there is 58% vs 82% average. Budget 20 min for the writing section." variant="recommendation" />
<Card className="border-0 shadow-sm w-full">
<CardContent className="p-8 text-center space-y-6">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<GraduationCap className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-xl font-bold mb-1">Ready to Start?</h2>
<p className="text-muted-foreground text-sm">IELTS Academic Mock Exam</p>
</div>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Student</span>
<span className="font-medium">Sarah Johnson</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Level</span>
<Badge variant="outline">B2</Badge>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Modules</span>
<span className="font-medium">4</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Duration</span>
<span className="font-medium">180 min</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Mode</span>
<Badge variant="secondary">Practice</Badge>
</div>
</div>
<div className="rounded-lg bg-primary/5 border border-primary/20 p-3 text-left">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-1"><Sparkles className="h-3 w-3" /> AI Pre-Exam Tip</p>
<p className="text-xs text-muted-foreground">Your last mock scored 7.5. To target 8.0, focus on time management in Writing Task 2 you spent 45 min last time vs recommended 40 min.</p>
</div>
<Button className="w-full" size="lg">
<Play className="h-4 w-4 mr-2" /> Begin Exam
</Button>
</CardContent>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,50 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search } from "lucide-react";
import { Search, Plus, FileText, Clock } from "lucide-react";
import { useInstitutionalExamSessions } from "@/hooks/queries";
import { api } from "@/lib/api-client";
import { Link } from "react-router-dom";
interface CustomExam {
id: number;
title: string;
status: string;
total_time_min: number;
sections: { id: number; title: string; skill: string }[];
teacher_id: number | null;
template_id: number | null;
description: string;
}
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
published: "default",
draft: "secondary",
archived: "outline",
};
export default function ExamsListPage() {
const [search, setSearch] = useState("");
const sessionsQ = useInstitutionalExamSessions();
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
const sessions = Array.isArray(items) ? items : [];
const [tab, setTab] = useState<"custom" | "sessions">("custom");
const customQ = useQuery({
queryKey: ["custom-exams", search],
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
});
const sessionsQ = useInstitutionalExamSessions();
const sessionItems = sessionsQ.data?.items ?? [];
const sessions: Record<string, string>[] = Array.isArray(sessionItems)
? (sessionItems as unknown as Record<string, string>[])
: [];
const customExams = customQ.data?.items ?? [];
const q = search.toLowerCase();
const filtered = sessions.filter(s =>
const filteredSessions = sessions.filter((s) =>
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
);
@@ -23,8 +53,20 @@ export default function ExamsListPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
</div>
<Link to="/admin/exam/create">
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
</Link>
</div>
<div className="flex gap-2">
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
</Button>
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
</Button>
</div>
<div className="relative max-w-sm">
@@ -32,44 +74,92 @@ export default function ExamsListPage() {
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{sessionsQ.isLoading ? (
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
)}
{filtered.map((s, i) => (
<TableRow key={s.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.course_name || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>{s.start_date || "—"}</TableCell>
<TableCell>{s.end_date || "—"}</TableCell>
<TableCell>
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
</TableCell>
{tab === "custom" && (
customQ.isLoading ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-12">#</TableHead>
<TableHead>Title</TableHead>
<TableHead>Modules</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{customExams.length === 0 && (
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
)}
{customExams.map((exam, i) => (
<TableRow key={exam.id}>
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
<TableCell className="font-medium">{exam.title}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{exam.sections.length > 0
? exam.sections.map((s) => (
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
))
: <span className="text-muted-foreground text-xs"></span>}
</div>
</TableCell>
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
<TableCell>
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
)}
{tab === "sessions" && (
sessionsQ.isLoading ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>#</TableHead>
<TableHead>Name</TableHead>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Start</TableHead>
<TableHead>End</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSessions.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
)}
{filteredSessions.map((s, i) => (
<TableRow key={s.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.course_name || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>{s.start_date || "—"}</TableCell>
<TableCell>{s.end_date || "—"}</TableCell>
<TableCell>
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)
)}
</div>
);

View File

@@ -48,7 +48,7 @@ export default function ForgotPassword() {
) : null}
<div className="mt-6 text-center">
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<ArrowLeft className="h-3 w-3" /> Back to sign in
<ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
</Link>
</div>
</CardContent>

File diff suppressed because it is too large Load Diff

View File

@@ -1,34 +1,122 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { CheckCircle, PenTool, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
Plus,
Search,
Trash2,
Loader2,
Sparkles,
Circle,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import { trainingService, type CefrLevel } from "@/services/training.service";
const grammarItems = [
{ rule: "Conditional Sentences (Type 2 & 3)", description: "If + past simple / past perfect for hypothetical situations", level: "B2", completed: true },
{ rule: "Passive Voice in Academic Writing", description: "Using passive constructions in formal reports", level: "B2", completed: false },
{ rule: "Relative Clauses", description: "Defining vs non-defining relative clauses", level: "B1", completed: true },
{ rule: "Subject-Verb Agreement", description: "Complex subjects with prepositional phrases", level: "B1", completed: false },
{ rule: "Modal Verbs for Speculation", description: "Must have, could have, might have + past participle", level: "C1", completed: false },
{ rule: "Reported Speech", description: "Tense changes and time references in indirect speech", level: "B2", completed: true },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function GrammarPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = grammarItems.filter(g => g.completed).length;
const filtered = showCompleted ? grammarItems : grammarItems.filter(g => !g.completed);
const { toast } = useToast();
const qc = useQueryClient();
const [showCompleted, setShowCompleted] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
name: "",
description: "",
example: "",
level: "B1" as CefrLevel,
category: "general",
});
const listQ = useQuery({
queryKey: ["training-grammar", levelFilter, search],
queryFn: () =>
trainingService.listGrammar({
...(levelFilter !== "all" ? { level: levelFilter } : {}),
...(search ? { search } : {}),
}),
});
const items = listQ.data?.items ?? [];
const summary = listQ.data?.summary;
const filtered = showCompleted ? items : items.filter((g) => !g.completed);
const createMut = useMutation({
mutationFn: trainingService.createGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
setCreateOpen(false);
setForm({
name: "",
description: "",
example: "",
level: "B1",
category: "general",
});
toast({ title: "Rule added" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to add rule";
toast({ title: msg, variant: "destructive" });
},
});
const delMut = useMutation({
mutationFn: trainingService.deleteGrammar,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-grammar"] });
toast({ title: "Rule deleted" });
},
});
const progressMut = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
trainingService.setGrammarProgress(id, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-grammar"] }),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">Master grammar rules essential for IELTS.</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Grammar Training</h1>
<p className="text-muted-foreground">
Manage the grammar-rule library and track mastery by CEFR level.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Rule
</Button>
</div>
<AiTipBanner tip="You've completed 50% of grammar topics. Focus on Passive Voice next — it appears in 73% of IELTS Writing Task 1 questions and will boost your band score." variant="recommendation" />
<AiTipBanner context="grammar" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
@@ -36,31 +124,104 @@ export default function GrammarPage() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{grammarItems.length} completed</span>
<span className="text-sm text-muted-foreground">
{summary
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
: "—"}
</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / grammarItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search rules…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch
id="show-g"
checked={showCompleted}
onCheckedChange={setShowCompleted}
/>
<Label htmlFor="show-g" className="text-sm">
Show completed
</Label>
</div>
</div>
<div className="space-y-2">
{listQ.isLoading && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
{!listQ.isLoading && filtered.length === 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="text-center text-muted-foreground py-8">
No grammar rules match.
</CardContent>
</Card>
)}
{filtered.map((g) => (
<Card key={g.rule} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{g.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{g.rule}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
</div>
<Card key={g.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: g.id, completed: !g.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
aria-label={g.completed ? "Mark incomplete" : "Mark complete"}
>
{g.completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{g.name}</p>
<p className="text-xs text-muted-foreground">{g.description}</p>
{g.example && (
<p className="text-xs italic text-muted-foreground mt-0.5">
e.g. {g.example}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{g.level}</Badge>
<Badge variant="secondary" className="text-xs">
{g.category}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => delMut.mutate(g.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Badge variant="outline">{g.level}</Badge>
</CardContent>
</Card>
))}
@@ -70,25 +231,133 @@ export default function GrammarPage() {
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Practice passive voice transformations high exam frequency</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review modal verbs for IELTS Writing Task 1</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on complex sentence structures for C1 readiness</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Your conditional sentences are strong try advanced mixed conditionals</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Practice passive-voice transformations high exam frequency.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Review modal verbs for IELTS Writing Task 1.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Focus on complex sentences for C1 readiness.
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Study Plan</p>
<p className="text-sm text-muted-foreground">Based on your progress, complete Passive Voice and Subject-Verb Agreement this week. At your current pace, you'll finish all B2 grammar by April 15.</p>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
<Sparkles className="h-3 w-3" /> AI Study Plan
</p>
<p className="text-sm text-muted-foreground">
{summary.remaining > 0
? `Finish the remaining ${summary.remaining} rule(s) at one per day to stay on track.`
: "You've mastered every active rule in the library."}
</p>
</CardContent>
</Card>
)}
</div>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Grammar Rule</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Rule name</Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. Present Perfect vs Past Simple"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
placeholder="When and how this rule applies"
/>
</div>
<div className="space-y-2">
<Label>Example (optional)</Label>
<Textarea
value={form.example}
onChange={(e) => setForm((f) => ({ ...f, example: e.target.value }))}
placeholder="A sample sentence"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>CEFR level</Label>
<Select
value={form.level}
onValueChange={(v) =>
setForm((f) => ({ ...f, level: v as CefrLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
(l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) =>
setForm((f) => ({ ...f, category: e.target.value }))
}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={
!form.name.trim() ||
!form.description.trim() ||
createMut.isPending
}
onClick={() => createMut.mutate(form)}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding
</>
) : (
"Add"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,14 +0,0 @@
// Update this page (the content is just a fallback if you fail to update the page)
const Index = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
</div>
</div>
);
};
export default Index;

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
import type { UserRole } from "@/types/auth";
import { LanguageToggle } from "@/components/LanguageToggle";
/** Keep in sync with `ProtectedRoute` post-login targets */
function getRoleDashboard(role: string): string {
@@ -48,11 +50,16 @@ export default function Login() {
const navigate = useNavigate();
const { login } = useAuth();
const { toast } = useToast();
const { t } = useTranslation();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({ title: "Error", description: "Please enter email and password", variant: "destructive" });
toast({
title: t("auth.errorTitle"),
description: t("auth.missingCredentials"),
variant: "destructive",
});
return;
}
@@ -61,7 +68,11 @@ export default function Login() {
const user = await login(email, password);
navigate(getRoleDashboard(user.user_type));
} catch (err: unknown) {
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
toast({
title: t("auth.loginFailedTitle"),
description: loginErrorMessage(err),
variant: "destructive",
});
} finally {
setLoading(false);
}
@@ -69,35 +80,39 @@ export default function Login() {
return (
<div className="min-h-screen flex items-center justify-center bg-background p-4">
<div className="absolute top-4 right-4">
<LanguageToggle />
</div>
<div className="w-full max-w-md">
<div className="flex items-center justify-center gap-3 mb-8">
<img src="/logo.png" alt="EnCoach" className="h-12 w-12 rounded-xl object-contain" />
<h1 className="text-2xl font-bold tracking-tight">
<span className="text-[hsl(42,40%,62%)]">En</span>
<span className="text-[hsl(240,20%,30%)]">Coach</span>
</h1>
<div className="flex items-center justify-center mb-8">
<img
src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform"
className="h-36 w-auto object-contain"
/>
</div>
<Card className="shadow-lg border-0 bg-card">
<CardHeader className="text-center pb-4">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Sign in to your account to continue</CardDescription>
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
<CardDescription>{t("auth.signInDescription")}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">{t("auth.email")}</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
type="text"
placeholder={t("auth.emailPlaceholder")}
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={loading}
dir="ltr"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t("auth.password")}</Label>
<div className="relative">
<Input
id="password"
@@ -106,11 +121,13 @@ export default function Login() {
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loading}
dir="ltr"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={showPassword ? t("auth.password") : t("auth.password")}
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
@@ -119,19 +136,25 @@ export default function Login() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Checkbox id="remember" />
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
{t("auth.rememberMe")}
</Label>
</div>
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
{t("auth.forgotPassword")}
</Link>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("auth.signIn")}
</Button>
</form>
<p className="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
{t("auth.needAccount")}{" "}
<Link to="/register" className="text-primary font-medium hover:underline">
{t("auth.signUp")}
</Link>
</p>
</CardContent>
</Card>

View File

@@ -1,8 +1,10 @@
import { useLocation } from "react-router-dom";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
const NotFound = () => {
const location = useLocation();
const { t } = useTranslation();
useEffect(() => {
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
@@ -11,10 +13,10 @@ const NotFound = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">404</h1>
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
<a href="/" className="text-primary underline hover:text-primary/90">
Return to Home
{t("errors.returnHome")}
</a>
</div>
</div>

View File

@@ -1,58 +1,105 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Plus, Download } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Download, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
const payments = [
{ id: "PAY-001", corporate: "Acme Corp", manager: "John Admin", amount: 5000, currency: "USD", paid: true, date: "2025-01-15", type: "Corporate" },
{ id: "PAY-002", corporate: "Global Ltd", manager: "HR Global", amount: 8500, currency: "USD", paid: true, date: "2025-02-01", type: "Corporate" },
{ id: "PAY-003", corporate: "Tech Co", manager: "Tech Admin", amount: 2000, currency: "USD", paid: false, date: "2025-03-01", type: "Commission" },
];
const paymobOrders = [
{ id: "PMB-1001", status: "Paid", user: "Sarah Johnson", email: "sarah.j@email.com", amount: 199.00 },
{ id: "PMB-1002", status: "Pending", user: "Ahmed Hassan", email: "ahmed.h@email.com", amount: 149.00 },
{ id: "PMB-1003", status: "Paid", user: "Li Wei", email: "li.w@email.com", amount: 199.00 },
{ id: "PMB-1004", status: "Failed", user: "Emma Brown", email: "emma.b@email.com", amount: 99.00 },
];
import { paymentsService } from "@/services/payments.service";
export default function PaymentRecordPage() {
const { data, isLoading } = useQuery({
queryKey: ["payment-records"],
queryFn: () => paymentsService.list(),
});
const { data: paymobData } = useQuery({
queryKey: ["paymob-orders"],
queryFn: () => paymentsService.paymobOrders(),
});
const payments = data?.items ?? [];
const totals = data?.totals;
const handleExport = () => {
if (!payments.length) return;
const header = ["Ref", "Student", "Course", "Product", "Amount", "Currency", "State", "Paid", "Date"];
const rows = payments.map((p) => [
p.ref,
p.student_name,
p.course_name,
p.product_name,
p.amount.toFixed(2),
p.currency,
p.state,
p.paid ? "yes" : "no",
p.date,
]);
const csv = [header, ...rows].map((r) => r.join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `payment-records-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Payment Record</h1>
<p className="text-muted-foreground">Manage payments, commissions, and Paymob orders.</p>
<p className="text-muted-foreground">
Live student fees, invoices, and Paymob orders.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm"><Download className="h-4 w-4 mr-1" /> Export CSV</Button>
<Dialog>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> New Payment</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Create Payment Record</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Corporate</Label><Select><SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger><SelectContent><SelectItem value="acme">Acme Corp</SelectItem></SelectContent></Select></div>
<div className="space-y-2"><Label>Amount (USD)</Label><Input type="number" placeholder="5000" /></div>
<div className="space-y-2"><Label>Type</Label><Select><SelectTrigger><SelectValue placeholder="Type" /></SelectTrigger><SelectContent><SelectItem value="corporate">Corporate</SelectItem><SelectItem value="commission">Commission</SelectItem></SelectContent></Select></div>
<Button className="w-full">Create</Button>
</div>
</DialogContent>
</Dialog>
<Button variant="outline" size="sm" onClick={handleExport} disabled={!payments.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
</div>
<AiTipBanner tip="PAY-003 (Tech Co) is unpaid and overdue. PMB-1004 failed — AI recommends sending an automated retry notification to Emma Brown." variant="recommendation" />
{totals && (
<div className="grid gap-4 md:grid-cols-4">
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Total records</p>
<p className="text-2xl font-bold">{totals.count}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Paid</p>
<p className="text-2xl font-bold text-green-600">{totals.paid}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Unpaid</p>
<p className="text-2xl font-bold text-red-600">{totals.unpaid}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="pt-6">
<p className="text-xs text-muted-foreground">Total amount</p>
<p className="text-2xl font-bold">${totals.amount.toLocaleString()}</p>
</CardContent>
</Card>
</div>
)}
<AiTipBanner context="payment-record" variant="recommendation" />
<Tabs defaultValue="payments">
<TabsList>
@@ -61,26 +108,57 @@ export default function PaymentRecordPage() {
</TabsList>
<TabsContent value="payments" className="mt-4 space-y-4">
<AiReportNarrative narrative="Total revenue collected: $13,500 from 2 corporate payments. One commission of $2,000 remains unpaid. Collection rate: 67%. Trend: Q1 payments are on track but Tech Co requires follow-up." />
<AiReportNarrative report_type="payments" data={{ payments }} />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead><TableHead>Corporate</TableHead><TableHead>Manager</TableHead>
<TableHead>Amount</TableHead><TableHead>Type</TableHead><TableHead>Paid</TableHead><TableHead>Date</TableHead>
<TableHead>Ref</TableHead>
<TableHead>Student</TableHead>
<TableHead>Course</TableHead>
<TableHead>Product</TableHead>
<TableHead>Amount</TableHead>
<TableHead>State</TableHead>
<TableHead>Paid</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && (
<TableRow>
<TableCell colSpan={8} className="text-center py-8">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
)}
{!isLoading && payments.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground py-8">
No payment records yet.
</TableCell>
</TableRow>
)}
{payments.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-mono text-xs">{p.id}</TableCell>
<TableCell>{p.corporate}</TableCell>
<TableCell>{p.manager}</TableCell>
<TableCell className="font-semibold">${p.amount.toLocaleString()}</TableCell>
<TableCell><Badge variant="outline">{p.type}</Badge></TableCell>
<TableCell><Badge variant={p.paid ? "default" : "destructive"}>{p.paid ? "Paid" : "Unpaid"}</Badge></TableCell>
<TableCell>{p.date}</TableCell>
<TableCell className="font-mono text-xs">{p.ref}</TableCell>
<TableCell>{p.student_name || "—"}</TableCell>
<TableCell>{p.course_name || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{p.product_name || "—"}
</TableCell>
<TableCell className="font-semibold">
${p.amount.toLocaleString()} {p.currency}
</TableCell>
<TableCell>
<Badge variant="outline">{p.state}</Badge>
</TableCell>
<TableCell>
<Badge variant={p.paid ? "default" : "destructive"}>
{p.paid ? "Paid" : "Unpaid"}
</Badge>
</TableCell>
<TableCell>{p.date || "—"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -91,26 +169,50 @@ export default function PaymentRecordPage() {
<TabsContent value="paymob" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead><TableHead>Status</TableHead><TableHead>User</TableHead>
<TableHead>Email</TableHead><TableHead>Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paymobOrders.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs">{o.id}</TableCell>
<TableCell><Badge variant={o.status === "Paid" ? "default" : o.status === "Pending" ? "secondary" : "destructive"}>{o.status}</Badge></TableCell>
<TableCell>{o.user}</TableCell>
<TableCell>{o.email}</TableCell>
<TableCell className="font-semibold">${o.amount.toFixed(2)}</TableCell>
<CardContent className="p-6">
{(paymobData?.items?.length ?? 0) === 0 ? (
<div className="text-center text-muted-foreground py-8">
{paymobData?.message ??
"Paymob integration is not yet wired. Orders will appear here once configured."}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Order ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Amount</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{paymobData!.items.map((o) => (
<TableRow key={o.id}>
<TableCell className="font-mono text-xs">{o.id}</TableCell>
<TableCell>
<Badge
variant={
o.status === "Paid"
? "default"
: o.status === "Pending"
? "secondary"
: "destructive"
}
>
{o.status}
</Badge>
</TableCell>
<TableCell>{o.user}</TableCell>
<TableCell>{o.email}</TableCell>
<TableCell className="font-semibold">
${o.amount.toFixed(2)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>

173
src/pages/PrivacyCenter.tsx Normal file
View File

@@ -0,0 +1,173 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Download, ShieldAlert, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { gdprService } from "@/services/gdpr.service";
import { useAuth } from "@/contexts/AuthContext";
export default function PrivacyCenter() {
const navigate = useNavigate();
const { logout } = useAuth();
const [exporting, setExporting] = useState(false);
const [erasing, setErasing] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const downloadExport = async () => {
try {
setExporting(true);
const payload = await gdprService.exportMyData();
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `encoach-data-export-${new Date()
.toISOString()
.slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
toast.success("Data export downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setExporting(false);
}
};
const eraseAccount = async () => {
try {
setErasing(true);
await gdprService.eraseMyAccount();
toast.success("Your account has been erased. Signing out…");
setTimeout(async () => {
try {
await logout();
} catch {
// ignore — the server may have already invalidated our tokens
}
navigate("/login", { replace: true });
}, 1200);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Erasure failed");
setErasing(false);
}
};
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
<ShieldAlert className="mr-1 inline h-5 w-5" />
Privacy Center
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Manage your personal data held by EnCoach under GDPR and equivalent
regulations.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Download your data</CardTitle>
<CardDescription>
We'll package your profile, entity memberships, exam attempts,
answers, AI feedback, and tickets into a single JSON file.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={downloadExport} disabled={exporting}>
<Download className="mr-1 h-4 w-4" />
{exporting ? "Preparing export…" : "Download my data"}
</Button>
</CardContent>
</Card>
<Card className="border-destructive/40">
<CardHeader>
<CardTitle className="text-destructive">Delete my account</CardTitle>
<CardDescription>
This anonymises your profile and removes personal fields from our
records. Exam attempts are retained (without identifying
information) for statistical integrity. This action cannot be
undone.
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={erasing}
>
<Trash2 className="mr-1 h-4 w-4" />
{erasing ? "Erasing…" : "Erase my account"}
</Button>
</CardContent>
</Card>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent description="Erasure is permanent — type DELETE to confirm.">
<AlertDialogHeader>
<AlertDialogTitle>Erase your account?</AlertDialogTitle>
<AlertDialogDescription>
We'll anonymise your personal data and deactivate your login.
Aggregate analytics (exam scores, attempt counts) will remain
but will no longer be linked to you.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-1">
<Label htmlFor="confirm-delete">
Type <strong>DELETE</strong> to confirm:
</Label>
<Input
id="confirm-delete"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="DELETE"
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setConfirmText("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={confirmText !== "DELETE" || erasing}
onClick={async () => {
setConfirmOpen(false);
await eraseAccount();
setConfirmText("");
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Erase account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { User } from "lucide-react";
import { api } from "@/lib/api-client";
import { useMutation } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function ProfilePage() {
const { user } = useAuth();
const { toast } = useToast();
const nameParts = (user?.name || "Admin User").split(" ");
const [first, setFirst] = useState(nameParts[0] || "");
const [last, setLast] = useState(nameParts.slice(1).join(" ") || "");
const [email, setEmail] = useState(user?.email || "");
const [curPw, setCurPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const saveMut = useMutation({
mutationFn: () => api.patch("/user", { first_name: first, last_name: last, email }),
onSuccess: () => toast({ title: "Profile saved" }),
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const pwMut = useMutation({
mutationFn: () => api.post("/user/change-password", { current_password: curPw, new_password: newPw }),
onSuccess: () => { setCurPw(""); setNewPw(""); setConfirmPw(""); toast({ title: "Password updated" }); },
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold tracking-tight">Profile</h1>
<p className="text-muted-foreground">Manage your account information.</p>
</div>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Personal Information</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<User className="h-8 w-8 text-primary" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"><Label>First Name</Label><Input value={first} onChange={(e) => setFirst(e.target.value)} /></div>
<div className="space-y-2"><Label>Last Name</Label><Input value={last} onChange={(e) => setLast(e.target.value)} /></div>
</div>
<div className="space-y-2"><Label>Email</Label><Input value={email} onChange={(e) => setEmail(e.target.value)} type="email" /></div>
<Button onClick={() => saveMut.mutate()} disabled={saveMut.isPending}>
{saveMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Change Password</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Current Password</Label><Input type="password" value={curPw} onChange={(e) => setCurPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} placeholder="••••••••" /></div>
<div className="space-y-2"><Label>Confirm New Password</Label><Input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} placeholder="••••••••" /></div>
{newPw && confirmPw && newPw !== confirmPw && <p className="text-sm text-destructive">Passwords do not match.</p>}
<Button onClick={() => pwMut.mutate()} disabled={pwMut.isPending || !curPw || !newPw || newPw !== confirmPw}>
{pwMut.isPending ? "Updating..." : "Update Password"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,66 +1,212 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
import { reportsService, RecordRow } from "@/services/reports.service";
const records = [
{ id: 1, assignment: "IELTS Prep Q1", exam: "EX-001", date: "2025-03-01", score: 7.5, duration: "175 min", status: "Completed" },
{ id: 2, assignment: "Speaking Workshop", exam: "EX-005", date: "2025-03-05", score: 6.0, duration: "14 min", status: "Completed" },
{ id: 3, assignment: "Full Mock Exam", exam: "EX-006", date: "2025-03-10", score: null, duration: "120 min", status: "In Progress" },
{ id: 4, assignment: "Listening Bootcamp", exam: "EX-003", date: "2025-02-20", score: 5.5, duration: "28 min", status: "Completed" },
];
type Period = "all" | "day" | "week" | "month";
export default function RecordPage() {
const [entityId, setEntityId] = useState("all");
const [userId, setUserId] = useState("all");
const [period, setPeriod] = useState<Period>("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["record", entityId, userId, period],
queryFn: () =>
reportsService.record({
entity_id: entityId === "all" ? undefined : Number(entityId),
user_id: userId === "all" ? undefined : Number(userId),
period: period === "all" ? undefined : period,
size: 100,
}),
});
const records: RecordRow[] = data?.items ?? [];
const exportCsv = () => {
const header = [
"Student",
"Assignment",
"Exam",
"Date",
"Score",
"Duration",
"Status",
];
const lines = records.map((r) =>
[
r.student_name,
r.assignment,
r.exam_code || r.exam,
r.date ?? "",
r.score ?? "",
r.duration,
r.status_label,
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
);
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
type: "text/csv",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `records-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
const statusVariant = (status: string) => {
if (status === "completed" || status === "released" || status === "scored")
return "default";
if (status === "in_progress" || status === "scoring") return "secondary";
return "outline";
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">Browse assignment and exam attempt history.</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Record</h1>
<p className="text-muted-foreground">
Browse assignment and exam attempt history.
{data && (
<span className="ml-2 text-xs">({data.total} attempts)</span>
)}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={exportCsv}
disabled={!records.length}
>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
<AiTipBanner tip="The student's scores show an upward trend from 5.5 → 6.0 → 7.5 over the last 3 completed exams. Listening remains the weakest module — recommend targeted practice." variant="insight" />
<AiTipBanner context="record" variant="insight" />
<AiReportNarrative narrative="3 of 4 attempts completed with an average score of 6.3. Time management is good — all exams finished within allocated time. The Full Mock Exam is still in progress (67% time used). Strongest area: Reading (7.5), weakest: Listening (5.5)." />
{records.length > 0 && (
<AiReportNarrative report_type="record" data={{ records }} />
)}
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="Entity" /></SelectTrigger>
<SelectContent><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="Select User" /></SelectTrigger>
<SelectContent><SelectItem value="sarah">Sarah Johnson</SelectItem><SelectItem value="ahmed">Ahmed Hassan</SelectItem></SelectContent>
<Select value={userId} onValueChange={setUserId}>
<SelectTrigger className="w-[220px]">
<SelectValue placeholder="All Users" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Users</SelectItem>
{filters?.students.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1">
{["Day", "Week", "Month"].map(t => (
<Button key={t} variant="outline" size="sm">{t}</Button>
{(["all", "day", "week", "month"] as Period[]).map((p) => (
<Button
key={p}
variant={period === p ? "default" : "outline"}
size="sm"
onClick={() => setPeriod(p)}
>
{p === "all" ? "All" : p.charAt(0).toUpperCase() + p.slice(1)}
</Button>
))}
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead><TableHead>Exam</TableHead><TableHead>Date</TableHead>
<TableHead>Score</TableHead><TableHead>Duration</TableHead><TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.assignment}</TableCell>
<TableCell className="font-mono text-xs">{r.exam}</TableCell>
<TableCell>{r.date}</TableCell>
<TableCell>{r.score ? r.score.toFixed(1) : "—"}</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell><Badge variant={r.status === "Completed" ? "default" : "secondary"}>{r.status}</Badge></TableCell>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
attempts...
</div>
) : records.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No attempts match these filters.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Assignment</TableHead>
<TableHead>Exam</TableHead>
<TableHead>Date</TableHead>
<TableHead>Score</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Status</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{records.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">
{r.student_name || "—"}
</TableCell>
<TableCell>{r.assignment || "—"}</TableCell>
<TableCell className="font-mono text-xs">
{r.exam_code}
</TableCell>
<TableCell>{r.date || "—"}</TableCell>
<TableCell>
{r.score ? r.score.toFixed(1) : "—"}
</TableCell>
<TableCell>{r.duration}</TableCell>
<TableCell>
<Badge variant={statusVariant(r.status)}>
{r.status_label}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@@ -1,17 +1,18 @@
import { useState } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { Link, useNavigate } from "react-router-dom";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Progress } from "@/components/ui/progress";
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
import { ApiError } from "@/lib/api-client";
import { ApiError, api } from "@/lib/api-client";
import type { RegisterRequest } from "@/types";
import { cn } from "@/lib/utils";
@@ -47,6 +48,41 @@ export default function Register() {
const checkEmail = useCheckEmail();
const [showPassword, setShowPassword] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [captchaToken, setCaptchaToken] = useState<string>("");
const captchaContainerRef = useRef<HTMLDivElement>(null);
const { data: captchaConfig } = useQuery({
queryKey: ["captcha-config"],
queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"),
staleTime: Infinity,
});
const loadCaptchaScript = useCallback((provider: string, siteKey: string) => {
if (!siteKey || !provider) return;
const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`);
if (existingScript) return;
const scriptUrls: Record<string, string> = {
recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
hcaptcha: "https://js.hcaptcha.com/1/api.js",
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
};
const url = scriptUrls[provider];
if (!url) return;
const script = document.createElement("script");
script.src = url;
script.async = true;
script.defer = true;
script.setAttribute("data-captcha-provider", provider);
document.head.appendChild(script);
}, []);
useEffect(() => {
if (captchaConfig?.site_key && captchaConfig?.provider) {
loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key);
}
}, [captchaConfig, loadCaptchaScript]);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -69,7 +105,7 @@ export default function Register() {
email: values.email.trim(),
password: values.password,
role: values.role,
captcha_token: "demo-placeholder",
captcha_token: captchaToken || undefined,
};
try {
await register.mutateAsync(payload);
@@ -251,10 +287,26 @@ export default function Register() {
/>
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
<p className="text-sm font-medium">CAPTCHA</p>
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
Verification widget placeholder
<p className="text-sm font-medium flex items-center gap-2">
<ShieldCheck className="h-4 w-4" /> Verification
</p>
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center">
{captchaConfig?.site_key ? (
<div
className={captchaConfig.provider === "hcaptcha" ? "h-captcha" :
captchaConfig.provider === "turnstile" ? "cf-turnstile" : "g-recaptcha"}
data-sitekey={captchaConfig.site_key}
data-callback="onCaptchaSuccess"
/>
) : (
<p className="text-xs text-muted-foreground">CAPTCHA not configured registration allowed</p>
)}
</div>
{captchaToken && (
<p className="text-xs text-green-600 flex items-center gap-1">
<ShieldCheck className="h-3 w-3" /> Verified
</p>
)}
</div>
{form.formState.errors.root && (

View File

@@ -1,31 +1,644 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Search, Plus } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Search, Plus, Loader2, Pencil, Trash2, X, Sparkles, ChevronDown } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
import { examsService } from "@/services/exams.service";
import { useToast } from "@/hooks/use-toast";
const rubrics = [
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
interface RubricItem {
id: number;
name: string;
skill: string;
exam_type: string;
criteria: number;
criteria_text: string;
levels: string[];
created: string;
}
interface CriterionEntry {
name: string;
weight: number;
descriptors: Record<string, string>;
}
const SKILL_OPTIONS = [
{ value: "writing", label: "Writing" },
{ value: "speaking", label: "Speaking" },
];
const rubricGroups = [
{ id: 1, name: "Academic IELTS Full", rubrics: ["IELTS Writing Task 2", "Speaking Fluency", "Reading Comprehension"], created: "2025-02-15" },
{ id: 2, name: "Business English", rubrics: ["Speaking Fluency"], created: "2025-03-01" },
const EXAM_TYPE_OPTIONS = [
{ value: "academic", label: "Academic" },
{ value: "general_training", label: "General Training" },
{ value: "general_english", label: "General English" },
];
const ALL_CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
interface RubricFormState {
name: string;
skill: string;
exam_type: string;
criteria: CriterionEntry[];
levels: Set<string>;
}
const emptyCriterion = (levels: Set<string>, name = "", weight = 0): CriterionEntry => ({
name,
weight,
descriptors: Object.fromEntries([...levels].map((l) => [l, ""])),
});
/**
* Canonical criterion catalog by skill. QA asked the "Add Criterion" button
* to offer a dropdown of predefined names instead of a blank row.
* Source: official IELTS public band descriptors for Writing & Speaking.
* "Custom…" keeps the old "add empty row and type it yourself" behaviour.
*/
const CRITERION_PRESETS: Record<string, { label: string; items: { name: string; weight: number }[] }[]> = {
writing: [
{
label: "IELTS Writing — Task 1",
items: [
{ name: "Task Achievement", weight: 25 },
{ name: "Coherence and Cohesion", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
],
},
{
label: "IELTS Writing — Task 2",
items: [
{ name: "Task Response", weight: 25 },
{ name: "Coherence and Cohesion", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
],
},
{
label: "Generic academic writing",
items: [
{ name: "Content & Ideas", weight: 25 },
{ name: "Organization", weight: 25 },
{ name: "Language Use", weight: 25 },
{ name: "Mechanics", weight: 25 },
],
},
],
speaking: [
{
label: "IELTS Speaking",
items: [
{ name: "Fluency and Coherence", weight: 25 },
{ name: "Lexical Resource", weight: 25 },
{ name: "Grammatical Range and Accuracy", weight: 25 },
{ name: "Pronunciation", weight: 25 },
],
},
{
label: "Generic oral skills",
items: [
{ name: "Content", weight: 25 },
{ name: "Delivery", weight: 25 },
{ name: "Interaction", weight: 25 },
{ name: "Language", weight: 25 },
],
},
],
};
const emptyForm = (): RubricFormState => ({
name: "",
skill: "writing",
exam_type: "academic",
criteria: [],
levels: new Set(ALL_CEFR_LEVELS),
});
function parseCriteriaText(text: string, levels: string[]): CriterionEntry[] {
if (!text) return [];
const mapStructured = (arr: unknown[]): CriterionEntry[] =>
arr.map((c) => {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors:
typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(levels.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")]))
: Object.fromEntries(levels.map((l) => [l, ""])),
};
});
try {
const parsed = JSON.parse(text);
// Handle {"criteria": [...]} wrapper (from AI or previous saves)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed) && Array.isArray(parsed.criteria)) {
return mapStructured(parsed.criteria);
}
if (Array.isArray(parsed)) {
if (parsed.length > 0 && typeof parsed[0] === "object" && parsed[0] !== null && "name" in parsed[0]) {
return mapStructured(parsed);
}
return parsed.filter(Boolean).map((name) => ({
name: String(name),
weight: Math.round(100 / parsed.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
} catch { /* not JSON */ }
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (lines.length === 0) return [];
return lines.map((name) => ({
name,
weight: Math.round(100 / lines.length),
descriptors: Object.fromEntries(levels.map((l) => [l, ""])),
}));
}
interface GroupFormState {
name: string;
rubric_ids: Set<number>;
}
const emptyGroupForm = (): GroupFormState => ({ name: "", rubric_ids: new Set() });
export default function RubricsPage() {
const { toast } = useToast();
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [createForm, setCreateForm] = useState<RubricFormState>(emptyForm());
const [editTarget, setEditTarget] = useState<RubricItem | null>(null);
const [editForm, setEditForm] = useState<RubricFormState>(emptyForm());
const [groupCreateOpen, setGroupCreateOpen] = useState(false);
const [groupCreateForm, setGroupCreateForm] = useState<GroupFormState>(emptyGroupForm());
const [groupEditTarget, setGroupEditTarget] = useState<{ id: number; name: string; rubric_ids: number[] } | null>(null);
const [groupEditForm, setGroupEditForm] = useState<GroupFormState>(emptyGroupForm());
const rubricsQ = useQuery({
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
queryFn: () => examsService.listRubricGroups({}),
});
const rubricGroups = rubricGroupsQ.data?.items ?? [];
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) => examsService.createRubric(data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setCreateOpen(false);
setCreateForm(emptyForm());
toast({ title: "Rubric created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create failed", description: err.message }),
});
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: Record<string, unknown> }) =>
examsService.updateRubric(id, data as never),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
setEditTarget(null);
toast({ title: "Rubric updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update failed", description: err.message }),
});
const deleteMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubric(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubrics"] });
toast({ title: "Rubric deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete failed", description: err.message }),
});
const createGroupMut = useMutation({
mutationFn: (data: { name: string; rubric_ids: number[] }) => examsService.createRubricGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupCreateOpen(false);
setGroupCreateForm(emptyGroupForm());
toast({ title: "Rubric group created" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Create group failed", description: err.message }),
});
const updateGroupMut = useMutation({
mutationFn: ({ id, data }: { id: number; data: { name?: string; rubric_ids?: number[] } }) =>
examsService.updateRubricGroup(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
setGroupEditTarget(null);
toast({ title: "Rubric group updated" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Update group failed", description: err.message }),
});
const deleteGroupMut = useMutation({
mutationFn: (id: number) => examsService.deleteRubricGroup(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["rubric-groups"] });
toast({ title: "Rubric group deleted" });
},
onError: (err: Error) => toast({ variant: "destructive", title: "Delete group failed", description: err.message }),
});
const [suggesting, setSuggesting] = useState(false);
const suggestCriteria = async (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
setSuggesting(true);
try {
const result = await examsService.suggestRubricCriteria({
name: form.name,
skill: form.skill,
exam_type: form.exam_type,
levels: [...form.levels],
});
if (result.criteria && Array.isArray(result.criteria) && result.criteria.length > 0) {
const levelsArr = [...form.levels];
const entries: CriterionEntry[] = result.criteria.map((c: unknown) => {
if (typeof c === "object" && c !== null) {
const obj = c as Record<string, unknown>;
return {
name: String(obj.name || ""),
weight: Number(obj.weight || 0),
descriptors: typeof obj.descriptors === "object" && obj.descriptors !== null
? Object.fromEntries(
levelsArr.map((l) => [l, String((obj.descriptors as Record<string, unknown>)[l] || "")])
)
: Object.fromEntries(levelsArr.map((l) => [l, ""])),
};
}
return { name: String(c), weight: 0, descriptors: Object.fromEntries(levelsArr.map((l) => [l, ""])) };
});
setForm((f) => ({ ...f, criteria: entries }));
toast({ title: "Criteria generated", description: `${entries.length} criteria with band descriptors created by AI.` });
}
} catch (err) {
toast({ variant: "destructive", title: "AI suggestion failed", description: (err as Error).message });
} finally {
setSuggesting(false);
}
};
const openEdit = (r: RubricItem) => {
const levelsArr = Array.isArray(r.levels) && r.levels.length > 0 ? r.levels : ALL_CEFR_LEVELS;
setEditTarget(r);
setEditForm({
name: r.name,
skill: r.skill || "writing",
exam_type: r.exam_type || "academic",
criteria: parseCriteriaText(r.criteria_text, levelsArr),
levels: new Set(levelsArr),
});
};
const formToPayload = (form: RubricFormState) => ({
name: form.name.trim(),
skill: form.skill,
exam_type: form.exam_type,
criteria: JSON.stringify(form.criteria.filter((c) => c.name.trim())),
levels: [...form.levels],
});
const filtered = rubrics.filter((r) => {
if (!search) return true;
const q = search.toLowerCase();
return r.name?.toLowerCase().includes(q) || r.skill?.toLowerCase().includes(q);
});
const updateCriterion = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
index: number,
patch: Partial<CriterionEntry>,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[index] = { ...criteria[index], ...patch };
return { ...f, criteria };
});
};
const updateDescriptor = (
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
cIndex: number,
level: string,
value: string,
) => {
setForm((f) => {
const criteria = [...f.criteria];
criteria[cIndex] = {
...criteria[cIndex],
descriptors: { ...criteria[cIndex].descriptors, [level]: value },
};
return { ...f, criteria };
});
};
const renderForm = (
form: RubricFormState,
setForm: React.Dispatch<React.SetStateAction<RubricFormState>>,
) => {
const levelsArr = [...form.levels].sort(
(a, b) => ALL_CEFR_LEVELS.indexOf(a) - ALL_CEFR_LEVELS.indexOf(b)
);
const totalWeight = form.criteria.reduce((s, c) => s + (c.weight || 0), 0);
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Rubric Name <span className="text-destructive">*</span></Label>
<Input
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
placeholder="e.g. IELTS Writing Task 1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Skill</Label>
<Select value={form.skill} onValueChange={(v) => setForm((f) => ({ ...f, skill: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{SKILL_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Exam Type</Label>
<Select value={form.exam_type} onValueChange={(v) => setForm((f) => ({ ...f, exam_type: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{EXAM_TYPE_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>CEFR Levels</Label>
<div className="flex flex-wrap gap-2">
{ALL_CEFR_LEVELS.map((level) => {
const checked = form.levels.has(level);
return (
<label
key={level}
className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 cursor-pointer transition-all text-sm font-medium ${
checked ? "bg-primary/10 border-primary text-primary" : "hover:bg-muted/50"
}`}
>
<Checkbox
checked={checked}
onCheckedChange={(val) => {
setForm((f) => {
const next = new Set(f.levels);
if (val) next.add(level); else next.delete(level);
return { ...f, levels: next };
});
}}
/>
{level}
</label>
);
})}
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div>
<Label>Criteria</Label>
{form.criteria.length > 0 && (
<span className={`ml-2 text-xs ${totalWeight === 100 ? "text-green-600" : "text-amber-600"}`}>
(Total weight: {totalWeight}%)
</span>
)}
</div>
<Button
type="button"
size="sm"
className="h-8 bg-gradient-to-r from-violet-500 to-indigo-500 text-white hover:from-violet-600 hover:to-indigo-600"
disabled={suggesting}
onClick={() => suggestCriteria(form, setForm)}
>
{suggesting
? <><Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> Generating...</>
: <><Sparkles className="h-3.5 w-3.5 mr-1.5" /> Generate with AI</>
}
</Button>
</div>
{suggesting ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed border-violet-300 bg-violet-50/50">
<Loader2 className="h-6 w-6 animate-spin text-violet-500" />
<p className="text-sm font-medium">AI is generating criteria with band descriptors...</p>
<p className="text-xs">Based on {form.skill} / {form.exam_type.replace(/_/g, " ")}</p>
</div>
) : form.criteria.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 gap-2 text-muted-foreground rounded-lg border border-dashed">
<Sparkles className="h-5 w-5 text-violet-400" />
<p className="text-sm">No criteria yet.</p>
<p className="text-xs">Click <strong>Generate with AI</strong> to create criteria automatically, or pick a preset below.</p>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="mt-1">
<Plus className="h-3 w-3 mr-1" /> Add Manually <ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
<div key={group.label}>
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
{group.items.map((item) => (
<DropdownMenuItem
key={`${group.label}-${item.name}`}
onClick={() =>
setForm((f) => ({
...f,
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
}))
}
>
{item.name}
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
<DropdownMenuItem
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
Custom (blank row)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
) : (
<div className="space-y-3">
{form.criteria.map((c, ci) => (
<Collapsible key={ci} defaultOpen={form.criteria.length <= 3}>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 p-3">
<CollapsibleTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<Input
value={c.name}
onChange={(e) => updateCriterion(setForm, ci, { name: e.target.value })}
placeholder="Criterion name"
className="flex-1 h-8 font-medium"
/>
<div className="flex items-center gap-1 shrink-0">
<Input
type="number"
min={0}
max={100}
value={c.weight}
onChange={(e) => updateCriterion(setForm, ci, { weight: Number(e.target.value) })}
className="w-16 h-8 text-center text-sm"
/>
<span className="text-xs text-muted-foreground">%</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => setForm((f) => ({ ...f, criteria: f.criteria.filter((_, i) => i !== ci) }))}
>
<X className="h-4 w-4" />
</Button>
</div>
<CollapsibleContent>
<div className="px-3 pb-3 pt-0 border-t">
<p className="text-xs text-muted-foreground my-2">Band level descriptors:</p>
<div className="space-y-2">
{levelsArr.map((level) => (
<div key={level} className="flex gap-2 items-start">
<Badge variant="outline" className="mt-1.5 shrink-0 w-9 justify-center text-xs">{level}</Badge>
<Textarea
value={c.descriptors[level] || ""}
onChange={(e) => updateDescriptor(setForm, ci, level, e.target.value)}
placeholder={`Performance descriptor for ${level}...`}
className="min-h-[40px] h-10 text-sm resize-none"
/>
</div>
))}
</div>
</div>
</CollapsibleContent>
</div>
</Collapsible>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="outline" size="sm" className="w-full">
<Plus className="h-3 w-3 mr-1" /> Add Criterion <ChevronDown className="h-3 w-3 ml-1" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-72">
{(CRITERION_PRESETS[form.skill] ?? []).map((group) => (
<div key={group.label}>
<DropdownMenuLabel className="text-[11px] text-muted-foreground">{group.label}</DropdownMenuLabel>
{group.items.map((item) => (
<DropdownMenuItem
key={`${group.label}-${item.name}`}
onClick={() =>
setForm((f) => ({
...f,
criteria: [...f.criteria, emptyCriterion(f.levels, item.name, item.weight)],
}))
}
>
{item.name}
<span className="ml-auto text-[11px] text-muted-foreground">{item.weight}%</span>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
</div>
))}
<DropdownMenuItem
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
Custom (blank row)
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
</div>
);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
@@ -35,25 +648,25 @@ export default function RubricsPage() {
</div>
<div className="flex gap-2">
<AiCreationAssistant type="rubric" />
<Dialog>
<Dialog open={createOpen} onOpenChange={(open) => { setCreateOpen(open); if (!open) setCreateForm(emptyForm()); }}>
<DialogTrigger asChild>
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Create Rubric</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader><DialogTitle>Create Rubric</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="space-y-2"><Label>Rubric Name</Label><Input placeholder="e.g. IELTS Writing Task 1" /></div>
<div className="space-y-3">
<Label>Level Descriptors</Label>
{["A1","A2","B1","B2","C1","C2"].map(level => (
<div key={level} className="space-y-1">
<Label className="text-xs text-muted-foreground">{level}</Label>
<Textarea placeholder={`Descriptor for level ${level}...`} className="h-16" />
</div>
))}
</div>
<Button className="w-full">Create Rubric</Button>
</div>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Create Rubric</DialogTitle>
<DialogDescription>Define a scoring rubric. Use AI to generate criteria or add them manually.</DialogDescription>
</DialogHeader>
{renderForm(createForm, setCreateForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={!createForm.name.trim() || createForm.criteria.filter((c) => c.name.trim()).length === 0 || createMut.isPending}
onClick={() => createMut.mutate(formToPayload(createForm))}
>
{createMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
@@ -72,51 +685,248 @@ export default function RubricsPage() {
<TabsTrigger value="groups">Rubric Groups</TabsTrigger>
</TabsList>
<TabsContent value="rubrics" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead><TableHead>Levels</TableHead><TableHead>Criteria</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubrics.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><div className="flex gap-1">{r.levels.map(l => <Badge key={l} variant="outline" className="text-xs">{l}</Badge>)}</div></TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell>{r.created}</TableCell>
{rubricsQ.isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{rubricsQ.error && (
<Card className="border-destructive">
<CardContent className="p-4 text-sm text-destructive">
Failed to load rubrics. The backend endpoint may not be available yet.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && !rubricsQ.error && filtered.length === 0 && (
<Card className="border-dashed">
<CardContent className="p-8 text-center text-muted-foreground">
No rubrics found. Create one to get started.
</CardContent>
</Card>
)}
{!rubricsQ.isLoading && filtered.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Skill</TableHead>
<TableHead>Exam Type</TableHead>
<TableHead>Levels</TableHead>
<TableHead>Criteria</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[90px]">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{filtered.map((r) => (
<TableRow key={r.id} className="cursor-pointer hover:bg-muted/50" onClick={() => openEdit(r)}>
<TableCell className="font-medium">{r.name}</TableCell>
<TableCell><Badge variant="secondary" className="capitalize">{r.skill || "—"}</Badge></TableCell>
<TableCell className="capitalize text-sm">{r.exam_type?.replace(/_/g, " ") || "—"}</TableCell>
<TableCell>
<div className="flex gap-1">
{(r.levels || ALL_CEFR_LEVELS).map((l) => (
<Badge key={l} variant="outline" className="text-xs">{l}</Badge>
))}
</div>
</TableCell>
<TableCell>{r.criteria}</TableCell>
<TableCell className="text-sm text-muted-foreground">{r.created}</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8"
onClick={(e) => { e.stopPropagation(); openEdit(r); }}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"
onClick={(e) => { e.stopPropagation(); deleteMut.mutate(r.id); }}
disabled={deleteMut.isPending}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="groups" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead><TableHead>Rubrics</TableHead><TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell><div className="flex gap-1 flex-wrap">{g.rubrics.map(r => <Badge key={r} variant="secondary" className="text-xs">{r}</Badge>)}</div></TableCell>
<TableCell>{g.created}</TableCell>
<div className="flex justify-end mb-3">
<Button size="sm" onClick={() => { setGroupCreateForm(emptyGroupForm()); setGroupCreateOpen(true); }}>
<Plus className="h-4 w-4 mr-1" /> Create Group
</Button>
</div>
{rubricGroupsQ.isLoading && (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length === 0 && (
<Card className="border-dashed"><CardContent className="p-8 text-center text-muted-foreground text-sm">No rubric groups yet. Create one to bundle rubrics together.</CardContent></Card>
)}
{!rubricGroupsQ.isLoading && rubricGroups.length > 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Group Name</TableHead>
<TableHead>Rubrics</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-24 text-right">Actions</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TableHeader>
<TableBody>
{rubricGroups.map((g) => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.name}</TableCell>
<TableCell>
<div className="flex gap-1 flex-wrap">
{(g.rubric_names ?? []).map((rb) => (
<Badge key={rb} variant="secondary" className="text-xs">{rb}</Badge>
))}
{(!g.rubric_names || g.rubric_names.length === 0) && (
<span className="text-xs text-muted-foreground italic">No rubrics</span>
)}
</div>
</TableCell>
<TableCell>{g.created}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => {
setGroupEditTarget({ id: g.id, name: g.name, rubric_ids: g.rubric_ids });
setGroupEditForm({ name: g.name, rubric_ids: new Set(g.rubric_ids) });
}}>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-destructive hover:text-destructive"
onClick={() => { if (confirm(`Delete group "${g.name}"?`)) deleteGroupMut.mutate(g.id); }}>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{/* Create Group Dialog */}
<Dialog open={groupCreateOpen} onOpenChange={setGroupCreateOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create Rubric Group</DialogTitle>
<DialogDescription>Bundle multiple rubrics into a group for easy selection.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupCreateForm.name} onChange={(e) => setGroupCreateForm((p) => ({ ...p, name: e.target.value }))} placeholder="e.g. Academic IELTS Full" />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupCreateForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupCreateForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
{rubrics.length === 0 && <p className="text-xs text-muted-foreground italic py-2 text-center">No rubrics available</p>}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupCreateOpen(false)}>Cancel</Button>
<Button disabled={!groupCreateForm.name.trim() || createGroupMut.isPending}
onClick={() => createGroupMut.mutate({ name: groupCreateForm.name.trim(), rubric_ids: [...groupCreateForm.rubric_ids] })}>
{createGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Creating...</> : "Create Group"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Group Dialog */}
<Dialog open={!!groupEditTarget} onOpenChange={(open) => { if (!open) setGroupEditTarget(null); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Rubric Group</DialogTitle>
<DialogDescription>Update the group name and selected rubrics.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label>Group Name <span className="text-destructive">*</span></Label>
<Input value={groupEditForm.name} onChange={(e) => setGroupEditForm((p) => ({ ...p, name: e.target.value }))} />
</div>
<div className="space-y-1">
<Label>Select Rubrics</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{rubrics.map((r) => (
<label key={r.id} className="flex items-center gap-2 text-sm cursor-pointer hover:bg-muted/50 rounded px-1 py-0.5">
<Checkbox checked={groupEditForm.rubric_ids.has(r.id)}
onCheckedChange={(checked) => {
setGroupEditForm((p) => {
const next = new Set(p.rubric_ids);
checked ? next.add(r.id) : next.delete(r.id);
return { ...p, rubric_ids: next };
});
}} className="h-3.5 w-3.5" />
{r.name}
<Badge variant="outline" className="text-[10px] ml-auto">{r.skill}</Badge>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setGroupEditTarget(null)}>Cancel</Button>
<Button disabled={!groupEditForm.name.trim() || updateGroupMut.isPending}
onClick={() => {
if (!groupEditTarget) return;
updateGroupMut.mutate({ id: groupEditTarget.id, data: { name: groupEditForm.name.trim(), rubric_ids: [...groupEditForm.rubric_ids] } });
}}>
{updateGroupMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
</Tabs>
<Dialog open={!!editTarget} onOpenChange={(open) => { if (!open) setEditTarget(null); }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Edit Rubric</DialogTitle>
<DialogDescription>Update the rubric details below. Use AI to regenerate criteria.</DialogDescription>
</DialogHeader>
{renderForm(editForm, setEditForm)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)}>Cancel</Button>
<Button
disabled={!editForm.name.trim() || editForm.criteria.filter((c) => c.name.trim()).length === 0 || updateMut.isPending}
onClick={() => {
if (!editTarget) return;
updateMut.mutate({ id: editTarget.id, data: formToPayload(editForm) });
}}
>
{updateMut.isPending ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Saving...</> : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,33 +1,142 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Copy } from "lucide-react";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Plus, Trash2, Copy, Loader2 } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
const codes = [
{ code: "ENCOACH-2025-A1B2", type: "Single", used: false, created: "2025-01-10" },
{ code: "ENCOACH-2025-C3D4", type: "Single", used: true, created: "2025-01-12" },
{ code: "BATCH-2025-E5F6", type: "Batch", used: false, created: "2025-02-01" },
{ code: "BATCH-2025-G7H8", type: "Batch", used: false, created: "2025-02-01" },
];
const packages = [
{ id: 1, name: "IELTS Starter", price: 99, duration: "1 month", discount: 0 },
{ id: 2, name: "IELTS Pro", price: 249, duration: "3 months", discount: 15 },
{ id: 3, name: "Corporate Bundle", price: 1999, duration: "12 months", discount: 25 },
];
import { useToast } from "@/hooks/use-toast";
import {
platformSettingsService,
type GradingConfig,
type PlatformPackage,
type RegistrationCode,
} from "@/services/platformSettings.service";
export default function SettingsPage() {
const { toast } = useToast();
const qc = useQueryClient();
// ── Codes ──────────────────────────────────────────────────────────
const codesQ = useQuery({
queryKey: ["codes"],
queryFn: () => platformSettingsService.listCodes(),
});
const codes: RegistrationCode[] = codesQ.data?.items ?? [];
const genMut = useMutation({
mutationFn: platformSettingsService.generateCodes,
onSuccess: (r) => {
qc.invalidateQueries({ queryKey: ["codes"] });
toast({ title: `Generated ${r.count} code(s)` });
},
onError: () =>
toast({ title: "Failed to generate codes", variant: "destructive" }),
});
const delCodeMut = useMutation({
mutationFn: platformSettingsService.deleteCode,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["codes"] });
toast({ title: "Code deleted" });
},
});
// ── Packages ───────────────────────────────────────────────────────
const pkgQ = useQuery({
queryKey: ["packages"],
queryFn: () => platformSettingsService.listPackages(),
});
const packages: PlatformPackage[] = pkgQ.data?.items ?? [];
const [pkgOpen, setPkgOpen] = useState(false);
const [pkgForm, setPkgForm] = useState<Omit<PlatformPackage, "id">>({
name: "",
price: 0,
duration: "1 month",
discount: 0,
});
const createPkgMut = useMutation({
mutationFn: platformSettingsService.createPackage,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["packages"] });
setPkgOpen(false);
setPkgForm({ name: "", price: 0, duration: "1 month", discount: 0 });
toast({ title: "Package created" });
},
});
const delPkgMut = useMutation({
mutationFn: platformSettingsService.deletePackage,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["packages"] });
toast({ title: "Package deleted" });
},
});
// ── Grading config ─────────────────────────────────────────────────
const gradingQ = useQuery({
queryKey: ["grading-config"],
queryFn: () => platformSettingsService.getGrading(),
});
const [grading, setGrading] = useState<GradingConfig>({
min_score: 0,
max_score: 9,
increment: 0.5,
});
useEffect(() => {
if (gradingQ.data) {
setGrading({
min_score: gradingQ.data.min_score,
max_score: gradingQ.data.max_score,
increment: gradingQ.data.increment,
});
}
}, [gradingQ.data]);
const saveGradingMut = useMutation({
mutationFn: platformSettingsService.setGrading,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["grading-config"] });
toast({ title: "Grading configuration saved" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to save";
toast({ title: msg, variant: "destructive" });
},
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">Manage codes, packages, discounts, and grading system.</p>
<p className="text-muted-foreground">
Manage registration codes, packages, and the grading scale.
</p>
</div>
<Tabs defaultValue="codes">
@@ -37,26 +146,107 @@ export default function SettingsPage() {
<TabsTrigger value="grading">Grading System</TabsTrigger>
</TabsList>
{/* CODES */}
<TabsContent value="codes" className="mt-4 space-y-4">
<AiTipBanner tip="2 batch codes have been unused for over 30 days. Consider sending reminder emails to the assigned entities or recycling unused codes." variant="insight" />
<AiTipBanner context="settings-codes" variant="insight" />
<div className="flex gap-2">
<Button size="sm"><Plus className="h-4 w-4 mr-1" /> Generate Single</Button>
<Button size="sm" variant="outline"><Copy className="h-4 w-4 mr-1" /> Generate Batch</Button>
<Button
size="sm"
disabled={genMut.isPending}
onClick={() =>
genMut.mutate({
count: 1,
code_type: "individual",
user_type: "student",
max_uses: 1,
})
}
>
{genMut.isPending ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-1" />
)}{" "}
Generate Single
</Button>
<Button
size="sm"
variant="outline"
disabled={genMut.isPending}
onClick={() =>
genMut.mutate({
count: 10,
code_type: "corporate",
user_type: "corporate",
max_uses: 50,
})
}
>
<Copy className="h-4 w-4 mr-1" /> Generate Batch (10)
</Button>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow><TableHead>Code</TableHead><TableHead>Type</TableHead><TableHead>Used</TableHead><TableHead>Created</TableHead><TableHead className="w-10"></TableHead></TableRow>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead>User</TableHead>
<TableHead>Uses</TableHead>
<TableHead>State</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{codesQ.isLoading && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</TableCell>
</TableRow>
)}
{!codesQ.isLoading && codes.length === 0 && (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground py-8"
>
No codes yet. Click Generate Single to create one.
</TableCell>
</TableRow>
)}
{codes.map((c) => (
<TableRow key={c.code}>
<TableRow key={c.id}>
<TableCell className="font-mono text-xs">{c.code}</TableCell>
<TableCell><Badge variant="outline">{c.type}</Badge></TableCell>
<TableCell><Badge variant={c.used ? "secondary" : "default"}>{c.used ? "Used" : "Available"}</Badge></TableCell>
<TableCell>{c.created}</TableCell>
<TableCell><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></TableCell>
<TableCell>
<Badge variant="outline">{c.code_type}</Badge>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{c.user_type}
</TableCell>
<TableCell className="text-sm">
{c.uses}/{c.max_uses}
</TableCell>
<TableCell>
<Badge variant={c.used ? "secondary" : "default"}>
{c.used ? "Used" : "Available"}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{c.created ? new Date(c.created).toLocaleDateString() : "—"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => delCodeMut.mutate(c.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -65,35 +255,186 @@ export default function SettingsPage() {
</Card>
</TabsContent>
{/* PACKAGES */}
<TabsContent value="packages" className="mt-4 space-y-4">
<AiTipBanner tip="Based on conversion data, the IELTS Pro package has the highest ROI. Consider increasing the Corporate Bundle discount to 30% to boost enterprise sign-ups." variant="recommendation" />
<AiTipBanner context="settings-packages" variant="recommendation" />
<div className="flex justify-end">
<Button size="sm" onClick={() => setPkgOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Package
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{pkgQ.isLoading && <Loader2 className="h-5 w-5 animate-spin" />}
{packages.map((p) => (
<Card key={p.id} className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardHeader className="pb-3 flex-row items-start justify-between">
<CardTitle className="text-base">{p.name}</CardTitle>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-destructive"
onClick={() => delPkgMut.mutate(p.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-2xl font-bold">${p.price}</p>
<p className="text-sm text-muted-foreground">{p.duration}</p>
{p.discount > 0 && <Badge variant="default">{p.discount}% OFF</Badge>}
{p.discount > 0 && (
<Badge variant="default">{p.discount}% OFF</Badge>
)}
</CardContent>
</Card>
))}
</div>
<Dialog open={pkgOpen} onOpenChange={setPkgOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>New Package</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={pkgForm.name}
onChange={(e) =>
setPkgForm((f) => ({ ...f, name: e.target.value }))
}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Price (USD)</Label>
<Input
type="number"
value={pkgForm.price}
onChange={(e) =>
setPkgForm((f) => ({
...f,
price: Number(e.target.value),
}))
}
/>
</div>
<div className="space-y-2">
<Label>Discount %</Label>
<Input
type="number"
value={pkgForm.discount}
onChange={(e) =>
setPkgForm((f) => ({
...f,
discount: Number(e.target.value),
}))
}
/>
</div>
</div>
<div className="space-y-2">
<Label>Duration</Label>
<Select
value={pkgForm.duration}
onValueChange={(v) =>
setPkgForm((f) => ({ ...f, duration: v }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1 month">1 month</SelectItem>
<SelectItem value="3 months">3 months</SelectItem>
<SelectItem value="6 months">6 months</SelectItem>
<SelectItem value="12 months">12 months</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setPkgOpen(false)}>
Cancel
</Button>
<Button
disabled={!pkgForm.name || createPkgMut.isPending}
onClick={() => createPkgMut.mutate(pkgForm)}
>
{createPkgMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Creating
</>
) : (
"Create"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TabsContent>
{/* GRADING */}
<TabsContent value="grading" className="mt-4 space-y-4">
<AiTipBanner tip="Current 0.5 increment scoring aligns with official IELTS band scoring. AI recommends keeping this configuration for standardised assessment." variant="tip" />
<AiTipBanner context="settings-grading" variant="tip" />
<Card className="border-0 shadow-sm max-w-lg">
<CardHeader><CardTitle className="text-base">Scoring Scale</CardTitle></CardHeader>
<CardHeader>
<CardTitle className="text-base">Scoring Scale</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2"><Label>Min Score</Label><Input type="number" defaultValue="0" /></div>
<div className="space-y-2"><Label>Max Score</Label><Input type="number" defaultValue="9" /></div>
<div className="space-y-2">
<Label>Min Score</Label>
<Input
type="number"
value={grading.min_score}
onChange={(e) =>
setGrading((g) => ({
...g,
min_score: Number(e.target.value),
}))
}
/>
</div>
<div className="space-y-2">
<Label>Max Score</Label>
<Input
type="number"
value={grading.max_score}
onChange={(e) =>
setGrading((g) => ({
...g,
max_score: Number(e.target.value),
}))
}
/>
</div>
</div>
<div className="space-y-2"><Label>Score Increment</Label><Input type="number" defaultValue="0.5" step="0.5" /></div>
<Button>Save Grading Configuration</Button>
<div className="space-y-2">
<Label>Score Increment</Label>
<Input
type="number"
step="0.5"
value={grading.increment}
onChange={(e) =>
setGrading((g) => ({
...g,
increment: Number(e.target.value),
}))
}
/>
</div>
<Button
disabled={saveGradingMut.isPending}
onClick={() => saveGradingMut.mutate(grading)}
>
{saveGradingMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Saving
</>
) : (
"Save Grading Configuration"
)}
</Button>
</CardContent>
</Card>
</TabsContent>

View File

@@ -1,133 +1,266 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useQuery } from "@tanstack/react-query";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line, PieChart, Pie, Cell } from "recharts";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
PieChart,
Pie,
Cell,
} from "recharts";
import { Loader2 } from "lucide-react";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
const tabNarratives: Record<string, string> = {
overview: "Writing scores (61%) are significantly lower than other modules. Consider allocating more teaching resources to writing workshops. Reading leads at 72%, suggesting current materials are effective.",
trends: "Scores have shown a consistent upward trend of +14 points over 6 months. The plateau in April correlates with mid-term exam stress. June's 72% is the highest recorded average this year.",
distribution: "B1 is the largest cohort at 30%, indicating most students are at intermediate level. Only 3% reach C2 — consider creating more advanced pathways to support progression from C1.",
comparison: "Attendance dropped 8% in the second week of March, correlating with the mid-term assignment deadline. Consider spacing deadlines more evenly across the term.",
};
import { reportsService } from "@/services/reports.service";
const thresholds = ["0%", "50%", "70%", "90%"];
const barData = [
{ module: "Reading", score: 72 },
{ module: "Listening", score: 68 },
{ module: "Writing", score: 61 },
{ module: "Speaking", score: 65 },
];
const trendData = [
{ month: "Jan", avg: 58 }, { month: "Feb", avg: 62 }, { month: "Mar", avg: 65 },
{ month: "Apr", avg: 64 }, { month: "May", avg: 69 }, { month: "Jun", avg: 72 },
];
const distData = [
{ name: "A1", value: 15, color: "hsl(0, 72%, 51%)" },
{ name: "A2", value: 22, color: "hsl(38, 92%, 50%)" },
{ name: "B1", value: 30, color: "hsl(199, 89%, 48%)" },
{ name: "B2", value: 20, color: "hsl(243, 75%, 59%)" },
{ name: "C1", value: 10, color: "hsl(142, 71%, 45%)" },
{ name: "C2", value: 3, color: "hsl(280, 65%, 50%)" },
];
export default function StatsCorporatePage() {
const [threshold, setThreshold] = useState("0%");
const [entityId, setEntityId] = useState("all");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["stats-corporate", threshold, entityId],
queryFn: () =>
reportsService.statsCorporate({
entity_id: entityId === "all" ? undefined : Number(entityId),
threshold: Number(threshold.replace("%", "")),
months: 6,
}),
});
const barData = data?.by_module ?? [];
const trendData = data?.trend ?? [];
const distData = data?.distribution ?? [];
const comparison = data?.comparison ?? [];
const attemptsConsidered = data?.meta.attempts_considered ?? 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Corporate Statistics</h1>
<p className="text-muted-foreground">Entity-level performance analytics and reports.</p>
<h1 className="text-2xl font-bold tracking-tight">
Corporate Statistics
</h1>
<p className="text-muted-foreground">
Entity-level performance analytics and reports.
{data && (
<span className="ml-2 text-xs">
({attemptsConsidered} scored attempts)
</span>
)}
</p>
</div>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex gap-1">
{thresholds.map(t => (
<Button key={t} variant={threshold === t ? "default" : "outline"} size="sm" onClick={() => setThreshold(t)}>{t}</Button>
{thresholds.map((t) => (
<Button
key={t}
variant={threshold === t ? "default" : "outline"}
size="sm"
onClick={() => setThreshold(t)}
>
{t}
</Button>
))}
</div>
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All</SelectItem><SelectItem value="acme">Acme Corp</SelectItem></SelectContent>
</Select>
<Select><SelectTrigger className="w-[180px]"><SelectValue placeholder="All Assignments" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Assignments</SelectItem></SelectContent>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Aggregating
attempts...
</div>
) : attemptsConsidered === 0 ? (
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">
No scored attempts match this threshold / entity combination yet.
</CardContent>
</Card>
) : (
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
<TabsTrigger value="distribution">Distribution</TabsTrigger>
<TabsTrigger value="comparison">Comparison</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative narrative={tabNarratives.overview} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Average Score by Module</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar dataKey="score" fill="hsl(243, 75%, 59%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="overview" className="mt-4">
<AiReportNarrative
report_type="corporate-overview"
data={{ modules: barData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Average Score by Module
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={barData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="module" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="score"
fill="hsl(243, 75%, 59%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative narrative={tabNarratives.trends} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Score Trend Over Time</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(220, 16%, 90%)" />
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line type="monotone" dataKey="avg" stroke="hsl(243, 75%, 59%)" strokeWidth={2} dot={{ r: 4 }} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="trends" className="mt-4">
<AiReportNarrative
report_type="corporate-trends"
data={{ trends: trendData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Score Trend Over Time
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={trendData}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="month" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Line
type="monotone"
dataKey="avg"
stroke="hsl(243, 75%, 59%)"
strokeWidth={2}
dot={{ r: 4 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative narrative={tabNarratives.distribution} />
<Card className="border-0 shadow-sm">
<CardHeader><CardTitle className="text-base">Level Distribution</CardTitle></CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie data={distData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={100} label>
{distData.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="distribution" className="mt-4">
<AiReportNarrative
report_type="corporate-distribution"
data={{ distribution: distData }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">Level Distribution</CardTitle>
</CardHeader>
<CardContent className="flex justify-center">
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={100}
label
>
{distData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative narrative={tabNarratives.comparison} />
<Card className="border-0 shadow-sm">
<CardContent className="p-8 text-center text-muted-foreground">Entity comparison charts will appear here based on selected filters.</CardContent>
</Card>
</TabsContent>
</Tabs>
<TabsContent value="comparison" className="mt-4">
<AiReportNarrative
report_type="corporate-comparison"
data={{ threshold, entities: comparison }}
/>
<Card className="border-0 shadow-sm">
<CardHeader>
<CardTitle className="text-base">
Entity Comparison (average band × 10)
</CardTitle>
</CardHeader>
<CardContent>
{comparison.length === 0 ? (
<p className="text-center text-muted-foreground p-8">
No entities have attempts above this threshold.
</p>
) : (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={comparison}>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(220, 16%, 90%)"
/>
<XAxis dataKey="entity_name" />
<YAxis domain={[0, 100]} />
<Tooltip />
<Bar
dataKey="avg"
fill="hsl(142, 71%, 45%)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
</div>
);
}

View File

@@ -1,77 +1,270 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useState } from "react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Loader2, Download } from "lucide-react";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
import { reportsService } from "@/services/reports.service";
const students = [
{ name: "Sarah Johnson", entity: "Acme Corp", reading: 7.5, listening: 8.0, writing: 7.0, speaking: 7.5, overall: 7.5, level: "B2" },
{ name: "Ahmed Hassan", entity: "Global Ltd", reading: 5.5, listening: 6.0, writing: 5.0, speaking: 5.5, overall: 5.5, level: "A2" },
{ name: "Maria Garcia", entity: "Acme Corp", reading: 8.5, listening: 8.0, writing: 8.0, speaking: 8.5, overall: 8.25, level: "C1" },
{ name: "Li Wei", entity: "Tech Co", reading: 6.5, listening: 6.0, writing: 6.0, speaking: 6.5, overall: 6.25, level: "B1" },
{ name: "Emma Brown", entity: "Acme Corp", reading: 4.5, listening: 5.0, writing: 4.0, speaking: 4.5, overall: 4.5, level: "A1" },
{ name: "John Park", entity: "Global Ltd", reading: 7.0, listening: 7.5, writing: 6.5, speaking: 7.0, overall: 7.0, level: "B2" },
];
const LEVELS = ["all", "A1", "A2", "B1", "B2", "C1", "C2"] as const;
function ScoreBadge({ score }: { score: number }) {
const color = score >= 7.5 ? "bg-success/10 text-success" : score >= 6.0 ? "bg-warning/10 text-warning" : "bg-destructive/10 text-destructive";
return <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}>{score.toFixed(1)}</span>;
function ScoreBadge({ score }: { score: number | null }) {
if (score === null || score === undefined) {
return <span className="text-muted-foreground"></span>;
}
const color =
score >= 7.5
? "bg-success/10 text-success"
: score >= 6.0
? "bg-warning/10 text-warning"
: "bg-destructive/10 text-destructive";
return (
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-semibold ${color}`}
>
{score.toFixed(1)}
</span>
);
}
export default function StudentPerformancePage() {
const [showUtilisation, setShowUtilisation] = useState(false);
const [entityId, setEntityId] = useState<string>("all");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("all");
const [search, setSearch] = useState("");
const { data: filters } = useQuery({
queryKey: ["reports-filters"],
queryFn: () => reportsService.filters(),
});
const { data, isLoading } = useQuery({
queryKey: ["student-performance", entityId, level, search],
queryFn: () =>
reportsService.studentPerformance({
entity_id: entityId === "all" ? undefined : Number(entityId),
level: level === "all" ? undefined : level,
search: search || undefined,
}),
});
const rows = data?.items ?? [];
const summary = useMemo(() => {
if (!rows.length) return null;
const overall = rows
.map((r) => r.overall)
.filter((v): v is number => v !== null);
const avg = overall.length
? overall.reduce((a, b) => a + b, 0) / overall.length
: null;
return {
total: rows.length,
avg: avg !== null ? avg.toFixed(1) : "—",
topLevel: rows[0]?.level ?? "—",
};
}, [rows]);
const exportCsv = () => {
const header = [
"Student",
"Entity",
"Level",
"Reading",
"Listening",
"Writing",
"Speaking",
"Overall",
"Attempts",
];
const lines = rows.map((r) =>
[
r.student_name,
r.entity_name,
r.level ?? "",
r.reading ?? "",
r.listening ?? "",
r.writing ?? "",
r.speaking ?? "",
r.overall ?? "",
r.attempts_count,
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(","),
);
const blob = new Blob([[header.join(","), ...lines].join("\n")], {
type: "text/csv",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `student-performance-${new Date().toISOString().slice(0, 10)}.csv`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Student Performance</h1>
<p className="text-muted-foreground">Track student scores across all IELTS modules.</p>
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">
Student Performance
</h1>
<p className="text-muted-foreground">
Track student scores across all IELTS modules.
</p>
</div>
<Button variant="outline" size="sm" onClick={exportCsv} disabled={!rows.length}>
<Download className="h-4 w-4 mr-1" /> Export CSV
</Button>
</div>
{summary && (
<div className="grid gap-3 md:grid-cols-3">
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Students tracked</p>
<p className="text-2xl font-bold">{summary.total}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Average overall band</p>
<p className="text-2xl font-bold">{summary.avg}</p>
</CardContent>
</Card>
<Card className="border-0 shadow-sm">
<CardContent className="p-4">
<p className="text-xs text-muted-foreground">Top performer level</p>
<p className="text-2xl font-bold">{summary.topLevel}</p>
</CardContent>
</Card>
</div>
)}
<AiStudyCoach />
<div className="flex flex-wrap gap-3 items-center">
<Select><SelectTrigger className="w-[160px]"><SelectValue placeholder="All Entities" /></SelectTrigger>
<SelectContent><SelectItem value="all">All Entities</SelectItem><SelectItem value="acme">Acme Corp</SelectItem><SelectItem value="global">Global Ltd</SelectItem></SelectContent>
<Input
placeholder="Search student..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-[220px]"
/>
<Select value={entityId} onValueChange={setEntityId}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Entities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Entities</SelectItem>
{filters?.entities.map((e) => (
<SelectItem key={e.id} value={String(e.id)}>
{e.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={level}
onValueChange={(v) => setLevel(v as (typeof LEVELS)[number])}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Level" />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All Levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 ml-auto">
<Switch id="util" checked={showUtilisation} onCheckedChange={setShowUtilisation} />
<Label htmlFor="util" className="text-sm">Show Utilisation</Label>
</div>
</div>
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead><TableHead>Entity</TableHead><TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead><TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead><TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{students.map((s) => (
<TableRow key={s.name}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.entity}</TableCell>
<TableCell><Badge variant="outline">{s.level}</Badge></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.reading} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.listening} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.writing} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.speaking} /></TableCell>
<TableCell className="text-center"><ScoreBadge score={s.overall} /></TableCell>
<TableCell><AiGradeExplainer studentName={s.name} /></TableCell>
{isLoading ? (
<div className="flex items-center justify-center p-12 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading
performance data...
</div>
) : rows.length === 0 ? (
<div className="p-8 text-center text-muted-foreground">
No completed attempts match these filters yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Student</TableHead>
<TableHead>Entity</TableHead>
<TableHead>Level</TableHead>
<TableHead className="text-center">Reading</TableHead>
<TableHead className="text-center">Listening</TableHead>
<TableHead className="text-center">Writing</TableHead>
<TableHead className="text-center">Speaking</TableHead>
<TableHead className="text-center">Overall</TableHead>
<TableHead className="text-center">Attempts</TableHead>
<TableHead className="w-10">AI</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.student_id}>
<TableCell className="font-medium">
{r.student_name}
</TableCell>
<TableCell>{r.entity_name || "—"}</TableCell>
<TableCell>
{r.level ? (
<Badge variant="outline">{r.level}</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.reading} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.listening} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.writing} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.speaking} />
</TableCell>
<TableCell className="text-center">
<ScoreBadge score={r.overall} />
</TableCell>
<TableCell className="text-center text-sm text-muted-foreground">
{r.attempts_count}
</TableCell>
<TableCell>
<AiGradeExplainer studentName={r.student_name} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>

View File

@@ -33,7 +33,7 @@ export default function TicketsPage() {
}),
});
const tickets: Ticket[] = data?.data ?? [];
const tickets: Ticket[] = data?.items ?? data?.data ?? [];
const createMut = useMutation({
mutationFn: ticketsService.create,

View File

@@ -3,151 +3,153 @@ import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Search, Plus, Download, MoreHorizontal } from "lucide-react";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useStudents, useTeachers, useCreateStudent, useCreateTeacher } from "@/hooks/queries";
import { lmsService } from "@/services/lms.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Search, Plus, Download, Shield, Pencil, UserCog } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { ApiError } from "@/lib/api-client";
function messageFromCreateError(err: unknown): string {
if (err instanceof ApiError && err.data && typeof err.data === "object" && err.data !== null && "error" in err.data) {
const e = (err.data as { error: unknown }).error;
if (e != null && String(e).trim()) return String(e);
}
if (err instanceof Error) return err.message;
return "Something went wrong.";
interface PlatformUser {
id: number;
name: string;
email: string;
login: string;
user_type: string;
active: boolean;
phone: string;
role_ids: number[];
roles: { id: number; name: string }[];
effective_permission_count: number;
}
function toastForCreateUserError(err: unknown): { title: string; description: string; variant?: "default" | "destructive" } {
const msg = messageFromCreateError(err).toLowerCase();
const isDuplicate =
msg.includes("already exists") ||
msg.includes("duplicate") ||
msg.includes("unique") ||
msg.includes("already registered");
if (isDuplicate) {
return {
title: "Email already in use",
description:
messageFromCreateError(err) +
" Try another address, or find the existing user in the Students or Teachers tab.",
variant: "destructive",
};
}
return { title: "Could not create user", description: messageFromCreateError(err), variant: "destructive" };
interface RoleInfo {
id: number;
name: string;
description: string;
assigned: boolean;
permission_count: number;
}
const TYPE_COLORS: Record<string, string> = {
admin: "bg-red-100 text-red-800 border-red-200",
teacher: "bg-blue-100 text-blue-800 border-blue-200",
student: "bg-green-100 text-green-800 border-green-200",
user: "bg-gray-100 text-gray-800 border-gray-200",
};
export default function UsersPage() {
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
const [editUser, setEditUser] = useState<PlatformUser | null>(null);
const [rolesUser, setRolesUser] = useState<PlatformUser | null>(null);
const [form, setForm] = useState({ name: "", email: "", password: "", phone: "" });
const { toast } = useToast();
const qc = useQueryClient();
const studentsQ = useStudents({ search: search || undefined, size: 200 });
const teachersQ = useTeachers({ search: search || undefined, size: 200 });
const createStudentMut = useCreateStudent();
const createTeacherMut = useCreateTeacher();
const students = studentsQ.data?.items ?? [];
const teachers = teachersQ.data?.items ?? [];
const deleteMut = useMutation({
mutationFn: async ({ type, id }: { type: "student" | "teacher"; id: number }) => {
if (type === "student") return lmsService.deleteStudent?.(id) ?? lmsService.updateStudent(id, { status: "inactive" });
return lmsService.updateTeacher?.(id, {}) ?? Promise.resolve();
const usersQ = useQuery({
queryKey: ["platform-users", search],
queryFn: async () => {
const params: Record<string, string | number> = { size: 200 };
if (search) params.search = search;
const res = await api.get<{ items?: PlatformUser[]; data?: PlatformUser[]; total: number }>("/users/list", params);
return (res.items ?? res.data ?? []) as PlatformUser[];
},
});
const rolesQ = useQuery({
queryKey: ["user-roles-detail", rolesUser?.id],
queryFn: async () => {
if (!rolesUser) return null;
return api.get<{ roles: RoleInfo[]; effective_permissions: unknown[] }>(`/users/${rolesUser.id}/roles`);
},
enabled: !!rolesUser,
});
const createMut = useMutation({
mutationFn: (data: { name: string; email: string; password: string; phone?: string }) =>
api.post<{ data: PlatformUser }>("/users/create", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms"] });
toast({ title: "Done" });
qc.invalidateQueries({ queryKey: ["platform-users"] });
setCreateOpen(false);
setForm({ name: "", email: "", password: "", phone: "" });
toast({ title: "User created" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function handleCreate() {
const email = form.email.trim();
const first_name = form.first_name.trim();
const last_name = form.last_name.trim();
const emailKey = email.toLowerCase();
const dupStudent = students.some((s) => (s.email || "").trim().toLowerCase() === emailKey);
const dupTeacher = teachers.some((t) => (t.email || "").trim().toLowerCase() === emailKey);
if (dupStudent || dupTeacher) {
toast({
title: "Email already in use",
description:
"That address matches someone already listed on this page (search may hide them). Use another email or clear search to find the user.",
variant: "destructive",
});
return;
}
const cb = {
onSuccess: () => {
setCreateOpen(false);
resetForm();
toast({ title: "User created successfully" });
},
onError: (err: unknown) => toast(toastForCreateUserError(err)),
};
if (form.role === "teacher") {
createTeacherMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: (form.gender as "male" | "female") || undefined,
},
cb,
);
} else {
createStudentMut.mutate(
{
first_name,
last_name,
email,
phone: form.phone?.trim() || undefined,
gender: form.gender || undefined,
create_portal_user: true,
},
cb,
);
}
}
const updateMut = useMutation({
mutationFn: (data: { id: number; name?: string; email?: string; phone?: string }) =>
api.patch<{ data: PlatformUser }>("/users/update", data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
setEditUser(null);
toast({ title: "User updated" });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
function resetForm() {
setForm({ first_name: "", last_name: "", email: "", role: "student", phone: "", gender: "" });
}
const toggleRoleMut = useMutation({
mutationFn: ({ userId, roleId }: { userId: number; roleId: number }) =>
api.post<{ assigned: boolean }>(`/users/${userId}/roles/toggle`, { role_id: roleId }),
onSuccess: (res) => {
qc.invalidateQueries({ queryKey: ["platform-users"] });
qc.invalidateQueries({ queryKey: ["user-roles-detail"] });
const verb = (res as { assigned: boolean }).assigned ? "assigned" : "removed";
toast({ title: `Role ${verb}` });
},
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const users = usersQ.data ?? [];
const filtered = users.filter((u) => {
if (typeFilter !== "all" && u.user_type !== typeFilter) return false;
return true;
});
const typeCounts = {
all: users.length,
admin: users.filter((u) => u.user_type === "admin").length,
teacher: users.filter((u) => u.user_type === "teacher").length,
student: users.filter((u) => u.user_type === "student").length,
};
function exportCsv() {
const rows = [["Name", "Email", "Role", "Status"]];
students.forEach(s => rows.push([s.name, s.email, "Student", s.status || "active"]));
teachers.forEach(t => rows.push([t.name, t.email, "Teacher", "active"]));
const csv = rows.map(r => r.join(",")).join("\n");
const rows = [["Name", "Email", "Type", "Roles", "Permissions"]];
users.forEach((u) =>
rows.push([
u.name,
u.email,
u.user_type,
u.roles.map((r) => r.name).join("; "),
String(u.effective_permission_count),
]),
);
const csv = rows.map((r) => r.map((c) => `"${c}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "users_export.csv";
a.download = "platform_users.csv";
a.click();
URL.revokeObjectURL(url);
}
const isPending = createStudentMut.isPending || createTeacherMut.isPending;
const loading = studentsQ.isLoading || teachersQ.isLoading;
const rolesData = rolesQ.data as { roles: RoleInfo[]; effective_permissions: unknown[] } | null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<p className="text-muted-foreground">Manage platform users across all roles.</p>
<p className="text-muted-foreground">
Manage all platform accounts admins, teachers, and students.
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={exportCsv}>
@@ -159,177 +161,254 @@ export default function UsersPage() {
</div>
</div>
<div className="flex gap-3 items-center">
<div className="flex gap-3 items-center flex-wrap">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input placeholder="Search users..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
<Input placeholder="Search by name..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<Tabs value={typeFilter} onValueChange={setTypeFilter}>
<TabsList>
<TabsTrigger value="all">All ({typeCounts.all})</TabsTrigger>
<TabsTrigger value="admin">Admins ({typeCounts.admin})</TabsTrigger>
<TabsTrigger value="teacher">Teachers ({typeCounts.teacher})</TabsTrigger>
<TabsTrigger value="student">Students ({typeCounts.student})</TabsTrigger>
</TabsList>
</Tabs>
</div>
{loading ? (
{usersQ.isLoading ? (
<div className="flex items-center justify-center min-h-[300px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
</div>
) : (
<Tabs defaultValue="students">
<TabsList>
<TabsTrigger value="students">Students ({students.length})</TabsTrigger>
<TabsTrigger value="teachers">Teachers ({teachers.length})</TabsTrigger>
</TabsList>
<TabsContent value="students" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Status</TableHead>
<TableHead>Enrollment</TableHead>
<TableHead className="w-10" />
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Type</TableHead>
<TableHead>Roles</TableHead>
<TableHead>Permissions</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[120px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
No users found.
</TableCell>
</TableRow>
)}
{filtered.map((u) => {
const initials = u.name
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return (
<TableRow key={u.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-semibold text-primary">
{initials}
</div>
<div>
<p className="text-sm font-medium">{u.name}</p>
<p className="text-xs text-muted-foreground">{u.email}</p>
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className={`capitalize text-xs ${TYPE_COLORS[u.user_type] || TYPE_COLORS.user}`}>
{u.user_type}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{u.roles.length > 0
? u.roles.map((r) => (
<Badge key={r.id} variant="secondary" className="text-xs">
{r.name}
</Badge>
))
: <span className="text-xs text-muted-foreground">No roles</span>}
</div>
</TableCell>
<TableCell>
<span className="text-sm">{u.effective_permission_count}</span>
</TableCell>
<TableCell>
<Badge variant={u.active ? "default" : "secondary"}>
{u.active ? "Active" : "Inactive"}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8" title="Edit user" onClick={() => setEditUser(u)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8" title="Manage roles" onClick={() => setRolesUser(u)}>
<UserCog className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{students.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No students found.</TableCell></TableRow>
)}
{students.map((s) => (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell>{s.email}</TableCell>
<TableCell>{s.phone || "—"}</TableCell>
<TableCell>{s.batch_name || "—"}</TableCell>
<TableCell>
<Badge variant={s.status === "active" ? "default" : "secondary"} className="capitalize">{s.status || "active"}</Badge>
</TableCell>
<TableCell>{s.enrollment_date || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Deactivate student "${s.name}"?`)) deleteMut.mutate({ type: "student", id: s.id }); }}>
Deactivate
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="teachers" className="mt-4">
<Card className="border-0 shadow-sm">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead>Department</TableHead>
<TableHead>Specialization</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{teachers.length === 0 && (
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">No teachers found.</TableCell></TableRow>
)}
{teachers.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell>{t.email}</TableCell>
<TableCell>{t.phone || "—"}</TableCell>
<TableCell>{t.department_name || "—"}</TableCell>
<TableCell>{t.specialization || "—"}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem className="text-destructive" onClick={() => { if (window.confirm(`Remove teacher "${t.name}"?`)) deleteMut.mutate({ type: "teacher", id: t.id }); }}>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<Dialog open={createOpen} onOpenChange={(v) => { setCreateOpen(v); if (!v) resetForm(); }}>
{/* Create User Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Create New User</DialogTitle></DialogHeader>
<DialogHeader>
<DialogTitle>Create Platform User</DialogTitle>
<DialogDescription>
Creates an internal Odoo user (res.users) with login access.
For students/teachers, use the LMS pages instead.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>First Name</Label>
<Input value={form.first_name} onChange={(e) => setForm(f => ({ ...f, first_name: e.target.value }))} placeholder="John" />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input value={form.last_name} onChange={(e) => setForm(f => ({ ...f, last_name: e.target.value }))} placeholder="Doe" />
</div>
<div className="space-y-2">
<Label>Full Name</Label>
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. John Admin" />
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input type="email" value={form.email} onChange={(e) => setForm(f => ({ ...f, email: e.target.value }))} placeholder="john@email.com" />
<p className="text-xs text-muted-foreground">
Must be unique in Odoo for students and teachers. If create fails, the address may already exist outside the current list.
</p>
<Label>Email (login)</Label>
<Input type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} placeholder="admin@encoach.com" />
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input value={form.phone} onChange={(e) => setForm(f => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
<Label>Password</Label>
<Input type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} placeholder="Minimum 6 characters" />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>Role</Label>
<Select value={form.role} onValueChange={(v) => setForm(f => ({ ...f, role: v }))}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="student">Student</SelectItem>
<SelectItem value="teacher">Teacher</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={form.gender} onValueChange={(v) => setForm(f => ({ ...f, gender: v }))}>
<SelectTrigger><SelectValue placeholder="Select" /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Phone (optional)</Label>
<Input value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} placeholder="+1234567890" />
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setCreateOpen(false); resetForm(); }}>Cancel</Button>
<Button variant="outline" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button
disabled={isPending || !form.first_name.trim() || !form.last_name.trim() || !form.email.trim()}
onClick={handleCreate}
disabled={createMut.isPending || !form.name.trim() || !form.email.trim()}
onClick={() => createMut.mutate({ name: form.name.trim(), email: form.email.trim(), password: form.password || "admin123", phone: form.phone.trim() || undefined })}
>
{isPending ? "Creating..." : "Create"}
{createMut.isPending ? "Creating..." : "Create User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={!!editUser} onOpenChange={(v) => { if (!v) setEditUser(null); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>Update profile for {editUser?.name}</DialogDescription>
</DialogHeader>
{editUser && (
<div className="space-y-4">
<div className="space-y-2">
<Label>Name</Label>
<Input
value={editUser.name}
onChange={(e) => setEditUser((u) => u ? { ...u, name: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input
type="email"
value={editUser.email}
onChange={(e) => setEditUser((u) => u ? { ...u, email: e.target.value } : null)}
/>
</div>
<div className="space-y-2">
<Label>Phone</Label>
<Input
value={editUser.phone || ""}
onChange={(e) => setEditUser((u) => u ? { ...u, phone: e.target.value } : null)}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
<Button
disabled={updateMut.isPending || !editUser?.name.trim()}
onClick={() => {
if (!editUser) return;
updateMut.mutate({
id: editUser.id,
name: editUser.name.trim(),
email: editUser.email.trim(),
phone: editUser.phone?.trim() || undefined,
});
}}
>
{updateMut.isPending ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Manage Roles Dialog */}
<Dialog open={!!rolesUser} onOpenChange={(v) => { if (!v) setRolesUser(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5" />
Manage Roles {rolesUser?.name}
</div>
</DialogTitle>
<DialogDescription>Toggle roles on/off for this user.</DialogDescription>
</DialogHeader>
{rolesQ.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
) : rolesData?.roles ? (
<div className="space-y-2 max-h-[400px] overflow-y-auto">
{rolesData.roles.map((r) => (
<label
key={r.id}
className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors ${
r.assigned ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
}`}
>
<Checkbox
checked={r.assigned}
onCheckedChange={() => {
if (rolesUser) toggleRoleMut.mutate({ userId: rolesUser.id, roleId: r.id });
}}
/>
<div className="flex-1">
<p className="text-sm font-medium">{r.name}</p>
{r.description && <p className="text-xs text-muted-foreground">{r.description}</p>}
</div>
<Badge variant="outline" className="text-xs shrink-0">
{r.permission_count} perms
</Badge>
</label>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No roles available.</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setRolesUser(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -1,96 +1,389 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { CheckCircle, BookA, Sparkles } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
CheckCircle,
Plus,
Search,
Trash2,
Loader2,
Sparkles,
Circle,
} from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
import { useToast } from "@/hooks/use-toast";
import {
trainingService,
type VocabItem,
type CefrLevel,
} from "@/services/training.service";
const vocabItems = [
{ word: "Ubiquitous", meaning: "Present, appearing, or found everywhere", level: "C1", completed: true },
{ word: "Pragmatic", meaning: "Dealing with things sensibly and realistically", level: "B2", completed: true },
{ word: "Eloquent", meaning: "Fluent or persuasive in speaking or writing", level: "C1", completed: false },
{ word: "Meticulous", meaning: "Showing great attention to detail", level: "B2", completed: false },
{ word: "Ambiguous", meaning: "Open to more than one interpretation", level: "B2", completed: false },
{ word: "Coherent", meaning: "Logical and consistent", level: "B1", completed: false },
{ word: "Versatile", meaning: "Able to adapt to many different functions", level: "B2", completed: true },
{ word: "Concise", meaning: "Giving a lot of information in few words", level: "B1", completed: false },
];
const LEVELS: (CefrLevel | "all")[] = ["all", "A1", "A2", "B1", "B2", "C1", "C2"];
export default function VocabularyPage() {
const [showCompleted, setShowCompleted] = useState(false);
const completed = vocabItems.filter(v => v.completed).length;
const filtered = showCompleted ? vocabItems : vocabItems.filter(v => !v.completed);
const { toast } = useToast();
const qc = useQueryClient();
const [showCompleted, setShowCompleted] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>("all");
const [search, setSearch] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState({
word: "",
meaning: "",
example_sentence: "",
level: "B1" as CefrLevel,
part_of_speech: "noun" as VocabItem["part_of_speech"],
category: "general",
});
const listQ = useQuery({
queryKey: ["training-vocab", levelFilter, search],
queryFn: () =>
trainingService.listVocab({
...(levelFilter !== "all" ? { level: levelFilter } : {}),
...(search ? { search } : {}),
}),
});
const items = listQ.data?.items ?? [];
const summary = listQ.data?.summary;
const filtered = showCompleted ? items : items.filter((v) => !v.completed);
const createMut = useMutation({
mutationFn: trainingService.createVocab,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-vocab"] });
setCreateOpen(false);
setForm({
word: "",
meaning: "",
example_sentence: "",
level: "B1",
part_of_speech: "noun",
category: "general",
});
toast({ title: "Word added" });
},
onError: (err: unknown) => {
const msg =
err && typeof err === "object" && "message" in err
? String((err as { message: unknown }).message)
: "Failed to add word";
toast({ title: msg, variant: "destructive" });
},
});
const delMut = useMutation({
mutationFn: trainingService.deleteVocab,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["training-vocab"] });
toast({ title: "Word deleted" });
},
});
const progressMut = useMutation({
mutationFn: ({ id, completed }: { id: number; completed: boolean }) =>
trainingService.setVocabProgress(id, { completed }),
onSuccess: () => qc.invalidateQueries({ queryKey: ["training-vocab"] }),
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">Build your vocabulary for IELTS success.</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Vocabulary Training</h1>
<p className="text-muted-foreground">
Build and manage the vocabulary library track completion by CEFR level.
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-1" /> Add Word
</Button>
</div>
<AiTipBanner context="vocabulary" variant="recommendation" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-4">
{/* Summary */}
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">Progress</CardTitle>
<span className="text-sm text-muted-foreground">{completed}/{vocabItems.length} completed</span>
<span className="text-sm text-muted-foreground">
{summary
? `${summary.completed}/${summary.total} completed (${summary.completion_rate}%)`
: "—"}
</span>
</div>
</CardHeader>
<CardContent>
<Progress value={(completed / vocabItems.length) * 100} className="h-3" />
<Progress value={summary?.completion_rate ?? 0} className="h-3" />
</CardContent>
</Card>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">Show completed</Label>
{/* Filters */}
<div className="flex flex-wrap gap-3 items-center">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search word or meaning…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Select value={levelFilter} onValueChange={setLevelFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVELS.map((l) => (
<SelectItem key={l} value={l}>
{l === "all" ? "All levels" : l}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Switch id="show" checked={showCompleted} onCheckedChange={setShowCompleted} />
<Label htmlFor="show" className="text-sm">
Show completed
</Label>
</div>
</div>
{/* List */}
<div className="space-y-2">
{listQ.isLoading && (
<div className="py-8 text-center text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
{!listQ.isLoading && filtered.length === 0 && (
<Card className="border-0 shadow-sm">
<CardContent className="text-center text-muted-foreground py-8">
No vocabulary items match.
</CardContent>
</Card>
)}
{filtered.map((v) => (
<Card key={v.word} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
{v.completed && <CheckCircle className="h-4 w-4 text-success shrink-0" />}
<div>
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground">{v.meaning}</p>
</div>
<Card key={v.id} className="border-0 shadow-sm">
<CardContent className="p-4 flex items-center justify-between gap-3">
<button
onClick={() =>
progressMut.mutate({ id: v.id, completed: !v.completed })
}
className="shrink-0 rounded-full p-0.5 hover:bg-muted transition"
aria-label={v.completed ? "Mark incomplete" : "Mark complete"}
>
{v.completed ? (
<CheckCircle className="h-5 w-5 text-green-600" />
) : (
<Circle className="h-5 w-5 text-muted-foreground" />
)}
</button>
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm">{v.word}</p>
<p className="text-xs text-muted-foreground truncate">{v.meaning}</p>
{v.example_sentence && (
<p className="text-xs italic text-muted-foreground mt-0.5 truncate">
{v.example_sentence}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline">{v.level}</Badge>
<Badge variant="secondary" className="text-xs">
{v.part_of_speech}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => delMut.mutate(v.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<Badge variant="outline">{v.level}</Badge>
</CardContent>
</Card>
))}
</div>
</div>
{/* AI panel */}
<div className="space-y-4">
<Card className="border-0 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-base flex items-center gap-2"><Sparkles className="h-4 w-4 text-primary" /> AI Recommendations</CardTitle>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> AI Recommendations
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2">
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Learn "coherent" + "concise" together they share academic writing context</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Review C1-level vocabulary for IELTS Task 2</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Focus on collocations with 'make' and 'do'</li>
<li className="text-sm text-muted-foreground flex items-start gap-2"><span className="text-primary font-bold"></span>Try using "meticulous" in your next writing practice</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Pair <b>coherent</b> + <b>concise</b> they share academic-writing
contexts.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Review C1 words for IELTS Writing Task 2.
</li>
<li className="text-sm text-muted-foreground flex items-start gap-2">
<span className="text-primary font-bold"></span>
Try using <b>meticulous</b> in your next essay.
</li>
</ul>
</CardContent>
</Card>
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2"><Sparkles className="h-3 w-3" /> AI Vocabulary Goal</p>
<p className="text-sm text-muted-foreground">At 2 words/day, you'll complete this set by March 15. AI recommends adding 10 more domain-specific words from your upcoming exam topics.</p>
</CardContent>
</Card>
{summary && (
<Card className="border-0 shadow-sm border-primary/20 bg-primary/5">
<CardContent className="p-4">
<p className="text-xs font-semibold text-primary flex items-center gap-1 mb-2">
<Sparkles className="h-3 w-3" /> AI Vocabulary Goal
</p>
<p className="text-sm text-muted-foreground">
{summary.remaining > 0
? `At 2 words/day, you'll complete the remaining ${summary.remaining} items in about ${Math.ceil(summary.remaining / 2)} days.`
: "You've completed every active word — try adding more to keep challenging yourself."}
</p>
</CardContent>
</Card>
)}
</div>
</div>
{/* Create dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Vocabulary Word</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-2">
<Label>Word</Label>
<Input
value={form.word}
onChange={(e) => setForm((f) => ({ ...f, word: e.target.value }))}
placeholder="e.g. Ubiquitous"
/>
</div>
<div className="space-y-2">
<Label>Meaning</Label>
<Textarea
value={form.meaning}
onChange={(e) => setForm((f) => ({ ...f, meaning: e.target.value }))}
placeholder="Plain-English definition"
/>
</div>
<div className="space-y-2">
<Label>Example sentence (optional)</Label>
<Textarea
value={form.example_sentence}
onChange={(e) =>
setForm((f) => ({ ...f, example_sentence: e.target.value }))
}
placeholder="Use the word in context"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label>CEFR level</Label>
<Select
value={form.level}
onValueChange={(v) =>
setForm((f) => ({ ...f, level: v as CefrLevel }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(LEVELS.filter((l) => l !== "all") as CefrLevel[]).map(
(l) => (
<SelectItem key={l} value={l}>
{l}
</SelectItem>
),
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Part of speech</Label>
<Select
value={form.part_of_speech}
onValueChange={(v) =>
setForm((f) => ({
...f,
part_of_speech: v as VocabItem["part_of_speech"],
}))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="noun">noun</SelectItem>
<SelectItem value="verb">verb</SelectItem>
<SelectItem value="adjective">adjective</SelectItem>
<SelectItem value="adverb">adverb</SelectItem>
<SelectItem value="phrase">phrase</SelectItem>
<SelectItem value="other">other</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Input
value={form.category}
onChange={(e) => setForm((f) => ({ ...f, category: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button
disabled={!form.word.trim() || !form.meaning.trim() || createMut.isPending}
onClick={() => createMut.mutate(form)}
>
{createMut.isPending ? (
<>
<Loader2 className="h-4 w-4 mr-1 animate-spin" /> Adding
</>
) : (
"Add"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,842 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
Activity,
Bot,
CheckCircle2,
ChevronRight,
PencilLine,
PlayCircle,
RotateCcw,
Search,
Settings2,
Wrench,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { aiAgentService } from "@/services/aiAgent.service";
import type {
AIAgent,
AIAgentSummary,
AIAgentTestResponse,
AIAgentUpdateInput,
AIToolSummary,
} from "@/types/aiAgent";
const MODEL_OPTIONS = [
{ value: "gpt-4o", label: "GPT-4o (quality)" },
{ value: "gpt-4o-mini", label: "GPT-4o mini (cheap / fast)" },
{ value: "gpt-4.1", label: "GPT-4.1" },
{ value: "gpt-4.1-mini", label: "GPT-4.1 mini" },
{ value: "gpt-3.5-turbo", label: "GPT-3.5 turbo (legacy)" },
];
const GRAPH_OPTIONS = [
{ value: "simple", labelKey: "agents.graph.simple" },
{ value: "plan_review_revise", labelKey: "agents.graph.planReviewRevise" },
{ value: "rag", labelKey: "agents.graph.rag" },
{ value: "react", labelKey: "agents.graph.react" },
];
function GraphTypeBadge({ value }: { value: string }) {
const { t } = useTranslation();
const text = t(`agents.graph.${value === "plan_review_revise" ? "planReviewRevise" : value}`, value);
const tone =
value === "react"
? "bg-purple-500"
: value === "rag"
? "bg-blue-500"
: value === "plan_review_revise"
? "bg-emerald-500"
: "bg-slate-500";
return <Badge className={`${tone} text-white hover:${tone}`}>{text}</Badge>;
}
// ============================================================================
// Agent list (left rail)
// ============================================================================
function AgentsList({
agents,
selectedId,
onSelect,
isLoading,
search,
onSearch,
}: {
agents: AIAgentSummary[];
selectedId: number | null;
onSelect: (id: number) => void;
isLoading: boolean;
search: string;
onSearch: (v: string) => void;
}) {
const { t } = useTranslation();
return (
<Card className="lg:col-span-1">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="h-4 w-4" />
{t("agents.list.title", "Agents")}
</CardTitle>
<CardDescription>
{t(
"agents.list.subtitle",
"Pre-configured for every platform pillar — edit defaults below.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="relative">
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={t("agents.list.search", "Search agents…")}
className="ps-8"
/>
</div>
{isLoading ? (
<Skeleton className="h-64 w-full" />
) : agents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("agents.list.empty", "No agents match your search.")}
</p>
) : (
<ScrollArea className="h-[480px] pe-2">
<ul className="space-y-1">
{agents.map((a) => {
const active = selectedId === a.id;
return (
<li key={a.id}>
<button
type="button"
onClick={() => onSelect(a.id)}
className={`w-full rounded-md border p-3 text-start transition-colors ${
active
? "border-primary bg-primary/5"
: "hover:bg-muted/40 border-transparent"
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{a.name}</div>
<div className="text-muted-foreground truncate font-mono text-xs">
{a.key}
</div>
</div>
<ChevronRight className="text-muted-foreground h-4 w-4 flex-shrink-0" />
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<GraphTypeBadge value={a.graph_type} />
<Badge variant="outline" className="text-xs">
{a.model}
</Badge>
<Badge variant="outline" className="text-xs">
<Wrench className="me-1 h-3 w-3" />
{a.tool_count}
</Badge>
{!a.active ? (
<Badge variant="secondary" className="text-xs">
{t("common.disabled", "Disabled")}
</Badge>
) : null}
</div>
</button>
</li>
);
})}
</ul>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
// ============================================================================
// Test panel (run a small input through the agent)
// ============================================================================
function AgentTestRunner({ agent }: { agent: AIAgent }) {
const { t } = useTranslation();
const [variables, setVariables] = useState<string>("{}");
const [payload, setPayload] = useState<string>("");
const [result, setResult] = useState<AIAgentTestResponse | null>(null);
const test = useMutation({
mutationFn: async () => {
let parsedVars: Record<string, unknown> = {};
try {
parsedVars = variables.trim() ? JSON.parse(variables) : {};
} catch {
throw new Error(t("agents.test.badVarsJson", "Variables must be valid JSON."));
}
let parsedPayload: unknown = payload;
const trimmed = payload.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
parsedPayload = JSON.parse(trimmed);
} catch {
parsedPayload = payload;
}
}
return aiAgentService.test(agent.id, {
variables: parsedVars,
payload: parsedPayload,
});
},
onSuccess: (res) => {
setResult(res);
if (res.error) {
toast.error(res.error);
} else {
toast.success(t("agents.test.ok", "Agent ran successfully"));
}
},
onError: (err: Error) => toast.error(err.message),
});
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<PlayCircle className="h-4 w-4" />
{t("agents.test.title", "Test runner")}
</CardTitle>
<CardDescription>
{t(
"agents.test.subtitle",
"Send a small payload and inspect the agent's output, tool calls, and quality issues.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">
{t("agents.test.variables", "Variables (JSON)")}
</Label>
<Textarea
value={variables}
onChange={(e) => setVariables(e.target.value)}
placeholder='{"cefr_level": "b1"}'
className="min-h-[120px] font-mono text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.test.payload", "Payload (text or JSON)")}
</Label>
<Textarea
value={payload}
onChange={(e) => setPayload(e.target.value)}
placeholder={t(
"agents.test.payloadPlaceholder",
"What should the agent do? e.g. 'Generate 5 MCQ for B1 reading.'",
)}
className="min-h-[120px] text-sm"
/>
</div>
</div>
<Button onClick={() => test.mutate()} disabled={test.isPending}>
<PlayCircle className="me-1 h-4 w-4" />
{test.isPending
? t("agents.test.running", "Running…")
: t("agents.test.run", "Run agent")}
</Button>
{result ? (
<div className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<Badge variant="outline">
<Activity className="me-1 h-3 w-3" />
{t("agents.test.iterations", "Iterations")}: {result.iterations}
</Badge>
<Badge variant="outline">
<RotateCcw className="me-1 h-3 w-3" />
{t("agents.test.revisions", "Revisions")}: {result.revisions_used}
</Badge>
<Badge variant="outline">
{t("agents.test.toolCalls", "Tool calls")}: {result.tool_results.length}
</Badge>
<Badge variant="outline">
{t("agents.test.retrievalHits", "Retrieval hits")}: {result.retrieval_hits}
</Badge>
{result.quality_issues.length ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500">
{t("agents.test.qualityIssues", "Quality issues")}: {result.quality_issues.length}
</Badge>
) : null}
</div>
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide">
{t("agents.test.output", "Output")}
</Label>
<pre className="bg-muted/50 max-h-[300px] overflow-auto rounded-md p-3 text-xs">
{typeof result.output === "string"
? result.output
: JSON.stringify(result.output, null, 2)}
</pre>
</div>
{result.tool_results.length ? (
<details className="rounded-md border p-2">
<summary className="cursor-pointer text-xs font-medium">
{t("agents.test.toolTrace", "Tool trace")}
</summary>
<pre className="mt-2 max-h-[260px] overflow-auto text-xs">
{JSON.stringify(result.tool_results, null, 2)}
</pre>
</details>
) : null}
</div>
) : null}
</CardContent>
</Card>
);
}
// ============================================================================
// Configure dialog
// ============================================================================
function ConfigureAgentDialog({
agent,
tools,
open,
onOpenChange,
}: {
agent: AIAgent;
tools: AIToolSummary[];
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const [draft, setDraft] = useState<AIAgentUpdateInput>({});
useEffect(() => {
if (open) {
setDraft({
name: agent.name,
description: agent.description,
system_prompt: agent.system_prompt,
prompt_key: agent.prompt_key,
model: agent.model,
fallback_model: agent.fallback_model,
temperature: agent.temperature,
max_tokens: agent.max_tokens,
max_revisions: agent.max_revisions,
response_format: agent.response_format,
graph_type: agent.graph_type,
quality_checks: (agent.quality_checks || []).join(","),
tool_keys: agent.tool_keys,
active: agent.active,
});
}
}, [open, agent]);
const update = useMutation({
mutationFn: (input: AIAgentUpdateInput) => aiAgentService.update(agent.id, input),
onSuccess: () => {
toast.success(t("agents.config.saved", "Agent configuration saved"));
qc.invalidateQueries({ queryKey: ["ai-agents"] });
qc.invalidateQueries({ queryKey: ["ai-agent", agent.id] });
onOpenChange(false);
},
onError: (err: Error) => toast.error(err.message),
});
const toggleTool = (key: string) => {
const current = new Set(draft.tool_keys ?? []);
if (current.has(key)) current.delete(key);
else current.add(key);
setDraft({ ...draft, tool_keys: Array.from(current) });
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("agents.config.title", "Configure")} {agent.name}
</DialogTitle>
<DialogDescription className="font-mono text-xs">
{agent.key}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Identity */}
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.name", "Display name")}</Label>
<Input
value={draft.name ?? ""}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.promptKey", "Prompt key (versioned)")}
</Label>
<Input
value={draft.prompt_key ?? ""}
placeholder="e.g. course_planner.system"
onChange={(e) => setDraft({ ...draft, prompt_key: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.description", "Description")}</Label>
<Textarea
value={draft.description ?? ""}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
className="min-h-[60px]"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.systemPrompt", "System prompt")}</Label>
<Textarea
value={draft.system_prompt ?? ""}
onChange={(e) => setDraft({ ...draft, system_prompt: e.target.value })}
className="min-h-[200px] font-mono text-xs"
/>
<p className="text-muted-foreground text-xs">
{t(
"agents.config.systemPromptHint",
"If a prompt key is set above, the active version of that prompt overrides this field at runtime.",
)}
</p>
</div>
{/* Runtime config */}
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.model", "Model")}</Label>
<Select
value={draft.model ?? agent.model}
onValueChange={(v) => setDraft({ ...draft, model: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.fallbackModel", "Fallback model")}
</Label>
<Select
value={draft.fallback_model ?? agent.fallback_model}
onValueChange={(v) => setDraft({ ...draft, fallback_model: v })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MODEL_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t("agents.config.responseFormat", "Output format")}</Label>
<Select
value={draft.response_format ?? agent.response_format}
onValueChange={(v) =>
setDraft({ ...draft, response_format: v as "text" | "json" })
}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="json">JSON</SelectItem>
<SelectItem value="text">{t("agents.config.text", "Text")}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.temperature", "Temperature")}
</Label>
<Input
type="number"
step="0.1"
min={0}
max={2}
value={draft.temperature ?? agent.temperature}
onChange={(e) =>
setDraft({ ...draft, temperature: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.maxTokens", "Max tokens")}
</Label>
<Input
type="number"
min={64}
max={32000}
value={draft.max_tokens ?? agent.max_tokens}
onChange={(e) =>
setDraft({ ...draft, max_tokens: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.maxRevisions", "Max revisions")}
</Label>
<Input
type="number"
min={0}
max={5}
value={draft.max_revisions ?? agent.max_revisions}
onChange={(e) =>
setDraft({ ...draft, max_revisions: Number(e.target.value) })
}
/>
</div>
</div>
{/* Graph */}
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.graphType", "Graph topology")}
</Label>
<Select
value={draft.graph_type ?? agent.graph_type}
onValueChange={(v) =>
setDraft({ ...draft, graph_type: v as AIAgent["graph_type"] })
}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{GRAPH_OPTIONS.map((g) => (
<SelectItem key={g.value} value={g.value}>
{t(g.labelKey, g.value)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">
{t("agents.config.qualityChecks", "Quality checks (comma-separated tool keys)")}
</Label>
<Input
value={draft.quality_checks ?? ""}
onChange={(e) =>
setDraft({ ...draft, quality_checks: e.target.value })
}
placeholder="quality.cefr_check,quality.ai_detect"
className="font-mono text-xs"
/>
</div>
{/* Tools */}
<div className="space-y-2">
<Label className="text-xs">{t("agents.config.tools", "Enabled tools")}</Label>
<div className="grid gap-2 md:grid-cols-2">
{tools.map((tool) => {
const enabled = (draft.tool_keys ?? []).includes(tool.key);
return (
<label
key={tool.id}
className={`flex items-start gap-2 rounded-md border p-2 text-xs transition-colors ${
enabled ? "border-primary bg-primary/5" : ""
}`}
>
<Checkbox
checked={enabled}
onCheckedChange={() => toggleTool(tool.key)}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1">
<span className="font-mono text-xs">{tool.key}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{tool.category}
</Badge>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
{t("agents.config.mutates", "writes")}
</Badge>
) : null}
</div>
<p className="text-muted-foreground mt-0.5 line-clamp-2">
{tool.description}
</p>
</div>
</label>
);
})}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={draft.active ?? agent.active}
onCheckedChange={(v) => setDraft({ ...draft, active: v })}
/>
<Label className="text-xs">
{t("agents.config.active", "Agent is active")}
</Label>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("common.cancel", "Cancel")}
</Button>
<Button onClick={() => update.mutate(draft)} disabled={update.isPending}>
<CheckCircle2 className="me-1 h-4 w-4" />
{update.isPending
? t("common.saving", "Saving…")
: t("common.save", "Save changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ============================================================================
// Detail panel
// ============================================================================
function AgentDetail({ agent, tools }: { agent: AIAgent; tools: AIToolSummary[] }) {
const { t } = useTranslation();
const [configOpen, setConfigOpen] = useState(false);
return (
<div className="space-y-4 lg:col-span-2">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-xl">{agent.name}</CardTitle>
<CardDescription className="font-mono text-xs">
{agent.key}
</CardDescription>
{agent.description ? (
<p className="mt-2 text-sm">{agent.description}</p>
) : null}
</div>
<Button variant="outline" onClick={() => setConfigOpen(true)}>
<Settings2 className="me-1 h-4 w-4" />
{t("agents.detail.configure", "Configure")}
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.graph", "Graph")}
</div>
<div className="mt-1"><GraphTypeBadge value={agent.graph_type} /></div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.model", "Model")}
</div>
<div className="mt-1 font-mono text-sm">{agent.model}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.temperature", "Temperature")}
</div>
<div className="mt-1 font-mono text-sm">{agent.temperature.toFixed(2)}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.tokens", "Max tokens")}
</div>
<div className="mt-1 font-mono text-sm">{agent.max_tokens}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.format", "Output format")}
</div>
<div className="mt-1 text-sm">
{agent.response_format === "json" ? "JSON" : t("agents.config.text", "Text")}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.fallback", "Fallback")}
</div>
<div className="mt-1 font-mono text-sm">
{agent.fallback_model || "—"}
</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.revisions", "Max revisions")}
</div>
<div className="mt-1 font-mono text-sm">{agent.max_revisions}</div>
</div>
<div>
<div className="text-muted-foreground text-xs">
{t("agents.detail.promptKey", "Prompt key")}
</div>
<div className="mt-1 font-mono text-xs">
{agent.prompt_key || "—"}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<Wrench className="h-4 w-4" />
{t("agents.detail.toolsTitle", "Enabled tools")} ({agent.tools.length})
</CardTitle>
</CardHeader>
<CardContent>
{agent.tools.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("agents.detail.noTools", "This agent has no tools enabled.")}
</p>
) : (
<ul className="space-y-2">
{agent.tools.map((tool) => (
<li key={tool.id} className="rounded-md border p-2 text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs">{tool.key}</span>
<Badge variant="outline" className="text-[10px] uppercase">
{tool.category}
</Badge>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white hover:bg-amber-500 text-[10px]">
{t("agents.config.mutates", "writes")}
</Badge>
) : null}
</div>
<p className="text-muted-foreground mt-1 text-xs">{tool.description}</p>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2 text-base">
<PencilLine className="h-4 w-4" />
{t("agents.detail.systemPrompt", "System prompt")}
</CardTitle>
</CardHeader>
<CardContent>
<pre className="bg-muted/50 max-h-[260px] overflow-auto rounded-md p-3 text-xs">
{agent.system_prompt || t("agents.detail.noPrompt", "(empty)")}
</pre>
</CardContent>
</Card>
<AgentTestRunner agent={agent} />
<ConfigureAgentDialog
agent={agent}
tools={tools}
open={configOpen}
onOpenChange={setConfigOpen}
/>
</div>
);
}
// ============================================================================
// Top-level Agents tab
// ============================================================================
export function AIAgentsPanel() {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [selectedId, setSelectedId] = useState<number | null>(null);
const agentsQ = useQuery({
queryKey: ["ai-agents", { search }],
queryFn: () => aiAgentService.list(search ? { search } : undefined),
});
const toolsQ = useQuery({
queryKey: ["ai-agent-tools"],
queryFn: () => aiAgentService.listTools(),
});
// Auto-select first agent.
useEffect(() => {
if (!selectedId && agentsQ.data && agentsQ.data.length > 0) {
setSelectedId(agentsQ.data[0].id);
}
}, [selectedId, agentsQ.data]);
const detailQ = useQuery({
queryKey: ["ai-agent", selectedId],
queryFn: () => aiAgentService.get(selectedId as number),
enabled: !!selectedId,
});
const filteredAgents = useMemo(() => agentsQ.data ?? [], [agentsQ.data]);
return (
<div className="grid gap-4 lg:grid-cols-3">
<AgentsList
agents={filteredAgents}
selectedId={selectedId}
onSelect={setSelectedId}
isLoading={agentsQ.isLoading}
search={search}
onSearch={setSearch}
/>
{detailQ.data ? (
<AgentDetail agent={detailQ.data} tools={toolsQ.data ?? []} />
) : selectedId ? (
<Card className="lg:col-span-2">
<CardContent className="p-6">
<Skeleton className="h-72 w-full" />
</CardContent>
</Card>
) : (
<Card className="lg:col-span-2">
<CardContent className="text-muted-foreground p-6 text-sm">
{t("agents.detail.empty", "Select an agent to inspect its configuration.")}
</CardContent>
</Card>
)}
</div>
);
}

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