10 Commits

Author SHA1 Message Date
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
146 changed files with 11948 additions and 1281 deletions

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

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,146 +10,175 @@ 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 TeacherLibrary from "@/pages/teacher/TeacherLibrary";
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();
return (
@@ -158,14 +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 />} />
@@ -200,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 />} />
@@ -226,6 +279,7 @@ const App = () => (
<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 />} />
@@ -241,6 +295,7 @@ const App = () => (
<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>
@@ -251,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 */}
@@ -263,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 />} />
@@ -285,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 */}
@@ -340,10 +407,12 @@ const App = () => (
<Route path="/dashboard/admin" element={<Navigate to="/admin/platform" replace />} />
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AuthProvider>
</BrowserRouter>
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</ErrorBoundary>
);

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

@@ -1,6 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
fallback?: ReactNode;
}
@@ -10,7 +11,12 @@ interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
/**
* 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 };
@@ -27,6 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
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">
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component<Props, State> {
<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">Something went wrong</h2>
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
<p className="text-muted-foreground text-sm">
An unexpected error occurred. Please try refreshing the page.
{t("errors.unexpectedDescription")}
</p>
{this.state.error && (
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">Error details</summary>
<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>
)}
@@ -50,7 +57,7 @@ export class ErrorBoundary extends Component<Props, State> {
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"
>
Refresh Page
{t("errors.refreshPage")}
</button>
</div>
</div>
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component<Props, State> {
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

@@ -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

@@ -2,47 +2,52 @@ 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 },
],
},
];

View File

@@ -1,39 +1,41 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, FileText,
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2, Library,
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: "Resource Library", url: "/teacher/library", icon: Library },
{ 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,6 +8,7 @@ import { analyticsService } from "@/services/analytics.service";
export default function AiAlertBanner() {
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
const [errorDismissed, setErrorDismissed] = useState(false);
const { t } = useTranslation();
const { data: resp, isLoading, isError, error } = useQuery({
queryKey: ["ai", "alerts"],
@@ -20,20 +22,20 @@ export default function AiAlertBanner() {
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" />
@@ -48,7 +50,7 @@ export default function AiAlertBanner() {
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>
);
}
@@ -58,11 +60,11 @@ export default function AiAlertBanner() {
return (
<div className="space-y-3">
{visible.map((alert, idx) => (
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
<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>

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,19 +9,27 @@ 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) =>
@@ -31,15 +40,12 @@ export default function AiAssistantDrawer() {
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

@@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
@@ -35,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",
});
},
@@ -60,21 +62,21 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
<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">
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const Icon = insightIcon(item.severity);
const color = insightColor(item.severity);
return (
<div key={idx} 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.recommendation && (

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"),
});
},
});
@@ -32,11 +34,11 @@ export default function AiSearchBar() {
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,7 +52,7 @@ 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>
@@ -58,11 +60,11 @@ export default function AiSearchBar() {
</div>
{(searchMutation.isPending || result !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
<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>
) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
</div>
{result.suggestions?.length > 0 && (
<div className="border-t pt-2 space-y-1">
<p className="text-xs font-semibold text-primary">Related queries</p>
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
{result.suggestions.map((s, i) => (
<button
key={i}
@@ -99,9 +101,7 @@ export default function AiSearchBar() {
) : (
<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

@@ -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 && (
@@ -52,25 +61,25 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
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"
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip`
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`;
? 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">{label}</span>
<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 && (

View File

@@ -1,6 +1,7 @@
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";
@@ -10,6 +11,7 @@ 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());
@@ -30,11 +32,14 @@ export default function ExamPopup() {
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("en-US", { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
};
const handleStart = (exam: StudentExamAssignment) => {
@@ -54,10 +59,10 @@ export default function ExamPopup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
Upcoming Exams ({pendingExams.length})
{t("examPopup.title", { n: pendingExams.length })}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<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">
@@ -66,7 +71,7 @@ export default function ExamPopup() {
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
</Badge>
</div>
@@ -77,11 +82,11 @@ export default function ExamPopup() {
<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" />
From: {formatDate(exam.start_date)}
{t("examPopup.from")} {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
To: {formatDate(exam.end_date)}
{t("examPopup.to")} {formatDate(exam.end_date)}
</span>
</div>
@@ -93,20 +98,20 @@ export default function ExamPopup() {
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? "Start Exam" : "Not Available Yet"}
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
Dismiss
{t("common.dismiss")}
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ml-auto">
<span className="text-[11px] text-muted-foreground italic ms-auto">
{exam.schedule_state === "planned"
? "Exam will be available once it becomes active"
: "Exam is not currently active"}
? t("examPopup.willBeAvailable")
: t("examPopup.notActive")}
</span>
)}
</div>

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,29 +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}
aria-describedby={props["aria-describedby"] ?? undefined}
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,244 +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,
myCourses: ["lms", "my-courses"] 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,
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,
@@ -258,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: {
@@ -266,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,
},
@@ -282,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,
@@ -299,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

@@ -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

@@ -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),

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

@@ -114,6 +114,7 @@ export default function AssignmentsPage() {
const [stateFilter, setStateFilter] = useState<ScheduleState | "all">("all");
const [createOpen, setCreateOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm());
const [studentQuery, setStudentQuery] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
@@ -144,6 +145,14 @@ export default function AssignmentsPage() {
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 ?? [];
@@ -468,9 +477,23 @@ export default function AssignmentsPage() {
{/* Individual student selection */}
{form.assign_mode === "individual" && (
<div className="space-y-1">
<Label className="text-xs">Select Students</Label>
<div className="max-h-48 overflow-y-auto border rounded-md p-2 space-y-1">
{students.map((s) => (
<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)}
@@ -487,6 +510,11 @@ export default function AssignmentsPage() {
{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>

View File

@@ -66,11 +66,11 @@ export default function EntitiesPage() {
onError: (err: Error) => toast({ title: "Error", description: err.message, variant: "destructive" }),
});
const raw = entitiesQ.data;
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 Record<string, unknown>;
const r = raw as { items?: unknown; data?: unknown };
const arr = (r.items ?? r.data ?? []) as never[];
return Array.isArray(arr) ? arr : [];
})();

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 context="exam" 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>
);
}

View File

@@ -1355,11 +1355,11 @@ export default function ExamStructuresPage() {
const examType = getExamTypeBadge(s);
return (
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />{s.name}
</CardTitle>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-primary" />{s.name}
</CardTitle>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
@@ -1370,9 +1370,9 @@ export default function ExamStructuresPage() {
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
@@ -1382,9 +1382,9 @@ export default function ExamStructuresPage() {
{(Array.isArray(s.modules) ? s.modules : []).map((m) => (
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
))}
</div>
</CardContent>
</Card>
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@@ -37,12 +37,14 @@ export default function ExamsListPage() {
});
const sessionsQ = useInstitutionalExamSessions();
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
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 filteredSessions = sessions.filter((s: Record<string, string>) =>
const filteredSessions = sessions.filter((s) =>
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
);
@@ -140,7 +142,7 @@ export default function ExamsListPage() {
{filteredSessions.length === 0 && (
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
)}
{filteredSessions.map((s: Record<string, string>, i: number) => (
{filteredSessions.map((s, i) => (
<TableRow key={s.id}>
<TableCell>{i + 1}</TableCell>
<TableCell className="font-medium">{s.name}</TableCell>

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>

View File

@@ -61,9 +61,9 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
import { generationService } from "@/services/generation.service";
import { mediaService, type Avatar } from "@/services/media.service";
import { examsService } from "@/services/exams.service";
import { api } from "@/lib/api-client";
import { api, describeApiError } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import type { ExamStructureConfig } from "@/types";
import type { ExamStructure, ExamStructureConfig } from "@/types";
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
@@ -398,7 +398,7 @@ export default function GenerationPage() {
const createStructureMut = useMutation({
mutationFn: (data: { name: string; modules: string[] }) =>
examsService.createStructure(data),
examsService.createStructure(data as unknown as Partial<ExamStructure>),
onSuccess: (created) => {
queryClient.invalidateQueries({ queryKey: ["exam-structures"] });
setExamStructure(String(created.id));
@@ -693,7 +693,7 @@ export default function GenerationPage() {
try {
const status = await mediaService.getVideoStatus(videoId);
if (status.status === "done" || status.status === "completed" || status.video_url) {
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || "" };
changed = true;
toast({ title: "Video ready!", description: "Avatar video has been generated." });
} else if (status.status === "error" || status.status === "failed") {
@@ -709,8 +709,51 @@ export default function GenerationPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeModule, moduleStates]);
// Compute the minimum required counts from the selected exam structure
// so the submit mutation can block under-fulfilled modules instead of
// silently dropping tasks/sections (QA: "if structure has Task 1 + Task 2
// the user should not be able to submit with only Task 1").
const requiredWritingTaskCount = (() => {
const cfg = selectedStructureConfig;
if (!cfg?.writing) return 0;
return Math.max(
cfg.writing.tasks?.length ?? 0,
cfg.writing.task2 ? 2 : cfg.writing.task1 ? 1 : 0,
);
})();
const requiredListeningPartCount = selectedStructureConfig?.listening?.parts?.length ?? 0;
const requiredReadingPassageCount = selectedStructureConfig?.reading?.passages?.length ?? 0;
const requiredSpeakingPartCount = selectedStructureConfig?.speaking?.parts?.length ?? 0;
const submitMut = useMutation({
mutationFn: (skipApproval: boolean) => {
// Enforce structure-defined task/section/part counts before submission.
if (examMode === "official" && selectedStructure) {
const w = getModuleState("writing");
if (selectedModules.has("writing") && requiredWritingTaskCount > 0 && w.writingTasks.length < requiredWritingTaskCount) {
throw new Error(
`This structure defines ${requiredWritingTaskCount} writing tasks — you currently have ${w.writingTasks.length}. Please add the missing task before submitting.`,
);
}
const l = getModuleState("listening");
if (selectedModules.has("listening") && requiredListeningPartCount > 0 && l.listeningSections.length < requiredListeningPartCount) {
throw new Error(
`This structure defines ${requiredListeningPartCount} listening parts — you currently have ${l.listeningSections.length}.`,
);
}
const r = getModuleState("reading");
if (selectedModules.has("reading") && requiredReadingPassageCount > 0 && r.passages.length < requiredReadingPassageCount) {
throw new Error(
`This structure defines ${requiredReadingPassageCount} reading passages — you currently have ${r.passages.length}.`,
);
}
const sp = getModuleState("speaking");
if (selectedModules.has("speaking") && requiredSpeakingPartCount > 0 && sp.speakingParts.length < requiredSpeakingPartCount) {
throw new Error(
`This structure defines ${requiredSpeakingPartCount} speaking parts — you currently have ${sp.speakingParts.length}.`,
);
}
}
const modulesPayload: Record<string, unknown> = {};
for (const mod of selectedModules) {
const st = getModuleState(mod);
@@ -726,7 +769,7 @@ export default function GenerationPage() {
totalMarks: st.totalMarks,
passages: mod === "reading" ? st.passages.map((p) => ({
text: p.text, category: p.category, type: p.type,
exercises: p.exercises?.map((ex: Record<string, unknown>) => ({
exercises: p.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
@@ -735,7 +778,7 @@ export default function GenerationPage() {
})) : undefined,
sections: mod === "listening" ? st.listeningSections.map((s) => ({
type: s.type, context: s.context, audioUrl: s.audioUrl,
exercises: s.exercises?.map((ex: Record<string, unknown>) => ({
exercises: s.exercises?.map((ex) => ({
type: ex.type, prompt: ex.prompt, options: ex.options,
correct_answer: ex.correct_answer, explanation: ex.explanation,
instructions: ex.instructions, marks: ex.marks || 1,
@@ -764,7 +807,12 @@ export default function GenerationPage() {
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
duration: 8000,
}),
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
onError: (err) =>
toast({
variant: "destructive",
title: "Submit failed",
description: describeApiError(err, "Submit failed", "module"),
}),
});
const anyGenerating = generatePassageMut.isPending || generateExercisesMut.isPending ||
@@ -773,6 +821,11 @@ export default function GenerationPage() {
const renderCommonConfig = (mod: ModuleKey) => {
const st = getModuleState(mod);
// Rubrics only apply to subjective skills (Writing & Speaking).
// Listening & Reading are auto-graded against a correct_answer, so
// surfacing the rubric picker here is misleading — QA asked to either
// grey it out or mark it "Not applicable".
const rubricApplies = mod === "writing" || mod === "speaking";
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div className="space-y-1">
@@ -781,30 +834,44 @@ export default function GenerationPage() {
</div>
{examMode === "official" && (
<div className="space-y-1">
<Label className="text-xs">Rubric</Label>
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
<Label className="text-xs flex items-center gap-1">
Rubric
{!rubricApplies && (
<span className="text-[10px] font-normal text-muted-foreground">(Not applicable)</span>
)}
</Label>
{rubricApplies ? (
<Select value={st.rubricId} onValueChange={(v) => updateModuleState(mod, { rubricId: v })}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select rubric..." /></SelectTrigger>
<SelectContent>
{rubrics.length > 0 && (
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rubrics</div>
)}
{rubrics.map((r) => (
<SelectItem key={`r-${r.id}`} value={`rubric-${r.id}`}>{r.name}</SelectItem>
))}
{rubricGroups.length > 0 && (
<>
<div className="border-t my-1" />
<div className="px-2 py-1 text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Groups</div>
</>
)}
{rubricGroups.map((g) => (
<SelectItem key={`g-${g.id}`} value={`group-${g.id}`}>{g.name}</SelectItem>
))}
{rubrics.length === 0 && rubricGroups.length === 0 && (
<SelectItem value="_none" disabled>No rubrics available</SelectItem>
)}
</SelectContent>
</Select>
) : (
<div
className="h-8 text-xs flex items-center px-3 rounded-md border border-input bg-muted/40 text-muted-foreground select-none"
title="Reading and Listening are auto-graded from a correct-answer key; rubrics only apply to Writing and Speaking."
>
Auto-graded no rubric
</div>
)}
</div>
)}
<div className="space-y-1">
@@ -1202,6 +1269,12 @@ export default function GenerationPage() {
<SelectItem value="page_break">Page Break</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
Controls how this section is visually separated from the next in the student view: <b>None</b> no separator; <b>Line</b> horizontal rule; <b>Space</b> extra vertical gap; <b>Page Break</b> section starts on a new page (paginated delivery only).
</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
Write instructions in the box above the audio (or the <i>Generate Instructions</i> card). Use the imperative voice, e.g. Listen to the conversation and answer the questions below.
</p>
</CollapsibleContent>
</Collapsible>
<Collapsible>
@@ -1407,7 +1480,7 @@ export default function GenerationPage() {
<div key={i} className="flex items-center gap-1 bg-muted/50 rounded px-2 py-0.5">
<Checkbox checked id={`wt-${i}`} />
<Label htmlFor={`wt-${i}`} className="text-xs">Task {i + 1}</Label>
{st.writingTasks.length > 1 && (
{st.writingTasks.length > Math.max(1, requiredWritingTaskCount) && (
<button
className="ml-1 text-muted-foreground/60 hover:text-destructive transition-colors"
onClick={() => {
@@ -1428,6 +1501,11 @@ export default function GenerationPage() {
}}><Plus className="h-3 w-3 mr-1" /> Add Task 2</Button>
)}
</div>
{requiredWritingTaskCount > 0 && st.writingTasks.length < requiredWritingTaskCount && (
<p className="text-[11px] text-destructive">
This structure requires {requiredWritingTaskCount} tasks. Add the missing task to submit.
</p>
)}
</div>
<Button variant="ghost" size="sm" onClick={() => setModuleStates((prev) => ({ ...prev, writing: defaultModuleState("writing") }))} className="text-xs">
@@ -1485,6 +1563,12 @@ export default function GenerationPage() {
<SelectItem value="page_break">Page Break</SelectItem>
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
Visual separator between Task&nbsp;1 and Task&nbsp;2 in the student exam: <b>None</b> — no gap; <b>Line</b> — horizontal rule; <b>Space</b> — extra padding; <b>Page&nbsp;Break</b> — Task&nbsp;2 opens on a new page.
</p>
<p className="text-[11px] text-muted-foreground mt-1 leading-snug">
Instructions are written in the <i>Generate Instructions</i> box below. Use the imperative voice and name the genre + audience, e.g. “Write a 250-word academic essay arguing…”.
</p>
</CollapsibleContent>
</Collapsible>
<Collapsible>
@@ -2196,6 +2280,54 @@ export default function GenerationPage() {
setSelectedModules(next);
if (next.size > 0) setActiveModule([...next][0]);
}
// Pre-populate writingTasks / listeningSections / readingPassages
// / speakingParts to match the structure. QA flagged that selecting
// a structure with "Task 1 + Task 2" still let you submit with only
// Task 1 because the UI defaulted to a single task.
const cfg = (s.config && typeof s.config === "object" ? s.config : null) as ExamStructureConfig | null;
if (cfg) {
const writingTaskCount = Math.max(
cfg.writing?.tasks?.length ?? 0,
cfg.writing?.task2 ? 2 : cfg.writing?.task1 ? 1 : 0,
);
if (writingTaskCount > 0) {
updateModuleState("writing", {
writingTasks: Array.from({ length: writingTaskCount }, (_, i) => {
const base = defaultWritingTask();
if (i === 1) {
base.type = "essay";
base.wordLimit = 250;
}
const taskCfg = cfg.writing?.tasks?.[i];
if (taskCfg) {
if (taskCfg.type) base.type = taskCfg.type;
if (taskCfg.min_words) base.wordLimit = taskCfg.min_words;
}
return base;
}),
});
}
const listeningPartCount = cfg.listening?.parts?.length ?? 0;
if (listeningPartCount > 0) {
updateModuleState("listening", {
listeningSections: Array.from({ length: listeningPartCount }, () => defaultListeningSection()),
});
}
const readingPassageCount = cfg.reading?.passages?.length ?? 0;
if (readingPassageCount > 0) {
updateModuleState("reading", {
passages: Array.from({ length: readingPassageCount }, () => defaultPassage()),
});
}
const speakingPartCount = cfg.speaking?.parts?.length ?? 0;
if (speakingPartCount > 0) {
updateModuleState("speaking", {
speakingParts: Array.from({ length: speakingPartCount }, (_, i) =>
defaultSpeakingPart(i === 2 ? "interactive" : `speaking_${i + 1}`),
),
});
}
}
}
};

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="text"
placeholder="you@example.com"
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>

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 context="record" variant="insight" />
<AiReportNarrative report_type="record" data={{ records }} />
{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

@@ -37,6 +37,14 @@ import {
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";
@@ -81,12 +89,70 @@ interface RubricFormState {
levels: Set<string>;
}
const emptyCriterion = (levels: Set<string>): CriterionEntry => ({
name: "",
weight: 0,
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",
@@ -165,7 +231,7 @@ export default function RubricsPage() {
queryKey: ["rubrics"],
queryFn: () => examsService.listRubrics({}),
});
const rubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
const rubrics = (rubricsQ.data?.items ?? []) as unknown as RubricItem[];
const rubricGroupsQ = useQuery({
queryKey: ["rubric-groups"],
@@ -437,16 +503,41 @@ export default function RubricsPage() {
<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 add manually.</p>
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Manually
</Button>
<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">
@@ -507,15 +598,40 @@ export default function RubricsPage() {
</div>
</Collapsible>
))}
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={() => setForm((f) => ({ ...f, criteria: [...f.criteria, emptyCriterion(f.levels)] }))}
>
<Plus className="h-3 w-3 mr-1" /> Add Criterion
</Button>
<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>

View File

@@ -1,126 +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";
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 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="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 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="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 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="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 report_type="corporate-comparison" data={{ threshold }} />
<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

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

View File

@@ -0,0 +1,348 @@
import { useMemo, useState } from "react";
import { CheckCircle2, Filter, MessageSquare, ThumbsDown, ThumbsUp } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import {
useAIFeedbackList,
useResolveAIFeedback,
} from "@/hooks/queries/useAIFeedback";
import type {
AIFeedback,
AIFeedbackStatus,
AIFeedbackSubjectType,
} from "@/types/ai-feedback";
const STATUS_COLORS: Record<AIFeedbackStatus, string> = {
open: "bg-amber-100 text-amber-900 border-amber-300",
acknowledged: "bg-sky-100 text-sky-900 border-sky-300",
fixed: "bg-emerald-100 text-emerald-900 border-emerald-300",
dismissed: "bg-slate-100 text-slate-700 border-slate-300",
};
type ResolveChoice = Exclude<AIFeedbackStatus, "open">;
function ResolveDialog({
feedback,
onClose,
}: {
feedback: AIFeedback | null;
onClose: () => void;
}) {
const resolve = useResolveAIFeedback();
const [status, setStatus] = useState<ResolveChoice>("acknowledged");
const [notes, setNotes] = useState("");
const open = !!feedback;
return (
<Dialog open={open} onOpenChange={(v) => (!v ? onClose() : null)}>
<DialogContent
className="max-w-lg"
description="Triage this feedback by setting its status and leaving a short note for the audit trail."
>
<DialogHeader>
<DialogTitle>Resolve feedback</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Status
</div>
<Select
value={status}
onValueChange={(v) => setStatus(v as ResolveChoice)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="acknowledged">Acknowledged</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<div className="text-muted-foreground text-xs uppercase">
Notes
</div>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={4}
placeholder="What did you do? (optional)"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={async () => {
if (!feedback) return;
try {
await resolve.mutateAsync({
id: feedback.id,
status,
notes,
});
toast.success(`Marked ${status}`);
setNotes("");
onClose();
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Resolve failed",
);
}
}}
disabled={resolve.isPending}
>
<CheckCircle2 className="mr-1 h-4 w-4" />
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default function AIFeedbackTriage() {
const [statusFilter, setStatusFilter] = useState<AIFeedbackStatus | "all">("open");
const [ratingFilter, setRatingFilter] = useState<"up" | "down" | "all">("down");
const [subjectFilter, setSubjectFilter] = useState<
AIFeedbackSubjectType | "all"
>("all");
const [selected, setSelected] = useState<AIFeedback | null>(null);
const params = useMemo(
() => ({
status: statusFilter === "all" ? undefined : statusFilter,
rating: ratingFilter === "all" ? undefined : ratingFilter,
subject_type: subjectFilter === "all" ? undefined : subjectFilter,
page: 0,
size: 50,
}),
[statusFilter, ratingFilter, subjectFilter],
);
const { data, isLoading } = useAIFeedbackList(params);
const items: AIFeedback[] = data?.items ?? [];
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
<MessageSquare className="mr-1 inline h-5 w-5" />
AI feedback triage
</h1>
<p className="text-muted-foreground mt-1 text-sm">
Student thumbs up/down on AI output use this to catch broken
prompts, bad questions, and low-quality coach replies.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>
<Filter className="mr-1 inline h-4 w-4" />
Filters
</CardTitle>
<CardDescription>
Default view surfaces open thumbs-down reports, which are usually
the most actionable.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-3">
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Status
</div>
<Select
value={statusFilter}
onValueChange={(v) =>
setStatusFilter(v as AIFeedbackStatus | "all")
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="acknowledged">Acknowledged</SelectItem>
<SelectItem value="fixed">Fixed</SelectItem>
<SelectItem value="dismissed">Dismissed</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Rating
</div>
<Select
value={ratingFilter}
onValueChange={(v) => setRatingFilter(v as "up" | "down" | "all")}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="down">Thumbs down</SelectItem>
<SelectItem value="up">Thumbs up</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<div className="text-muted-foreground text-xs uppercase">
Type
</div>
<Select
value={subjectFilter}
onValueChange={(v) =>
setSubjectFilter(v as AIFeedbackSubjectType | "all")
}
>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="question">Exam question</SelectItem>
<SelectItem value="coach">Coach reply</SelectItem>
<SelectItem value="explanation">Explanation</SelectItem>
<SelectItem value="translation">Translation</SelectItem>
<SelectItem value="narrative">Report narrative</SelectItem>
<SelectItem value="other">Other</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Feedback ({data?.total ?? 0})</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-40 w-full" />
) : items.length === 0 ? (
<p className="text-muted-foreground text-sm">
No feedback matches the current filters.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>When</TableHead>
<TableHead>User</TableHead>
<TableHead>Subject</TableHead>
<TableHead>Rating</TableHead>
<TableHead>Prompt</TableHead>
<TableHead>Comment</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((f) => (
<TableRow key={f.id}>
<TableCell className="text-muted-foreground text-xs whitespace-nowrap">
{f.create_date
? new Date(f.create_date).toLocaleString()
: "—"}
</TableCell>
<TableCell className="text-sm">{f.user_name}</TableCell>
<TableCell className="font-mono text-xs">
{f.subject_key}
</TableCell>
<TableCell>
{f.rating === "up" ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
<ThumbsUp className="mr-1 h-3 w-3" />
Up
</Badge>
) : (
<Badge className="bg-rose-500 text-white hover:bg-rose-500">
<ThumbsDown className="mr-1 h-3 w-3" />
Down
</Badge>
)}
</TableCell>
<TableCell className="font-mono text-xs">
{f.prompt_key
? `${f.prompt_key} v${f.prompt_version ?? "?"}`
: "—"}
</TableCell>
<TableCell className="max-w-md text-sm">
{f.comment || <span className="text-muted-foreground"></span>}
</TableCell>
<TableCell>
<Badge
variant="outline"
className={STATUS_COLORS[f.status]}
>
{f.status}
</Badge>
</TableCell>
<TableCell className="text-right">
{f.status === "open" ? (
<Button
size="sm"
variant="outline"
onClick={() => setSelected(f)}
>
Resolve
</Button>
) : (
<span className="text-muted-foreground text-xs">
{f.resolution_notes || "—"}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<ResolveDialog feedback={selected} onClose={() => setSelected(null)} />
</div>
);
}

View File

@@ -0,0 +1,599 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
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 { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
useAIPrompt,
useAIPromptKeys,
useAIPromptVersions,
useActivateAIPrompt,
useCreateAIPrompt,
useRenderAIPrompt,
} from "@/hooks/queries/useAIPrompts";
import { AIAgentsPanel } from "@/pages/admin/AIAgentsPanel";
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
import type { AIPromptSummary } from "@/types/ai-prompt";
import {
Bot,
CheckCircle2,
FileText,
History,
Play,
PlusCircle,
Wrench,
} from "lucide-react";
import { toast } from "sonner";
function SelectedKeyPanel({
selectedKey,
onSelectVersion,
activePromptId,
}: {
selectedKey: string;
onSelectVersion: (id: number) => void;
activePromptId: number | null;
}) {
const { data, isLoading } = useAIPromptVersions(selectedKey);
const activate = useActivateAIPrompt();
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
const versions = data ?? [];
if (!versions.length) {
return (
<p className="text-muted-foreground text-sm">
No versions yet for this prompt.
</p>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Version</TableHead>
<TableHead>Title</TableHead>
<TableHead>Author</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{versions.map((v) => (
<TableRow
key={v.id}
className={activePromptId === v.id ? "bg-muted/50" : undefined}
>
<TableCell className="font-mono">v{v.version}</TableCell>
<TableCell>{v.title}</TableCell>
<TableCell>{v.author_name || "—"}</TableCell>
<TableCell>
{v.is_active ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
Active
</Badge>
) : (
<Badge variant="secondary">Archived</Badge>
)}
</TableCell>
<TableCell className="space-x-2 text-right">
<Button
size="sm"
variant="outline"
onClick={() => onSelectVersion(v.id)}
>
<FileText className="mr-1 h-3 w-3" />
Open
</Button>
{!v.is_active ? (
<Button
size="sm"
onClick={async () => {
try {
await activate.mutateAsync(v.id);
toast.success(`Activated v${v.version}`);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Activate failed",
);
}
}}
disabled={activate.isPending}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Activate
</Button>
) : null}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function PromptDetail({ promptId }: { promptId: number }) {
const { data, isLoading } = useAIPrompt(promptId);
const render = useRenderAIPrompt();
// Variables detected from the prompt body — keep local state keyed by name
// so the editor can show one input per placeholder.
const [sampleValues, setSampleValues] = useState<Record<string, string>>({});
const [rendered, setRendered] = useState<string>("");
useEffect(() => {
setRendered("");
setSampleValues({});
}, [promptId]);
if (isLoading || !data) {
return <Skeleton className="h-40 w-full" />;
}
const variables = data.variables ?? [];
return (
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">
{data.title}{" "}
<span className="text-muted-foreground font-normal">
({data.key} v{data.version})
</span>
</h3>
{data.description ? (
<p className="text-muted-foreground mt-1 text-sm">
{data.description}
</p>
) : null}
</div>
<div>
<Label className="text-xs uppercase tracking-wide">Template</Label>
<ScrollArea className="bg-muted/30 mt-1 max-h-64 rounded border">
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
{data.content}
</pre>
</ScrollArea>
</div>
{variables.length > 0 ? (
<div className="space-y-2">
<Label className="text-xs uppercase tracking-wide">
Sample variables
</Label>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{variables.map((v) => (
<div key={v} className="space-y-1">
<Label className="text-xs">{v}</Label>
<Input
value={sampleValues[v] ?? ""}
onChange={(e) =>
setSampleValues((prev) => ({
...prev,
[v]: e.target.value,
}))
}
placeholder={`Sample value for {${v}}`}
/>
</div>
))}
</div>
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
const r = await render.mutateAsync({
id: data.id,
variables: sampleValues,
});
setRendered(r.rendered);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Render failed",
);
}
}}
disabled={render.isPending}
>
<Play className="mr-1 h-3 w-3" />
Render preview
</Button>
{rendered ? (
<ScrollArea className="bg-background mt-2 max-h-48 rounded border">
<pre className="p-3 font-mono text-sm whitespace-pre-wrap">
{rendered}
</pre>
</ScrollArea>
) : null}
</div>
) : null}
</div>
);
}
function NewVersionDialog({
open,
onOpenChange,
defaultKey,
defaultContent,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
defaultKey: string;
defaultContent: string;
}) {
const create = useCreateAIPrompt();
const [key, setKey] = useState(defaultKey);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [content, setContent] = useState(defaultContent);
const [activate, setActivate] = useState(true);
useEffect(() => {
if (open) {
setKey(defaultKey);
setContent(defaultContent);
setTitle("");
setDescription("");
setActivate(true);
}
}, [open, defaultKey, defaultContent]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-2xl"
description="Create a new prompt version; the previous active version will be archived."
>
<DialogHeader>
<DialogTitle>New prompt version</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Key</Label>
<Input
value={key}
onChange={(e) => setKey(e.target.value.trim())}
placeholder="exam.mcq.generate"
/>
<p className="text-muted-foreground mt-1 text-xs">
Lowercase dotted identifier. Reuse an existing key to bump its
version.
</p>
</div>
<div>
<Label>Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="MCQ generation — CEFR B2 — v6"
/>
</div>
<div>
<Label>Description (optional)</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
placeholder="What changed? What should editors know?"
/>
</div>
<div>
<Label>Template</Label>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={10}
className="font-mono text-sm"
placeholder="You are an exam author. Generate {count} MCQs for {topic}…"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={activate}
onChange={(e) => setActivate(e.target.checked)}
/>
Activate this version immediately
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={async () => {
if (!key || !title || !content.trim()) {
toast.error("Key, title, and content are required");
return;
}
try {
await create.mutateAsync({
key,
title,
description,
content,
activate,
});
toast.success("Prompt version created");
onOpenChange(false);
} catch (err) {
toast.error(
err instanceof Error ? err.message : "Create failed",
);
}
}}
disabled={create.isPending}
>
Save version
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* The original prompt-library UI, now scoped as the "Prompts" tab inside
* the larger AI Agents & Tools configurator. Behaviour unchanged — only
* the surrounding chrome (header + new-version button) is now contextual.
*/
function AIPromptsPanel() {
const [search, setSearch] = useState("");
const { data, isLoading } = useAIPromptKeys({ search, page: 1, size: 50 });
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const [selectedPromptId, setSelectedPromptId] = useState<number | null>(null);
const [newOpen, setNewOpen] = useState(false);
const items: AIPromptSummary[] = data?.items ?? [];
const activePromptId = useMemo(() => {
if (!selectedKey) return null;
const match = items.find((it) => it.key === selectedKey);
return match?.id ?? null;
}, [items, selectedKey]);
// Autoselect first key on load.
useEffect(() => {
if (!selectedKey && items.length) {
setSelectedKey(items[0].key);
setSelectedPromptId(items[0].id);
}
}, [items, selectedKey]);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-end">
<Button onClick={() => setNewOpen(true)}>
<PlusCircle className="mr-1 h-4 w-4" />
New version
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Prompt keys</CardTitle>
<CardDescription>
Each row shows the latest version of a prompt key. Click a row to
inspect all versions.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input
placeholder="Search keys (e.g. exam.mcq)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-sm"
/>
{isLoading ? (
<Skeleton className="h-32 w-full" />
) : items.length === 0 ? (
<p className="text-muted-foreground text-sm">
No prompts yet. Click <strong>New version</strong> to add one.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Key</TableHead>
<TableHead>Latest</TableHead>
<TableHead>Title</TableHead>
<TableHead>Variables</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((p) => (
<TableRow
key={p.id}
className={
selectedKey === p.key
? "bg-muted/40 cursor-pointer"
: "cursor-pointer"
}
onClick={() => {
setSelectedKey(p.key);
setSelectedPromptId(p.id);
}}
>
<TableCell className="font-mono text-xs">{p.key}</TableCell>
<TableCell>
v{p.version}{" "}
{p.total_versions && p.total_versions > 1 ? (
<span className="text-muted-foreground text-xs">
(of {p.total_versions})
</span>
) : null}
</TableCell>
<TableCell>{p.title}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{p.variables.slice(0, 4).map((v) => (
<Badge key={v} variant="outline" className="text-xs">
{v}
</Badge>
))}
{p.variables.length > 4 ? (
<Badge variant="outline" className="text-xs">
+{p.variables.length - 4}
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
{p.is_active ? (
<Badge className="bg-emerald-500 text-white hover:bg-emerald-500">
Active
</Badge>
) : (
<Badge variant="secondary">No active</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{selectedKey ? (
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>
<History className="mr-1 inline h-4 w-4" />
Versions of {selectedKey}
</CardTitle>
</CardHeader>
<CardContent>
<SelectedKeyPanel
selectedKey={selectedKey}
onSelectVersion={setSelectedPromptId}
activePromptId={selectedPromptId}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
Inspect template body and try a dry-run render.
</CardDescription>
</CardHeader>
<CardContent>
{selectedPromptId ? (
<PromptDetail promptId={selectedPromptId} />
) : (
<p className="text-muted-foreground text-sm">
Select a version from the list.
</p>
)}
</CardContent>
</Card>
</div>
) : null}
<NewVersionDialog
open={newOpen}
onOpenChange={setNewOpen}
defaultKey={selectedKey ?? ""}
defaultContent=""
/>
</div>
);
}
/**
* Top-level page mounted at /admin/ai/prompts.
*
* The original "AI prompt library" lives on as the third tab so existing
* deep-links and saved bookmarks still work. The default tab is now the
* Agents configurator — the user explicitly asked for this page to become
* "config ai agent tools" with sensible defaults already shipped.
*/
export default function AIPromptEditor() {
const { t } = useTranslation();
return (
<div className="mx-auto max-w-7xl space-y-6 p-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{t("aiAdmin.title", "AI Agents & Tools")}
</h1>
<p className="text-muted-foreground mt-1 text-sm">
{t(
"aiAdmin.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.",
)}
</p>
</div>
<Tabs defaultValue="agents" className="space-y-6">
<TabsList className="h-auto w-full justify-start gap-2 bg-transparent p-0">
<TabsTrigger
value="agents"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<Bot className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.agents", "Agents")}
</TabsTrigger>
<TabsTrigger
value="tools"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<Wrench className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.tools", "Tools")}
</TabsTrigger>
<TabsTrigger
value="prompts"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<FileText className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.prompts", "Prompts")}
</TabsTrigger>
</TabsList>
<TabsContent value="agents" className="mt-2">
<AIAgentsPanel />
</TabsContent>
<TabsContent value="tools" className="mt-2">
<AIToolsPanel />
</TabsContent>
<TabsContent value="prompts" className="mt-2">
<AIPromptsPanel />
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,179 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Search, Wrench } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { aiAgentService } from "@/services/aiAgent.service";
import type { AIToolSummary } from "@/types/aiAgent";
const CATEGORY_TONES: Record<string, string> = {
retrieval: "bg-blue-500",
persistence: "bg-amber-500",
quality: "bg-purple-500",
scoring: "bg-emerald-500",
reference: "bg-slate-500",
other: "bg-zinc-500",
};
export function AIToolsPanel() {
const { t } = useTranslation();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const toolsQ = useQuery({
queryKey: ["ai-agent-tools"],
queryFn: () => aiAgentService.listTools(),
});
const toggle = useMutation({
mutationFn: (tool: AIToolSummary) =>
aiAgentService.updateTool(tool.id, { active: !tool.active }),
onSuccess: (tool) => {
toast.success(
tool.active
? t("tools.toggle.enabled", "Tool enabled")
: t("tools.toggle.disabled", "Tool disabled"),
);
qc.invalidateQueries({ queryKey: ["ai-agent-tools"] });
},
onError: (err: Error) => toast.error(err.message),
});
const filtered = useMemo(() => {
const items = toolsQ.data ?? [];
if (!search.trim()) return items;
const needle = search.toLowerCase();
return items.filter(
(t) =>
t.key.toLowerCase().includes(needle) ||
t.name.toLowerCase().includes(needle) ||
(t.description || "").toLowerCase().includes(needle),
);
}, [toolsQ.data, search]);
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Wrench className="h-4 w-4" />
{t("tools.title", "Agent tools")}
</CardTitle>
<CardDescription>
{t(
"tools.subtitle",
"Capabilities your agents can call. Toggle a tool off to take it out of every agent without editing each one.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="relative max-w-sm">
<Search className="text-muted-foreground absolute start-2 top-1/2 h-4 w-4 -translate-y-1/2" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t("tools.search", "Search tools…")}
className="ps-8"
/>
</div>
{toolsQ.isLoading ? (
<Skeleton className="h-64 w-full" />
) : filtered.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("tools.empty", "No tools match your search.")}
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("tools.col.key", "Key")}</TableHead>
<TableHead>{t("tools.col.name", "Name")}</TableHead>
<TableHead>{t("tools.col.category", "Category")}</TableHead>
<TableHead>{t("tools.col.description", "Description")}</TableHead>
<TableHead>{t("tools.col.params", "Params")}</TableHead>
<TableHead className="w-[100px] text-end">
{t("tools.col.active", "Active")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((tool) => {
const tone = CATEGORY_TONES[tool.category] || "bg-zinc-500";
const params =
(tool.schema?.properties as Record<string, unknown>) || {};
const paramKeys = Object.keys(params);
return (
<TableRow key={tool.id}>
<TableCell className="font-mono text-xs">{tool.key}</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<span>{tool.name}</span>
{tool.mutates ? (
<Badge className="bg-amber-500 text-white text-[10px] hover:bg-amber-500">
{t("tools.writes", "writes")}
</Badge>
) : null}
</div>
</TableCell>
<TableCell>
<Badge className={`${tone} text-white hover:${tone}`}>
{tool.category}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground max-w-[360px] text-xs">
{tool.description}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{paramKeys.length === 0 ? (
<span className="text-muted-foreground text-xs"></span>
) : (
paramKeys.slice(0, 5).map((k) => (
<Badge key={k} variant="outline" className="text-[10px]">
{k}
</Badge>
))
)}
{paramKeys.length > 5 ? (
<Badge variant="outline" className="text-[10px]">
+{paramKeys.length - 5}
</Badge>
) : null}
</div>
</TableCell>
<TableCell className="text-end">
<Switch
checked={tool.active}
onCheckedChange={() => toggle.mutate(tool)}
disabled={toggle.isPending}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -35,7 +35,7 @@ export default function AdminBatchDetail() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4" /></Link></Button>
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{batch.name}</h1>
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>

View File

@@ -0,0 +1,423 @@
import { useMemo } from "react";
import { Link, useParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
BookOpen,
Calendar,
ClipboardList,
Headphones,
Library,
MessageSquare,
Mic,
PenSquare,
Sparkles,
Type,
Wand2,
type LucideIcon,
} 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 { Skeleton } from "@/components/ui/skeleton";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type {
CoursePlan,
CoursePlanMaterial,
CoursePlanSkill,
CoursePlanWeek,
} from "@/types";
/**
* Detail page for a single AI-generated course plan.
*
* Sections:
* - Header with course metadata + CEFR badge
* - Course description
* - Objectives list
* - Learning outcomes grouped by skill
* - Grammar scope
* - Assessment breakdown
* - Resources
* - Weekly delivery plan (accordion). Each week shows the items table
* from the AI plus a "Generate materials" button. When the user
* clicks it, we POST to `/weeks/:n/materials` and re-render the
* week content with the returned reading/listening/grammar/…
* materials.
*/
const SKILL_ICONS: Record<string, LucideIcon> = {
reading: BookOpen,
writing: PenSquare,
listening: Headphones,
speaking: Mic,
grammar: Type,
vocabulary: Library,
integrated: MessageSquare,
};
export default function AdminCoursePlanDetail() {
const { t } = useTranslation();
const params = useParams<{ planId: string }>();
const planId = Number(params.planId);
const qc = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["course-plan", planId],
queryFn: () => coursePlanService.get(planId),
enabled: Number.isFinite(planId) && planId > 0,
});
const plan = data?.data as CoursePlan | undefined;
const generateMut = useMutation({
mutationFn: (weekNumber: number) =>
coursePlanService.generateWeekMaterials(planId, weekNumber),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plan", planId] });
toast.success(t("coursePlan.weekMaterialsGenerated"));
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.weekMaterialsFailed"))),
});
const materialsByWeek = useMemo(() => {
const map = new Map<number, CoursePlanMaterial[]>();
if (!plan?.materials) return map;
for (const m of plan.materials) {
const list = map.get(m.week_number) ?? [];
list.push(m);
map.set(m.week_number, list);
}
return map;
}, [plan?.materials]);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="-ml-2 text-muted-foreground">
<Link to="/admin/course-plans" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
{t("coursePlan.backToList")}
</Link>
</Button>
{isLoading && (
<div className="space-y-4">
<Skeleton className="h-20 rounded-lg" />
<Skeleton className="h-40 rounded-lg" />
<Skeleton className="h-60 rounded-lg" />
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{plan && (
<>
{/* Header */}
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="space-y-1 flex-1 min-w-0">
<CardTitle className="text-2xl">{plan.name}</CardTitle>
{plan.description && (
<CardDescription className="max-w-3xl">
{plan.description}
</CardDescription>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="uppercase">
{plan.cefr_level || "—"}
</Badge>
<Badge variant="outline">
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</Badge>
<Badge variant="outline">
{t("coursePlan.hoursPerWeek", {
count: plan.contact_hours_per_week,
})}
</Badge>
</div>
</div>
{plan.skills_division && (
<p className="text-sm text-muted-foreground">
{plan.skills_division}
</p>
)}
</CardHeader>
</Card>
{/* Objectives */}
{plan.objectives.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-primary" />
{t("coursePlan.sections.objectives")}
</CardTitle>
</CardHeader>
<CardContent>
<ol className="list-decimal list-inside space-y-1 text-sm">
{plan.objectives.map((obj, i) => (
<li key={i}>{obj}</li>
))}
</ol>
</CardContent>
</Card>
)}
{/* Outcomes per skill */}
{Object.keys(plan.outcomes).length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("coursePlan.sections.outcomes")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{(Object.keys(plan.outcomes) as CoursePlanSkill[]).map((skill) => {
const list = plan.outcomes[skill];
if (!list?.length) return null;
const Icon = SKILL_ICONS[skill] ?? ClipboardList;
return (
<div key={skill}>
<div className="flex items-center gap-2 mb-2">
<Icon className="h-4 w-4" />
<div className="font-medium capitalize">{t(`coursePlan.skill.${skill}`, skill)}</div>
</div>
<ul className="space-y-1 text-sm">
{list.map((o) => (
<li key={o.code} className="flex gap-2">
<Badge variant="outline" className="font-mono text-[10px] shrink-0">
{o.code}
</Badge>
<span className="flex-1">{o.description}</span>
</li>
))}
</ul>
</div>
);
})}
</CardContent>
</Card>
)}
{/* Grammar scope */}
{plan.grammar.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Type className="h-4 w-4 text-primary" />
{t("coursePlan.sections.grammar")}
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm">
{plan.grammar.map((g) => (
<li key={g.code}>
<div className="font-medium">
<Badge variant="outline" className="font-mono text-[10px] mr-2">
{g.code}
</Badge>
{g.label}
</div>
{g.sub_items?.length ? (
<ul className="list-disc list-inside ml-6 mt-1 text-muted-foreground text-xs">
{g.sub_items.map((s, i) => (
<li key={i}>{s}</li>
))}
</ul>
) : null}
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Weekly delivery plan */}
{plan.weeks && plan.weeks.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Calendar className="h-4 w-4 text-primary" />
{t("coursePlan.sections.delivery")}
</CardTitle>
<CardDescription>
{t("coursePlan.deliveryHint")}
</CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{plan.weeks.map((week) => (
<WeekAccordionItem
key={week.id}
week={week}
materials={materialsByWeek.get(week.week_number) ?? []}
generating={generateMut.isPending && generateMut.variables === week.week_number}
onGenerate={() => generateMut.mutate(week.week_number)}
/>
))}
</Accordion>
</CardContent>
</Card>
)}
</>
)}
</div>
);
}
function WeekAccordionItem({
week,
materials,
generating,
onGenerate,
}: {
week: CoursePlanWeek;
materials: CoursePlanMaterial[];
generating: boolean;
onGenerate: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionItem value={String(week.week_number)}>
<AccordionTrigger className="text-left">
<div className="flex-1 flex items-center gap-3 min-w-0">
<Badge variant="outline" className="shrink-0">
{t("coursePlan.weekN", { n: week.week_number })}
</Badge>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{week.focus || week.unit || "—"}</div>
{week.date_label && (
<div className="text-xs text-muted-foreground truncate">
{week.date_label}
</div>
)}
</div>
{materials.length > 0 && (
<Badge variant="secondary" className="shrink-0">
{t("coursePlan.materialsCount", { count: materials.length })}
</Badge>
)}
</div>
</AccordionTrigger>
<AccordionContent className="space-y-4">
{/* Items table */}
{week.items.length > 0 && (
<div className="rounded-md border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-xs uppercase text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.skill")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.outcomes")}
</th>
<th className="px-3 py-2 text-left font-medium">
{t("coursePlan.table.remarks")}
</th>
</tr>
</thead>
<tbody>
{week.items.map((item, i) => {
const Icon = SKILL_ICONS[item.skill] ?? ClipboardList;
return (
<tr key={i} className="border-t">
<td className="px-3 py-2">
<span className="flex items-center gap-1.5 capitalize">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
{item.skill}
</span>
</td>
<td className="px-3 py-2 flex flex-wrap gap-1">
{item.outcome_codes.map((c) => (
<Badge
key={c}
variant="outline"
className="font-mono text-[10px]"
>
{c}
</Badge>
))}
</td>
<td className="px-3 py-2 text-muted-foreground">
{item.remarks || "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Generate materials */}
<div className="flex flex-wrap items-center gap-2">
<Button size="sm" onClick={onGenerate} disabled={generating}>
<Wand2 className="mr-1 h-4 w-4" />
{generating
? t("coursePlan.generating")
: materials.length > 0
? t("coursePlan.regenerateMaterials")
: t("coursePlan.generateMaterials")}
</Button>
<span className="text-xs text-muted-foreground">
{t("coursePlan.generateHint")}
</span>
</div>
{/* Generated materials */}
{materials.length > 0 && (
<div className="space-y-3">
{materials.map((m) => (
<MaterialCard key={m.id} material={m} />
))}
</div>
)}
</AccordionContent>
</AccordionItem>
);
}
function MaterialCard({ material }: { material: CoursePlanMaterial }) {
const { t } = useTranslation();
const Icon = SKILL_ICONS[material.skill] ?? ClipboardList;
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<Icon className="h-4 w-4 text-primary" />
<CardTitle className="text-base flex-1 min-w-0">{material.title}</CardTitle>
<Badge variant="outline" className="text-[10px]">
{t(`coursePlan.materialType.${material.material_type}`, material.material_type)}
</Badge>
</div>
{material.summary && (
<CardDescription>{material.summary}</CardDescription>
)}
</CardHeader>
<CardContent>
<pre className="whitespace-pre-wrap text-xs font-mono bg-muted/40 rounded-md p-3 max-h-80 overflow-auto">
{material.body_text || JSON.stringify(material.body, null, 2)}
</pre>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,175 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
BookOpen,
Plus,
Sparkles,
Trash2,
Calendar,
Layers,
} from "lucide-react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
/**
* Admin list of AI-generated course plans.
*
* Each plan row shows name + CEFR + weeks + status, plus an "Open"
* button that deep-links to the detail page. A prominent "Generate new"
* CTA routes to the Smart Wizard's CoursePlanWizard.
*/
export default function AdminCoursePlans() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const [search, setSearch] = useState("");
const { data, isLoading, isError, error } = useQuery({
queryKey: ["course-plans", { search }],
queryFn: () =>
coursePlanService.list({
page: 0,
size: 50,
search: search || undefined,
}),
});
const removeMut = useMutation({
mutationFn: (id: number) => coursePlanService.remove(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["course-plans"] });
toast.success(t("coursePlan.deleted"));
},
onError: (err) =>
toast.error(describeApiError(err, t("coursePlan.deleteFailed"))),
});
const items = data?.items ?? [];
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="space-y-1">
<div className="flex items-center gap-2">
<BookOpen className="h-5 w-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">
{t("coursePlan.listTitle")}
</h1>
</div>
<p className="text-muted-foreground max-w-2xl">
{t("coursePlan.listSubtitle")}
</p>
</div>
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
<Sparkles className="mr-1 h-4 w-4" />
{t("coursePlan.generateNew")}
</Button>
</div>
<div className="max-w-sm">
<Input
placeholder={t("coursePlan.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-lg" />
))}
</div>
)}
{isError && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{describeApiError(error, t("coursePlan.loadFailed"))}
</div>
)}
{!isLoading && !isError && items.length === 0 && (
<Card>
<CardContent className="flex flex-col items-center justify-center gap-3 py-12">
<Sparkles className="h-8 w-8 text-muted-foreground" />
<div className="text-center">
<div className="font-medium">{t("coursePlan.emptyTitle")}</div>
<div className="text-sm text-muted-foreground max-w-md">
{t("coursePlan.emptySubtitle")}
</div>
</div>
<Button onClick={() => navigate("/admin/smart-wizard/course-plan")}>
<Plus className="mr-1 h-4 w-4" />
{t("coursePlan.generateNew")}
</Button>
</CardContent>
</Card>
)}
{!isLoading && items.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{items.map((plan) => (
<Card key={plan.id} className="flex flex-col">
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base line-clamp-2">{plan.name}</CardTitle>
<Badge variant={plan.status === "approved" ? "default" : "outline"}>
{t(`coursePlan.status.${plan.status}`, plan.status)}
</Badge>
</div>
<CardDescription className="line-clamp-2">
{plan.description || t("coursePlan.noDescription")}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-between gap-3">
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Badge variant="secondary" className="text-[10px] uppercase">
{plan.cefr_level || "—"}
</Badge>
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{t("coursePlan.weeksCount", { count: plan.total_weeks })}
</span>
<span className="flex items-center gap-1">
<Layers className="h-3.5 w-3.5" />
{t("coursePlan.materialsCount", { count: plan.material_count })}
</span>
</div>
<div className="flex items-center gap-2">
<Button asChild variant="default" size="sm" className="flex-1">
<Link to={`/admin/course-plans/${plan.id}`}>
{t("coursePlan.open")}
</Link>
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => {
if (!window.confirm(t("coursePlan.confirmDelete", { name: plan.name }))) return;
removeMut.mutate(plan.id);
}}
aria-label={t("coursePlan.delete")}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -253,7 +253,13 @@ function CourseFormDialog({
/>
</div>
<div className="space-y-2">
<Label>Difficulty</Label>
<Label className="flex items-center gap-1">
Difficulty
<span
className="text-[11px] text-muted-foreground cursor-help"
title="General hardness tag shown to students when browsing courses (Beginner / Intermediate / Advanced). Use this to set expectations."
>(?)</span>
</Label>
<Select
value={form.difficulty_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, difficulty_level: v === "none" ? "" : v }))}
@@ -266,9 +272,18 @@ function CourseFormDialog({
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
A plain-language tag (Beginner / Intermediate / Advanced).
</p>
</div>
<div className="space-y-2">
<Label>CEFR Level</Label>
<Label className="flex items-center gap-1">
CEFR Level
<span
className="text-[11px] text-muted-foreground cursor-help"
title="Common European Framework of Reference level (A1-C2). Used by placement logic, adaptive engine and rubric mapping."
>(?)</span>
</Label>
<Select
value={form.cefr_level || "none"}
onValueChange={(v) => setForm((f) => ({ ...f, cefr_level: v === "none" ? "" : v }))}
@@ -281,6 +296,9 @@ function CourseFormDialog({
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
Standard proficiency band (A1-C2); drives placement &amp; rubrics.
</p>
</div>
</div>

View File

@@ -24,7 +24,7 @@ export default function AdminGradebook() {
const { data: coursesData } = useCourses({ size: 200 });
const { data: subjectsData } = useSubjects();
const coursesList = coursesData?.items ?? [];
const subjectsList = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const subjectsList = Array.isArray(subjectsData) ? subjectsData : [];
const createMutation = useCreateGradingAssignment();
const updateMutation = useUpdateGradingAssignment();
const deleteMutation = useDeleteGradingAssignment();

View File

@@ -29,7 +29,7 @@ export default function AdminLessons() {
const { data: subjectsData } = useSubjects();
const courses = coursesData?.items ?? [];
const allBatches = batchesData?.items ?? [];
const subjects = Array.isArray(subjectsData) ? subjectsData : subjectsData?.data ?? subjectsData?.items ?? [];
const subjects = Array.isArray(subjectsData) ? subjectsData : [];
const batchesForCourse = useMemo(
() => (form.course_id ? allBatches.filter((b) => b.course_id === Number(form.course_id)) : allBatches),
[allBatches, form.course_id],

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
@@ -32,6 +33,7 @@ interface DashboardStats {
}
export default function AdminLmsDashboard() {
const { t } = useTranslation();
const { data: coursesData, isLoading: lc } = useCourses();
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
const { data: teachersData, isLoading: lt } = useTeachers({ size: 500 });
@@ -39,9 +41,10 @@ export default function AdminLmsDashboard() {
const { data: dbStats } = useQuery<DashboardStats>({
queryKey: ["dashboard", "stats"],
queryFn: async () => {
const res = await api.get<{ data: DashboardStats }>("/stats");
return (res as { data: DashboardStats }).data ?? res;
queryFn: async (): Promise<DashboardStats> => {
const res = await api.get<{ data: DashboardStats } | DashboardStats>("/stats");
const wrapped = res as { data?: DashboardStats };
return wrapped.data ?? (res as DashboardStats);
},
staleTime: 30_000,
});
@@ -57,12 +60,12 @@ export default function AdminLmsDashboard() {
const openTickets = dbStats?.open_tickets ?? 0;
const statCards = [
{ label: "Total Students", value: String(students.length), icon: Users, color: "text-primary" },
{ label: "Active Courses", value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
{ label: "Teachers", value: String(teachers.length), icon: GraduationCap, color: "text-success" },
{ label: "Active Batches", value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
{ label: "Open Tickets", value: String(openTickets), icon: Ticket, color: "text-destructive" },
{ label: "Revenue", value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
{ label: t("adminDash.totalStudents"), value: String(students.length), icon: Users, color: "text-primary" },
{ label: t("adminDash.activeCourses"), value: `${courses.filter(c => c.status === "active").length} / ${courses.length}`, icon: BookOpen, color: "text-info" },
{ label: t("adminDash.teachers"), value: String(teachers.length), icon: GraduationCap, color: "text-success" },
{ label: t("adminDash.activeBatches"), value: `${batches.filter(b => b.status === "active").length} / ${batches.length}`, icon: Layers, color: "text-warning" },
{ label: t("adminDash.openTickets"), value: String(openTickets), icon: Ticket, color: "text-destructive" },
{ label: t("adminDash.revenue"), value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
];
const courseChartData = courses.map(c => {
@@ -81,12 +84,16 @@ export default function AdminLmsDashboard() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
<p className="text-muted-foreground">Platform overview and key metrics.</p>
<h1 className="text-2xl font-bold">{t("adminDash.title")}</h1>
<p className="text-muted-foreground">{t("adminDash.subtitle")}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" asChild><Link to="/admin/students"><Plus className="mr-1 h-3 w-3" />Add Student</Link></Button>
<Button asChild><Link to="/admin/courses"><Plus className="mr-1 h-3 w-3" />New Course</Link></Button>
<Button variant="outline" asChild>
<Link to="/admin/students"><Plus className="me-1 h-3 w-3" />{t("adminDash.addStudent")}</Link>
</Button>
<Button asChild>
<Link to="/admin/courses"><Plus className="me-1 h-3 w-3" />{t("adminDash.newCourse")}</Link>
</Button>
</div>
</div>
@@ -98,7 +105,7 @@ export default function AdminLmsDashboard() {
<Card key={s.label}>
<CardContent className="pt-4 pb-4">
<s.icon className={`h-5 w-5 ${s.color} mb-1`} />
<p className="text-lg font-bold">{s.value}</p>
<p className="text-lg font-bold"><bdi>{s.value}</bdi></p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</CardContent>
</Card>
@@ -107,16 +114,16 @@ export default function AdminLmsDashboard() {
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{[
{ label: "Departments", value: dbStats?.total_departments ?? 0, icon: FolderOpen },
{ label: "Classrooms", value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
{ label: "Subjects", value: dbStats?.total_subjects ?? 0, icon: BookOpen },
{ label: "Resources", value: dbStats?.total_resources ?? 0, icon: Layers },
{ label: t("adminDash.departments"), value: dbStats?.total_departments ?? 0, icon: FolderOpen },
{ label: t("adminDash.classrooms"), value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
{ label: t("adminDash.subjects"), value: dbStats?.total_subjects ?? 0, icon: BookOpen },
{ label: t("adminDash.resources"), value: dbStats?.total_resources ?? 0, icon: Layers },
].map(s => (
<Card key={s.label}>
<CardContent className="pt-3 pb-3 flex items-center gap-3">
<s.icon className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-sm font-semibold">{s.value}</p>
<s.icon className="h-4 w-4 text-muted-foreground shrink-0" />
<div className="min-w-0">
<p className="text-sm font-semibold"><bdi>{s.value}</bdi></p>
<p className="text-xs text-muted-foreground">{s.label}</p>
</div>
</CardContent>
@@ -126,7 +133,7 @@ export default function AdminLmsDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Courses Overview</CardTitle></CardHeader>
<CardHeader><CardTitle className="text-lg">{t("adminDash.coursesOverview")}</CardTitle></CardHeader>
<CardContent>
{courseChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
@@ -135,18 +142,18 @@ export default function AdminLmsDashboard() {
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="enrolled" name="Enrolled" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
<Bar dataKey="enrolled" name={t("adminDash.chartEnrolled")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">No course data available.</p>
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noCourseData")}</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Batch Capacity</CardTitle></CardHeader>
<CardHeader><CardTitle className="text-lg">{t("adminDash.batchCapacity")}</CardTitle></CardHeader>
<CardContent>
{batchChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
@@ -155,11 +162,11 @@ export default function AdminLmsDashboard() {
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
<Tooltip />
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground py-8 text-center">No batch data available.</p>
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noBatchData")}</p>
)}
</CardContent>
</Card>
@@ -167,31 +174,43 @@ export default function AdminLmsDashboard() {
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Quick Summary</CardTitle>
<CardTitle className="text-lg">{t("adminDash.quickSummary")}</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Module</TableHead><TableHead className="text-right">Count</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableHeader>
<TableRow>
<TableHead>{t("adminDash.colModule")}</TableHead>
<TableHead className="text-end">{t("adminDash.colCount")}</TableHead>
<TableHead>{t("adminDash.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">Exams</TableCell>
<TableCell className="text-right">{dbStats?.total_exams ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{dbStats?.total_exam_sessions ?? 0} sessions</TableCell>
<TableCell className="font-medium">{t("adminDash.exams")}</TableCell>
<TableCell className="text-end"><bdi>{dbStats?.total_exams ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">
{t("adminDash.examSessionsCount", { n: dbStats?.total_exam_sessions ?? 0 })}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Assignments</TableCell>
<TableCell className="text-right">{dbStats?.total_assignments ?? 0}</TableCell>
<TableCell className="text-muted-foreground">across courses</TableCell>
<TableCell className="font-medium">{t("adminDash.assignments")}</TableCell>
<TableCell className="text-end"><bdi>{dbStats?.total_assignments ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">{t("adminDash.acrossCourses")}</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Support Tickets</TableCell>
<TableCell className="text-right">{dbStats?.total_tickets ?? 0}</TableCell>
<TableCell className="text-muted-foreground">{openTickets} open</TableCell>
<TableCell className="font-medium">{t("adminDash.supportTickets")}</TableCell>
<TableCell className="text-end"><bdi>{dbStats?.total_tickets ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">
{t("adminDash.ticketsOpenCount", { n: openTickets })}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="font-medium">Payments</TableCell>
<TableCell className="text-right">{dbStats?.total_payments ?? 0}</TableCell>
<TableCell className="text-muted-foreground">${revenue.toLocaleString()} total</TableCell>
<TableCell className="font-medium">{t("adminDash.payments")}</TableCell>
<TableCell className="text-end"><bdi>{dbStats?.total_payments ?? 0}</bdi></TableCell>
<TableCell className="text-muted-foreground">
{t("adminDash.revenueTotal", { amount: `$${revenue.toLocaleString()}` })}
</TableCell>
</TableRow>
</TableBody>
</Table>

View File

@@ -0,0 +1,165 @@
import {
BookOpen,
Layers,
Wand2,
GitBranch,
ClipboardList,
FolderOpen,
Users,
GraduationCap,
FileText,
Ticket,
Building2,
Sparkles,
} from "lucide-react";
import { QuickSetupWizard, type WizardStep, type QuickCreate } from "@/components/QuickSetupWizard";
import { examsService } from "@/services/exams.service";
import { assignmentsService } from "@/services/assignments.service";
/**
* Admin smart-setup wizard.
*
* Surfaces the recommended order for standing up an exam-ready platform:
* rubric → structure → generate → approve → assign. Each step deep-links
* into the existing creation page and auto-ticks when the backend confirms
* at least one record exists, so coming back here after a sub-task shows
* visible progress.
*
* "Other quick creates" covers the most common ad-hoc actions (new course,
* upload resource, add user, etc.) so admins can jump straight in without
* hunting through the sidebar.
*/
const steps: WizardStep[] = [
{
id: "rubric",
titleKey: "quickSetup.admin.step1.title",
descriptionKey: "quickSetup.admin.step1.description",
helpKey: "quickSetup.admin.step1.help",
to: "/admin/rubrics",
icon: BookOpen,
check: async () => {
const res = await examsService.listRubrics({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "structure",
titleKey: "quickSetup.admin.step2.title",
descriptionKey: "quickSetup.admin.step2.description",
helpKey: "quickSetup.admin.step2.help",
to: "/admin/exam-structures",
icon: Layers,
check: async () => {
const res = await examsService.listStructures({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "generate",
titleKey: "quickSetup.admin.step3.title",
descriptionKey: "quickSetup.admin.step3.description",
helpKey: "quickSetup.admin.step3.help",
to: "/admin/generation",
icon: Wand2,
// "Is there at least one exam of any module?" — cheapest existence
// check; the writing module is a common default.
check: async () => {
const res = await examsService.list("writing", { page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
{
id: "approve",
titleKey: "quickSetup.admin.step4.title",
descriptionKey: "quickSetup.admin.step4.description",
helpKey: "quickSetup.admin.step4.help",
to: "/admin/approval-workflows",
icon: GitBranch,
// No cheap "any workflow exists" endpoint — leave this one uncheckable
// so it stays neutral and always visible in the flow.
},
{
id: "assign",
titleKey: "quickSetup.admin.step5.title",
descriptionKey: "quickSetup.admin.step5.description",
helpKey: "quickSetup.admin.step5.help",
to: "/admin/assignments",
icon: ClipboardList,
check: async () => {
const res = await assignmentsService.listSchedules({ page: 1, size: 1 });
return (res.items?.length ?? 0) > 0;
},
},
];
const quickCreates: QuickCreate[] = [
{
id: "course",
titleKey: "quickSetup.admin.quick.course.title",
descriptionKey: "quickSetup.admin.quick.course.description",
to: "/admin/courses",
icon: BookOpen,
},
{
id: "resource",
titleKey: "quickSetup.admin.quick.resource.title",
descriptionKey: "quickSetup.admin.quick.resource.description",
to: "/admin/resources",
icon: FolderOpen,
},
{
id: "student",
titleKey: "quickSetup.admin.quick.student.title",
descriptionKey: "quickSetup.admin.quick.student.description",
to: "/admin/students",
icon: Users,
},
{
id: "teacher",
titleKey: "quickSetup.admin.quick.teacher.title",
descriptionKey: "quickSetup.admin.quick.teacher.description",
to: "/admin/teachers",
icon: GraduationCap,
},
{
id: "classroom",
titleKey: "quickSetup.admin.quick.classroom.title",
descriptionKey: "quickSetup.admin.quick.classroom.description",
to: "/admin/classrooms",
icon: Building2,
},
{
id: "exam-session",
titleKey: "quickSetup.admin.quick.examSession.title",
descriptionKey: "quickSetup.admin.quick.examSession.description",
to: "/admin/exam-sessions",
icon: FileText,
},
{
id: "custom-exam",
titleKey: "quickSetup.admin.quick.customExam.title",
descriptionKey: "quickSetup.admin.quick.customExam.description",
to: "/admin/exams/new",
icon: Sparkles,
},
{
id: "ticket",
titleKey: "quickSetup.admin.quick.ticket.title",
descriptionKey: "quickSetup.admin.quick.ticket.description",
to: "/admin/tickets",
icon: Ticket,
},
];
export default function AdminQuickSetup() {
return (
<QuickSetupWizard
titleKey="quickSetup.adminTitle"
subtitleKey="quickSetup.adminSubtitle"
steps={steps}
quickCreates={quickCreates}
/>
);
}

View File

@@ -19,7 +19,7 @@ export default function AdminTeachers() {
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [gender, setGender] = useState("male");
const [gender, setGender] = useState<"male" | "female">("male");
const [specialization, setSpecialization] = useState("");
const { toast } = useToast();
const qc = useQueryClient();
@@ -157,7 +157,7 @@ export default function AdminTeachers() {
<div className="space-y-2"><Label>Email *</Label><Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></div>
<div className="space-y-2">
<Label>Gender</Label>
<Select value={gender} onValueChange={setGender}>
<Select value={gender} onValueChange={(v) => setGender(v as "male" | "female")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>

View File

@@ -57,7 +57,7 @@ export default function AdmissionDetail() {
<div className="space-y-6">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
<ArrowLeft className="h-4 w-4" />
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
</Button>
<div className="flex-1">
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>

View File

@@ -6,19 +6,12 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Plus, Trash2, Loader2, ChevronRight, Settings2, ArrowRight } from "lucide-react";
import { approvalsService, type ApprovalWorkflow } from "@/services/approvals.service";
import { Plus, Trash2, Loader2, Settings2, ArrowRight } from "lucide-react";
import { approvalsService, type ApprovalWorkflow, type ApprovalStage } from "@/services/approvals.service";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
interface WorkflowStage {
order: number;
approver_id: number;
approver_name: string;
max_days?: number;
notification_email?: string;
auto_escalate?: boolean;
}
type WorkflowStage = Omit<ApprovalStage, "order"> & { order: number };
export default function ApprovalWorkflowConfig() {
const { toast } = useToast();
@@ -28,16 +21,21 @@ export default function ApprovalWorkflowConfig() {
queryKey: ["approvals", "list"],
queryFn: () => approvalsService.list(),
});
const workflows = workflowsData?.results ?? [];
const workflows: ApprovalWorkflow[] = workflowsData?.results ?? workflowsData?.items ?? [];
type WorkflowWriteInput = Partial<ApprovalWorkflow> & {
steps?: Partial<ApprovalStage>[];
bypass_enabled?: boolean;
};
const createWorkflow = useMutation({
mutationFn: (data: Partial<ApprovalWorkflow>) => approvalsService.create(data),
mutationFn: (data: WorkflowWriteInput) => approvalsService.create(data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to create workflow", variant: "destructive" }),
});
const updateWorkflow = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<ApprovalWorkflow> }) => approvalsService.update(id, data),
mutationFn: ({ id, data }: { id: number; data: WorkflowWriteInput }) => approvalsService.update(id, data),
onSuccess: () => qc.invalidateQueries({ queryKey: ["approvals"] }),
onError: () => toast({ title: "Error", description: "Failed to update workflow", variant: "destructive" }),
});
@@ -72,8 +70,26 @@ export default function ApprovalWorkflowConfig() {
};
const handleCreate = () => {
const valid = stages.filter(s => s.approver_id > 0);
if (valid.length === 0) {
toast({ title: "Validation", description: "Add at least one stage with an approver ID", variant: "destructive" });
return;
}
createWorkflow.mutate(
{ name: wfName, steps: stages.map(s => ({ order: s.order, approver_id: s.approver_id, approver_name: s.approver_name })), status: "draft" } as Partial<ApprovalWorkflow>,
{
name: wfName,
status: "draft",
allow_bypass: bypassEnabled,
bypass_enabled: bypassEnabled,
steps: valid.map(s => ({
order: s.order,
approver_id: s.approver_id,
approver_name: s.approver_name,
max_days: s.max_days,
auto_escalate: s.auto_escalate,
notification_email: s.notification_email,
})),
},
{ onSuccess: () => { toast({ title: "Workflow Created" }); setShowCreate(false); resetForm(); } },
);
};
@@ -200,7 +216,7 @@ export default function ApprovalWorkflowConfig() {
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
<span>{step.approver_name}</span>
</div>
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground rtl:rotate-180" />}
</div>
))}
</div>

View File

@@ -17,6 +17,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useCreateCustomExam, useSaveAsTemplate } from "@/hooks/queries/useExamTemplates";
import { describeApiError } from "@/lib/api-client";
import { useSubjects } from "@/hooks/queries/useAdaptive";
import type { CustomExamCreateRequest, CustomScoringMethod } from "@/types";
import { toast } from "sonner";
@@ -114,7 +115,11 @@ export default function CustomExamCreate() {
total_time_min: v.total_time_min,
pass_threshold: v.pass_threshold,
results_release_mode: v.results_release_mode,
// Backend model field is `randomize_questions`; the controller reads both
// `randomize` and `randomize_questions`, but prefer the real field name
// so future serialisation roundtrips don't silently drop the flag.
randomize: v.randomize,
randomize_questions: v.randomize,
sections: v.sections.map((sec, i) => ({
title: sec.title,
skill: sec.skill,
@@ -124,26 +129,61 @@ export default function CustomExamCreate() {
sequence: i,
question_ids: sec.question_ids,
})),
};
} as CustomExamCreateRequest;
};
// Extract the id the backend actually returns. `POST /api/exam/custom/create`
// returns the serialised exam dict with `id`, not `exam_id`, so the old
// `raw.exam_id` read was always undefined (preventing navigation after publish).
const pickExamId = (res: unknown): number | undefined => {
const raw = res as { id?: number; exam_id?: number; data?: { id?: number; exam_id?: number } };
return raw?.id ?? raw?.exam_id ?? raw?.data?.id ?? raw?.data?.exam_id;
};
// Thin wrapper around the shared helper so the existing call-sites below
// keep working; the helper lives in api-client so every page gets the same
// actionable 413/504/401 messaging.
const describeError = (err: unknown, fallback: string): string =>
describeApiError(err, fallback, "exam");
const onSaveDraft = async () => {
// Validate everything before firing the request — previously the Save
// button POSTed with whatever was in the form and silently swallowed the
// 400/500 as a generic toast.
const ok = await form.trigger();
if (!ok) {
toast.error("Please fix the highlighted fields before saving.");
return;
}
try {
const payload = buildPayload();
await createExam.mutateAsync(payload);
toast.success("Draft saved.");
} catch {
toast.error("Could not save.");
} catch (err) {
toast.error(describeError(err, "Could not save draft"));
}
};
const onPublish = async () => {
const ok = await form.trigger();
if (!ok) {
toast.error("Please fix the highlighted fields before publishing.");
return;
}
let examId: number | undefined;
try {
const payload = buildPayload();
const res = await createExam.mutateAsync(payload);
const raw = res as { exam_id?: number; data?: { exam_id?: number } };
const id = raw.exam_id ?? raw.data?.exam_id;
if (form.getValues("save_as_template")) {
examId = pickExamId(res);
} catch (err) {
toast.error(describeError(err, "Publish failed"));
return;
}
// Template save is additive — if it fails, the exam has already been
// created. Don't show a scary "Publish failed" toast for a template issue.
if (form.getValues("save_as_template")) {
try {
const name = form.getValues("template_name")?.trim() || form.getValues("title");
await saveTemplate.mutateAsync({
name,
@@ -151,12 +191,13 @@ export default function CustomExamCreate() {
editable: true,
type: "custom",
});
} catch (err) {
toast.warning(describeError(err, "Exam saved, but template save failed"));
}
toast.success("Exam published.");
if (id != null) navigate(`/admin/exam`);
} catch {
toast.error("Publish failed.");
}
toast.success("Exam published.");
if (examId != null) navigate(`/admin/exam`);
};
return (

View File

@@ -0,0 +1,341 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Sparkles,
XCircle,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/sonner";
import {
useApproveExamReview,
useExamReviewDetail,
useRejectExamReview,
} from "@/hooks/queries/useExamReview";
import type { ExamReviewQuestion } from "@/types/exam-review";
const FAILING_THRESHOLD = 0.7;
function QuestionCard({ q, index }: { q: ExamReviewQuestion; index: number }) {
const failing = (q.quality_score ?? 0) < FAILING_THRESHOLD;
return (
<Card className={failing ? "border-destructive/40" : undefined}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<span className="font-mono">Q{index + 1}</span>
<span>·</span>
<Badge variant="outline" className="capitalize">
{q.skill}
</Badge>
<Badge variant="outline" className="capitalize">
{q.question_type}
</Badge>
<Badge variant="outline" className="capitalize">
{q.difficulty}
</Badge>
{q.ai_generated && (
<Badge variant="secondary" className="gap-1">
<Sparkles className="h-3 w-3" />
AI
</Badge>
)}
</div>
<CardTitle className="text-sm leading-snug">
{q.stem.length > 220 ? `${q.stem.slice(0, 220)}` : q.stem}
</CardTitle>
</div>
<Badge
variant={
(q.quality_score ?? 0) >= 0.85
? "default"
: failing
? "destructive"
: "secondary"
}
className="tabular-nums shrink-0"
>
{((q.quality_score ?? 0) * 100).toFixed(0)}%
</Badge>
</div>
</CardHeader>
{q.quality_report && Object.keys(q.quality_report).length > 0 && (
<CardContent>
<Label className="text-xs text-muted-foreground">Quality report</Label>
<pre className="mt-1 rounded-md bg-muted/50 p-3 text-xs overflow-x-auto">
{JSON.stringify(q.quality_report, null, 2)}
</pre>
</CardContent>
)}
</Card>
);
}
export default function ExamReviewDetail() {
const { examId } = useParams<{ examId: string }>();
const navigate = useNavigate();
const parsedId = examId ? Number(examId) : undefined;
const { data: exam, isLoading } = useExamReviewDetail(parsedId);
const approve = useApproveExamReview();
const reject = useRejectExamReview();
const [approveNotes, setApproveNotes] = useState("");
const [rejectNotes, setRejectNotes] = useState("");
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
if (isLoading || !exam || !parsedId) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-40 w-full" />
<Skeleton className="h-64 w-full" />
</div>
);
}
const handleApprove = async () => {
try {
await approve.mutateAsync({ examId: parsedId, notes: approveNotes });
toast.success("Exam approved and published.");
setApproveOpen(false);
navigate("/admin/exam/review-queue");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Approve failed");
}
};
const handleReject = async () => {
if (!rejectNotes.trim()) {
toast.error("Rejection notes are required.");
return;
}
try {
await reject.mutateAsync({ examId: parsedId, notes: rejectNotes });
toast.success("Exam rejected and returned to draft.");
setRejectOpen(false);
navigate("/admin/exam/review-queue");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Reject failed");
}
};
const summary = exam.summary;
const alreadyReviewed = exam.status !== "pending_review";
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Button asChild variant="ghost" size="sm" className="-ml-2">
<Link to="/admin/exam/review-queue">
<ArrowLeft className="h-4 w-4 me-1 rtl:rotate-180" /> Back to queue
</Link>
</Button>
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="outline" className="capitalize">
{exam.status.replace("_", " ")}
</Badge>
{exam.subject_name && <span>· {exam.subject_name}</span>}
{exam.teacher_name && <span>· Created by {exam.teacher_name}</span>}
</div>
</div>
{!alreadyReviewed && (
<div className="flex items-center gap-2">
<Dialog open={rejectOpen} onOpenChange={setRejectOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-1">
<XCircle className="h-4 w-4" /> Reject
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject this exam?</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="reject-notes">
Feedback for the author (required)
</Label>
<Textarea
id="reject-notes"
value={rejectNotes}
onChange={(e) => setRejectNotes(e.target.value)}
rows={6}
placeholder="Explain what needs to change before this exam can be published."
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setRejectOpen(false)}
disabled={reject.isPending}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={reject.isPending || !rejectNotes.trim()}
>
Reject &amp; return to draft
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={approveOpen} onOpenChange={setApproveOpen}>
<DialogTrigger asChild>
<Button className="gap-1">
<CheckCircle2 className="h-4 w-4" /> Approve &amp; publish
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Approve and publish?</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="approve-notes">Notes (optional)</Label>
<Textarea
id="approve-notes"
value={approveNotes}
onChange={(e) => setApproveNotes(e.target.value)}
rows={4}
placeholder="Any comments or context for the audit log."
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => setApproveOpen(false)}
disabled={approve.isPending}
>
Cancel
</Button>
<Button
onClick={handleApprove}
disabled={approve.isPending}
>
Publish exam
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)}
</div>
{alreadyReviewed && exam.reviewed_by_name && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Previously reviewed</CardTitle>
<CardDescription>
{exam.reviewed_by_name}
{exam.reviewed_at ? ` · ${new Date(exam.reviewed_at).toLocaleString()}` : ""}
</CardDescription>
</CardHeader>
{exam.review_notes && (
<CardContent className="text-sm whitespace-pre-wrap">
{exam.review_notes}
</CardContent>
)}
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Quality summary</CardTitle>
<CardDescription>
Aggregated across every question in this exam.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div className="text-muted-foreground">Questions</div>
<div className="text-2xl font-semibold tabular-nums">
{summary.question_count}
</div>
</div>
<div>
<div className="text-muted-foreground">Average quality</div>
<div className="text-2xl font-semibold tabular-nums">
{(summary.avg_quality_score * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-muted-foreground">Lowest score</div>
<div className="text-2xl font-semibold tabular-nums">
{(summary.min_quality_score * 100).toFixed(0)}%
</div>
</div>
<div>
<div className="text-muted-foreground flex items-center gap-1">
<AlertTriangle className="h-3 w-3" /> Failing
</div>
<div className="text-2xl font-semibold tabular-nums text-destructive">
{summary.failing_count}
</div>
</div>
</div>
</CardContent>
</Card>
<Separator />
<div className="space-y-3">
{exam.sections.length === 0 ? (
<p className="text-sm text-muted-foreground">
This exam has no sections yet.
</p>
) : (
exam.sections.map((section) => (
<section key={section.id} className="space-y-3">
<h2 className="text-base font-semibold flex items-center gap-2">
<span>{section.title}</span>
<Badge variant="outline" className="capitalize text-xs">
{section.skill || "general"}
</Badge>
<span className="text-sm font-normal text-muted-foreground">
· {section.questions.length} questions
</span>
</h2>
<div className="space-y-2">
{section.questions.map((q, idx) => (
<QuestionCard key={q.id} q={q} index={idx} />
))}
</div>
</section>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import {
AlertTriangle,
CheckCircle2,
FileText,
Search,
Sparkles,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useExamReviewQueue } from "@/hooks/queries/useExamReview";
/**
* Admin-facing queue of AI-generated exams that failed the automated quality
* gate and need a human to sign off before they can be published to students.
*
* The list intentionally stays dense — each row shows only the signals an
* operator needs to prioritise (quality score, failing-question count, AI
* model used). Clicking through to the detail page reveals the full report.
*/
export default function ExamReviewQueue() {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const pageSize = 20;
const { data, isLoading } = useExamReviewQueue({
page,
size: pageSize,
search: search || undefined,
status: "pending_review",
});
const items = data?.items ?? [];
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Exam Review Queue</h1>
<p className="text-sm text-muted-foreground mt-1">
AI-generated exams that failed the automated quality gate. Review
each one, then approve to publish or reject back to draft.
</p>
</div>
<Badge variant="secondary" className="gap-1">
<Sparkles className="h-3 w-3" />
{total} awaiting review
</Badge>
</div>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<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 by exam title…"
value={search}
onChange={(e) => {
setPage(1);
setSearch(e.target.value);
}}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isLoading ? (
<div className="p-6 space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
) : items.length === 0 ? (
<div className="p-10 text-center">
<CheckCircle2 className="mx-auto h-10 w-10 text-muted-foreground mb-3" />
<p className="text-sm text-muted-foreground">
Nothing in the review queue. Any time an AI-generated exam
fails the quality gate, it will show up here.
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[32%]">Exam</TableHead>
<TableHead>Subject</TableHead>
<TableHead className="text-center">Questions</TableHead>
<TableHead className="text-center">Avg. Quality</TableHead>
<TableHead className="text-center">Failing</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((exam) => {
const avg = exam.summary.avg_quality_score;
const failing = exam.summary.failing_count;
return (
<TableRow key={exam.id}>
<TableCell>
<div className="flex items-start gap-2">
<FileText className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div className="min-w-0">
<Link
to={`/admin/exam/review/${exam.id}`}
className="font-medium hover:underline"
>
{exam.title}
</Link>
<div className="text-xs text-muted-foreground mt-0.5">
{exam.summary.ai_generated_count} AI-generated ·{" "}
{exam.teacher_name || "Unassigned"}
</div>
</div>
</div>
</TableCell>
<TableCell className="text-sm">
{exam.subject_name || "—"}
</TableCell>
<TableCell className="text-center text-sm">
{exam.summary.question_count}
</TableCell>
<TableCell className="text-center">
<Badge
variant={
avg >= 0.85
? "default"
: avg >= 0.7
? "secondary"
: "destructive"
}
className="tabular-nums"
>
{(avg * 100).toFixed(0)}%
</Badge>
</TableCell>
<TableCell className="text-center">
{failing > 0 ? (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="h-3 w-3" />
{failing}
</Badge>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell className="text-right">
<Button asChild size="sm" variant="outline">
<Link to={`/admin/exam/review/${exam.id}`}>Review</Link>
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{pageCount > 1 && (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Page {page} of {pageCount} · {total} total
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -19,7 +19,7 @@ import {
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { useIeltsValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useIeltsExamValidation, usePublishIeltsExam, useAssignIeltsExam } from "@/hooks/queries/useExamTemplates";
import { useStudents, useBatches } from "@/hooks/queries/useLms";
import { ieltsExamService } from "@/services/ielts-exam.service";
import type { ExamValidationReport, ValidationCheck } from "@/types";
@@ -40,7 +40,7 @@ export default function IeltsExamValidation() {
const navigate = useNavigate();
const examId = Number(examIdParam);
const validationQ = useIeltsValidation(Number.isFinite(examId) ? examId : 0);
const validationQ = useIeltsExamValidation(Number.isFinite(examId) ? examId : 0);
const publishMut = usePublishIeltsExam();
const assignMut = useAssignIeltsExam();

View File

@@ -17,6 +17,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { resourcesService } from "@/services/resources.service";
import { coursewareService } from "@/services/courseware.service";
import { taxonomyService } from "@/services/taxonomy.service";
import { describeApiError } from "@/lib/api-client";
import { useToast } from "@/hooks/use-toast";
import { TaxonomyCascade } from "@/components/TaxonomyCascade";
import type { Resource, ResourceTag } from "@/types";
@@ -176,7 +177,7 @@ function EditResourceDialog({
</div>
<div className="space-y-2">
<Label>Status</Label>
<Select value={status} onValueChange={setStatus}>
<Select value={status} onValueChange={(v) => setStatus(v as "pending" | "approved" | "rejected")}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="pending">Pending</SelectItem>
@@ -274,7 +275,17 @@ export default function ResourceManager() {
const uploadMutation = useMutation({
mutationFn: (formData: FormData) => resourcesService.upload(formData),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["resources"] }); toast({ title: "Resource uploaded" }); setShowUpload(false); setUploadTagIds([]); setUploadSubjectId("none"); setUploadDomainId("none"); setUploadTopicIds([]); setUploadObjectiveIds([]); },
onError: () => toast({ title: "Error", description: "Upload failed", variant: "destructive" }),
onError: (err) => {
// describeApiError surfaces status code + actionable next step so QA /
// ops can tell 413 (payload limit) from 504 (timeout) from 401 (session
// expired) without opening DevTools. Default "Upload failed" was
// reported as non-actionable during the Apr-2026 deployment.
toast({
title: "Upload failed",
description: describeApiError(err, "Upload failed", "file"),
variant: "destructive",
});
},
});
const createTagMutation = useMutation({

View File

@@ -0,0 +1,215 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
BookOpen,
Layers,
Wand2,
GitBranch,
ClipboardList,
FolderOpen,
Users,
GraduationCap,
Sparkles,
Compass,
ChevronRight,
ArrowRight,
type LucideIcon,
} 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";
/**
* Smart Wizard Hub.
*
* Landing page that groups every step-by-step wizard in a single place.
* Admins pick a scenario → land in the inline Next/Next/Finish flow. The
* page also exposes "Advanced" deep links to the full creation pages for
* power users who don't need hand-holding.
*/
interface WizardCard {
id: string;
to: string;
icon: LucideIcon;
titleKey: string;
descriptionKey: string;
badgeKey?: string;
/** When true, deep-links to the full creation page instead of a wizard. */
advanced?: boolean;
}
const guidedWizards: WizardCard[] = [
{
id: "rubric",
to: "/admin/smart-wizard/rubric",
icon: BookOpen,
titleKey: "wizardHub.cards.rubric.title",
descriptionKey: "wizardHub.cards.rubric.description",
},
{
id: "exam-structure",
to: "/admin/smart-wizard/exam-structure",
icon: Layers,
titleKey: "wizardHub.cards.examStructure.title",
descriptionKey: "wizardHub.cards.examStructure.description",
},
{
id: "course",
to: "/admin/smart-wizard/course",
icon: GraduationCap,
titleKey: "wizardHub.cards.course.title",
descriptionKey: "wizardHub.cards.course.description",
},
{
id: "course-plan",
to: "/admin/smart-wizard/course-plan",
icon: Compass,
titleKey: "wizardHub.cards.coursePlan.title",
descriptionKey: "wizardHub.cards.coursePlan.description",
badgeKey: "wizardHub.aiBadge",
},
];
const advancedLinks: WizardCard[] = [
{
id: "generation",
to: "/admin/generation",
icon: Wand2,
titleKey: "wizardHub.cards.generation.title",
descriptionKey: "wizardHub.cards.generation.description",
advanced: true,
},
{
id: "approval",
to: "/admin/approval-workflows",
icon: GitBranch,
titleKey: "wizardHub.cards.approval.title",
descriptionKey: "wizardHub.cards.approval.description",
advanced: true,
},
{
id: "assign",
to: "/admin/assignments",
icon: ClipboardList,
titleKey: "wizardHub.cards.assign.title",
descriptionKey: "wizardHub.cards.assign.description",
advanced: true,
},
{
id: "resource",
to: "/admin/resources",
icon: FolderOpen,
titleKey: "wizardHub.cards.resource.title",
descriptionKey: "wizardHub.cards.resource.description",
advanced: true,
},
{
id: "student",
to: "/admin/students",
icon: Users,
titleKey: "wizardHub.cards.student.title",
descriptionKey: "wizardHub.cards.student.description",
advanced: true,
},
];
export default function SmartWizardHub() {
const { t } = useTranslation();
return (
<div className="space-y-8">
<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("wizardHub.title")}</h1>
</div>
<p className="text-muted-foreground max-w-3xl">{t("wizardHub.subtitle")}</p>
</div>
{/* Recommended order hint */}
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
<div className="font-medium mb-1">{t("wizardHub.recommendedOrder")}</div>
<ol className="list-decimal list-inside text-muted-foreground space-y-0.5">
<li>{t("wizardHub.order.rubric")}</li>
<li>{t("wizardHub.order.structure")}</li>
<li>{t("wizardHub.order.generate")}</li>
<li>{t("wizardHub.order.approve")}</li>
<li>{t("wizardHub.order.assign")}</li>
</ol>
</div>
{/* Guided wizards */}
<section aria-labelledby="wizard-hub-guided">
<h2 id="wizard-hub-guided" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
{t("wizardHub.guided")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{guidedWizards.map((card) => (
<WizardCardView key={card.id} card={card} />
))}
</div>
</section>
{/* Advanced deep links */}
<section aria-labelledby="wizard-hub-advanced">
<h2 id="wizard-hub-advanced" className="text-sm font-semibold uppercase tracking-wide text-muted-foreground mb-3">
{t("wizardHub.advanced")}
</h2>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{advancedLinks.map((card) => (
<WizardCardView key={card.id} card={card} />
))}
</div>
</section>
</div>
);
}
function WizardCardView({ card }: { card: WizardCard }) {
const { t } = useTranslation();
const Icon = card.icon;
return (
<Card className="transition-all hover:shadow-md hover:-translate-y-0.5">
<CardHeader className="pb-2">
<div className="flex items-center gap-2 flex-wrap">
<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 flex-1 min-w-0">{t(card.titleKey)}</CardTitle>
{card.badgeKey && (
<Badge variant="secondary" className="text-[10px]">
{t(card.badgeKey)}
</Badge>
)}
{card.advanced && (
<Badge variant="outline" className="text-[10px]">
{t("wizardHub.advancedBadge")}
</Badge>
)}
</div>
<CardDescription className="line-clamp-3 min-h-[3em]">
{t(card.descriptionKey)}
</CardDescription>
</CardHeader>
<CardContent>
<Button
asChild
size="sm"
variant={card.advanced ? "secondary" : "default"}
className="w-full"
>
<Link to={card.to} className="flex items-center justify-center gap-1">
{card.advanced ? t("wizardHub.openPage") : t("wizardHub.startWizard")}
{card.advanced ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ArrowRight className="h-3.5 w-3.5" />
)}
</Link>
</Button>
</CardContent>
</Card>
);
}

View File

@@ -50,7 +50,7 @@ export default function TaxonomyManager() {
const [showAddTopic, setShowAddTopic] = useState<number | null>(null);
const [newTopicName, setNewTopicName] = useState("");
const [newTopicDifficulty, setNewTopicDifficulty] = useState("medium");
const [newTopicDifficulty, setNewTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [newTopicHours, setNewTopicHours] = useState("1");
const [editingSubjectId, setEditingSubjectId] = useState<number | null>(null);
@@ -62,7 +62,7 @@ export default function TaxonomyManager() {
const [editingTopicId, setEditingTopicId] = useState<number | null>(null);
const [editTopicName, setEditTopicName] = useState("");
const [editTopicDifficulty, setEditTopicDifficulty] = useState("medium");
const [editTopicDifficulty, setEditTopicDifficulty] = useState<"easy" | "medium" | "hard">("medium");
const [editTopicHours, setEditTopicHours] = useState("1");
const [editTopicDesc, setEditTopicDesc] = useState("");
@@ -142,7 +142,7 @@ export default function TaxonomyManager() {
const startEditTopic = (t: { id: number; name: string; difficulty_level?: string; estimated_hours?: number; description?: string }) => {
setEditingTopicId(t.id);
setEditTopicName(t.name);
setEditTopicDifficulty(t.difficulty_level ?? "medium");
setEditTopicDifficulty((t.difficulty_level as "easy" | "medium" | "hard") ?? "medium");
setEditTopicHours(String(t.estimated_hours ?? 1));
setEditTopicDesc(t.description ?? "");
};
@@ -333,7 +333,7 @@ export default function TaxonomyManager() {
{showAddTopic === domain.id && (
<div className="flex items-center gap-2 p-2 rounded border bg-muted/50">
<Input placeholder="Topic name" value={newTopicName} onChange={e => setNewTopicName(e.target.value)} className="h-8 text-sm" />
<Select value={newTopicDifficulty} onValueChange={setNewTopicDifficulty}>
<Select value={newTopicDifficulty} onValueChange={(v) => setNewTopicDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger className="w-28 h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>
@@ -352,7 +352,7 @@ export default function TaxonomyManager() {
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input value={editTopicName} onChange={e => setEditTopicName(e.target.value)} className="h-7 text-sm flex-1" placeholder="Topic name" />
<Select value={editTopicDifficulty} onValueChange={setEditTopicDifficulty}>
<Select value={editTopicDifficulty} onValueChange={(v) => setEditTopicDifficulty(v as "easy" | "medium" | "hard")}>
<SelectTrigger className="w-28 h-7"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="easy">Easy</SelectItem>

View File

@@ -0,0 +1,373 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { X } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { coursePlanService } from "@/services/coursePlan.service";
import { describeApiError } from "@/lib/api-client";
import type { CoursePlanGenerateBrief } from "@/types";
/**
* AI course-plan generation wizard.
*
* Four steps:
* 1. Basics — title, CEFR level, total weeks, contact hours.
* 2. Coverage — skills division (e.g. "10 hrs Reading+Writing /
* 8 hrs Listening+Speaking"), learner profile.
* 3. Scope — grammar focus chips, resource citations chips,
* optional free-form notes.
* 4. Review — finish → POST /api/ai/course-plan which triggers
* the OpenAI call, persists the plan, and returns
* the finished record; we navigate to the detail
* page so the user can start generating Week 1
* materials immediately.
*/
interface CoursePlanWizardState {
title: string;
cefr_level: string;
total_weeks: number;
contact_hours_per_week: number;
skills_division: string;
learner_profile: string;
grammar_focus: string[];
resources: string[];
notes: string;
}
const CEFR_OPTIONS = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
export default function CoursePlanWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: (brief: CoursePlanGenerateBrief) =>
coursePlanService.generate(brief),
onSuccess: (resp) => {
qc.invalidateQueries({ queryKey: ["course-plans"] });
toast.success(t("coursePlan.generateSuccess"));
const id = resp?.data?.id;
if (id) navigate(`/admin/course-plans/${id}`);
else navigate("/admin/course-plans");
},
onError: (err) => {
toast.error(describeApiError(err, t("coursePlan.generateFailed")));
},
});
const steps: WizardStepDef<CoursePlanWizardState>[] = [
{
id: "basics",
titleKey: "coursePlan.wizard.steps.basics",
descriptionKey: "coursePlan.wizard.steps.basicsDesc",
validate: (s) => {
if (!s.title.trim()) return t("coursePlan.wizard.errors.titleRequired");
if (!s.cefr_level) return t("coursePlan.wizard.errors.cefrRequired");
if (!s.total_weeks || s.total_weeks < 1)
return t("coursePlan.wizard.errors.weeksRange");
return null;
},
render: ({ state, update }) => (
<div className="grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Label htmlFor="title">{t("coursePlan.wizard.fields.title")}</Label>
<Input
id="title"
placeholder="General English 1"
value={state.title}
onChange={(e) => update({ title: e.target.value })}
/>
</div>
<div>
<Label>{t("coursePlan.wizard.fields.cefr")}</Label>
<Select
value={state.cefr_level}
onValueChange={(v) => update({ cefr_level: v })}
>
<SelectTrigger>
<SelectValue placeholder={t("coursePlan.wizard.fields.cefrPlaceholder")} />
</SelectTrigger>
<SelectContent>
{CEFR_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="weeks">{t("coursePlan.wizard.fields.totalWeeks")}</Label>
<Input
id="weeks"
type="number"
min={1}
max={30}
value={state.total_weeks}
onChange={(e) => update({ total_weeks: Number(e.target.value) || 0 })}
/>
</div>
<div>
<Label htmlFor="hours">{t("coursePlan.wizard.fields.contactHours")}</Label>
<Input
id="hours"
type="number"
min={1}
max={40}
value={state.contact_hours_per_week}
onChange={(e) =>
update({ contact_hours_per_week: Number(e.target.value) || 0 })
}
/>
</div>
</div>
),
},
{
id: "coverage",
titleKey: "coursePlan.wizard.steps.coverage",
descriptionKey: "coursePlan.wizard.steps.coverageDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<div>
<Label htmlFor="skills">
{t("coursePlan.wizard.fields.skillsDivision")}
</Label>
<Input
id="skills"
placeholder="10 hrs/wk Reading & Writing + 8 hrs/wk Listening & Speaking"
value={state.skills_division}
onChange={(e) => update({ skills_division: e.target.value })}
/>
<p className="mt-1 text-xs text-muted-foreground">
{t("coursePlan.wizard.fields.skillsDivisionHint")}
</p>
</div>
<div>
<Label htmlFor="profile">
{t("coursePlan.wizard.fields.learnerProfile")}
</Label>
<Textarea
id="profile"
rows={3}
placeholder={t("coursePlan.wizard.fields.learnerProfilePlaceholder")}
value={state.learner_profile}
onChange={(e) => update({ learner_profile: e.target.value })}
/>
</div>
</div>
),
},
{
id: "scope",
titleKey: "coursePlan.wizard.steps.scope",
descriptionKey: "coursePlan.wizard.steps.scopeDesc",
render: ({ state, update }) => (
<div className="grid gap-4">
<ChipInput
label={t("coursePlan.wizard.fields.grammarFocus")}
placeholder={t("coursePlan.wizard.fields.grammarFocusPlaceholder")}
values={state.grammar_focus}
onChange={(next) => update({ grammar_focus: next })}
/>
<ChipInput
label={t("coursePlan.wizard.fields.resources")}
placeholder={t("coursePlan.wizard.fields.resourcesPlaceholder")}
values={state.resources}
onChange={(next) => update({ resources: next })}
/>
<div>
<Label htmlFor="notes">{t("coursePlan.wizard.fields.notes")}</Label>
<Textarea
id="notes"
rows={3}
placeholder={t("coursePlan.wizard.fields.notesPlaceholder")}
value={state.notes}
onChange={(e) => update({ notes: e.target.value })}
/>
</div>
</div>
),
},
{
id: "review",
titleKey: "coursePlan.wizard.steps.review",
descriptionKey: "coursePlan.wizard.steps.reviewDesc",
render: ({ state }) => (
<div className="space-y-3 text-sm">
<ReviewRow label={t("coursePlan.wizard.fields.title")} value={state.title} />
<ReviewRow
label={t("coursePlan.wizard.fields.cefr")}
value={
CEFR_OPTIONS.find((o) => o.value === state.cefr_level)?.label ||
state.cefr_level ||
"—"
}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.totalWeeks")}
value={String(state.total_weeks || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.contactHours")}
value={String(state.contact_hours_per_week || "—")}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.skillsDivision")}
value={state.skills_division || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.learnerProfile")}
value={state.learner_profile || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.grammarFocus")}
value={state.grammar_focus.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.resources")}
value={state.resources.join(", ") || "—"}
/>
<ReviewRow
label={t("coursePlan.wizard.fields.notes")}
value={state.notes || "—"}
/>
<div className="rounded-md border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{t("coursePlan.wizard.reviewHint")}
</div>
</div>
),
},
];
return (
<StepWizard<CoursePlanWizardState>
titleKey="coursePlan.wizard.title"
subtitleKey="coursePlan.wizard.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
submitting={mutation.isPending}
finishLabelKey="coursePlan.wizard.finish"
initialState={{
title: "",
cefr_level: "a2",
total_weeks: 12,
contact_hours_per_week: 18,
skills_division: "",
learner_profile: "",
grammar_focus: [],
resources: [],
notes: "",
}}
onFinish={async (state) => {
await mutation.mutateAsync({
title: state.title.trim(),
cefr_level: state.cefr_level,
total_weeks: state.total_weeks,
contact_hours_per_week: state.contact_hours_per_week,
skills_division: state.skills_division.trim() || undefined,
learner_profile: state.learner_profile.trim() || undefined,
grammar_focus: state.grammar_focus.length
? state.grammar_focus
: undefined,
resources: state.resources.length ? state.resources : undefined,
notes: state.notes.trim() || undefined,
});
}}
/>
);
}
function ReviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-3 gap-2">
<div className="col-span-1 text-muted-foreground">{label}</div>
<div className="col-span-2 font-medium break-words">{value}</div>
</div>
);
}
/**
* Tiny chip input: press Enter (or comma) to commit a value. Used for
* grammar focus items and resource citations. Kept local because it's
* not worth promoting to a reusable component yet.
*/
function ChipInput({
label,
placeholder,
values,
onChange,
}: {
label: string;
placeholder?: string;
values: string[];
onChange: (next: string[]) => void;
}) {
const [draft, setDraft] = useState("");
const commit = () => {
const clean = draft.trim();
if (!clean) return;
if (!values.includes(clean)) onChange([...values, clean]);
setDraft("");
};
return (
<div>
<Label>{label}</Label>
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded-md border px-2 py-2">
{values.map((v, i) => (
<Badge key={`${v}-${i}`} variant="secondary" className="gap-1">
{v}
<button
type="button"
className="ml-1 rounded-sm hover:bg-destructive/20"
onClick={() => onChange(values.filter((_, idx) => idx !== i))}
aria-label={`Remove ${v}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Input
placeholder={placeholder}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
commit();
} else if (e.key === "Backspace" && !draft && values.length) {
onChange(values.slice(0, -1));
}
}}
onBlur={commit}
className="flex-1 border-0 shadow-none focus-visible:ring-0 h-8 min-w-[8rem] px-1"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,271 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { lmsService } from "@/services/lms.service";
import { describeApiError } from "@/lib/api-client";
import type { CourseCreateRequest } from "@/types";
/**
* Course creation wizard (3 steps).
*
* 1. Basics — title, code (auto-derived from title by default), description
* 2. Level & capacity — difficulty, CEFR level, max capacity
* 3. Review → Finish posts to lmsService.createCourse
*
* Mirrors the fields of the existing AdminCourses "New course" dialog but
* surfaced as a guided flow. Fields the admin normally leaves blank
* (topic_ids, tag_ids, subject_id) are omitted here; they can be refined
* on the full course page after the course is created.
*/
interface CourseWizardState {
title: string;
code: string;
description: string;
difficulty_level: "beginner" | "intermediate" | "advanced" | "";
cefr_level: string;
max_capacity: number;
}
const INITIAL: CourseWizardState = {
title: "",
code: "",
description: "",
difficulty_level: "",
cefr_level: "",
max_capacity: 30,
};
/**
* Odoo `op.course.cefr_level` is a Selection field with lowercase keys
* (`pre_a1`, `a1`, `a2`, …). Sending `"A2"` raises
* `Wrong value for op.course.cefr_level` (HTTP 500). We display the
* uppercase label but always submit the lowercase key.
*/
const CEFR_LEVELS: { value: string; label: string }[] = [
{ value: "pre_a1", label: "Pre-A1" },
{ value: "a1", label: "A1" },
{ value: "a2", label: "A2" },
{ value: "b1", label: "B1" },
{ value: "b2", label: "B2" },
{ value: "c1", label: "C1" },
{ value: "c2", label: "C2" },
];
function deriveCode(title: string): string {
return title.trim().toUpperCase().replace(/\s+/g, "-").slice(0, 16);
}
export default function CourseWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: CourseCreateRequest) => lmsService.createCourse(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["lms", "courses"] });
toast.success(t("wizard.course.toastSuccess"));
navigate("/admin/courses");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.course.toastError")));
},
});
const steps: WizardStepDef<CourseWizardState>[] = [
{
id: "basics",
titleKey: "wizard.course.step1.title",
descriptionKey: "wizard.course.step1.description",
validate: (s) => {
if (!s.title.trim()) return t("wizard.course.errors.titleRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="course-title">{t("wizard.course.labels.title")}</Label>
<Input
id="course-title"
value={state.title}
onChange={(e) => update({ title: e.target.value })}
placeholder={t("wizard.course.placeholders.title")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-code">{t("wizard.course.labels.code")}</Label>
<Input
id="course-code"
value={state.code}
onChange={(e) => update({ code: e.target.value })}
placeholder={deriveCode(state.title) || t("wizard.course.placeholders.code")}
/>
<p className="text-xs text-muted-foreground">{t("wizard.course.codeHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-desc">{t("wizard.course.labels.description")}</Label>
<Textarea
id="course-desc"
value={state.description}
onChange={(e) => update({ description: e.target.value })}
placeholder={t("wizard.course.placeholders.description")}
rows={4}
/>
</div>
</div>
),
},
{
id: "level",
titleKey: "wizard.course.step2.title",
descriptionKey: "wizard.course.step2.description",
validate: (s) => {
if (s.max_capacity < 1) return t("wizard.course.errors.capacityRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>{t("wizard.course.labels.difficulty")}</Label>
<Select
value={state.difficulty_level || undefined}
onValueChange={(v) =>
update({ difficulty_level: v as CourseWizardState["difficulty_level"] })
}
>
<SelectTrigger>
<SelectValue placeholder={t("wizard.course.placeholders.difficulty")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="beginner">{t("wizard.course.difficulty.beginner")}</SelectItem>
<SelectItem value="intermediate">
{t("wizard.course.difficulty.intermediate")}
</SelectItem>
<SelectItem value="advanced">{t("wizard.course.difficulty.advanced")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("wizard.course.difficultyHint")}</p>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.course.labels.cefrLevel")}</Label>
<Select
value={state.cefr_level || undefined}
onValueChange={(v) => update({ cefr_level: v })}
>
<SelectTrigger>
<SelectValue placeholder={t("wizard.course.placeholders.cefr")} />
</SelectTrigger>
<SelectContent>
{CEFR_LEVELS.map((lvl) => (
<SelectItem key={lvl.value} value={lvl.value}>
{lvl.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">{t("wizard.course.cefrHint")}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="course-capacity">{t("wizard.course.labels.capacity")}</Label>
<Input
id="course-capacity"
type="number"
min={1}
value={state.max_capacity}
onChange={(e) => update({ max_capacity: Number(e.target.value) || 0 })}
/>
</div>
</div>
),
},
{
id: "review",
titleKey: "wizard.course.step3.title",
descriptionKey: "wizard.course.step3.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.course.labels.title")} value={state.title} />
<ReviewRow
label={t("wizard.course.labels.code")}
value={state.code || deriveCode(state.title)}
/>
{state.description && (
<ReviewRow
label={t("wizard.course.labels.description")}
value={state.description}
wrap
/>
)}
<ReviewRow
label={t("wizard.course.labels.difficulty")}
value={
state.difficulty_level
? t(`wizard.course.difficulty.${state.difficulty_level}`)
: "—"
}
/>
<ReviewRow
label={t("wizard.course.labels.cefrLevel")}
value={
CEFR_LEVELS.find((l) => l.value === state.cefr_level)?.label || "—"
}
/>
<ReviewRow
label={t("wizard.course.labels.capacity")}
value={String(state.max_capacity)}
/>
</div>
),
},
];
return (
<StepWizard<CourseWizardState>
titleKey="wizard.course.title"
subtitleKey="wizard.course.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.course.finish"
onFinish={async (state) => {
const payload: Partial<CourseCreateRequest> = {
title: state.title.trim(),
code: state.code.trim() || deriveCode(state.title),
description: state.description,
max_capacity: state.max_capacity,
difficulty_level: state.difficulty_level || undefined,
cefr_level: state.cefr_level || undefined,
};
await createMut.mutateAsync(payload as CourseCreateRequest);
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -0,0 +1,348 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { examsService } from "@/services/exams.service";
import { describeApiError } from "@/lib/api-client";
import type { ExamModule, ExamStructure } from "@/types";
/**
* Exam Structure creation wizard (4 steps).
*
* 1. Basics — name, industry, exam type (academic/general)
* 2. Modules — which modules this structure covers (multi-select)
* 3. Writing tasks — min-words per task (only if writing module selected)
* 4. Review — summary → Finish posts to /api/exam-structures
*
* The backend expects `modules` as a JSON-serialisable array and `config`
* as an arbitrary JSON object. We only populate writing-task config from
* the wizard; listening/reading/speaking can be refined later on the
* full structure edit page.
*/
interface WritingTaskDraft {
type: string;
min_words: number;
label: string;
}
interface StructureWizardState {
name: string;
industry: string;
exam_type: "academic" | "general";
modules: ExamModule[];
writing_tasks: WritingTaskDraft[];
}
const INITIAL: StructureWizardState = {
name: "",
industry: "",
exam_type: "academic",
modules: ["writing"],
writing_tasks: [
{ type: "task_1", label: "Task 1", min_words: 150 },
{ type: "task_2", label: "Task 2", min_words: 250 },
],
};
const ALL_MODULES: ExamModule[] = ["listening", "reading", "writing", "speaking"];
export default function ExamStructureWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: Partial<ExamStructure>) =>
examsService.createStructure(data as Partial<ExamStructure>),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["exam-structures"] });
toast.success(t("wizard.structure.toastSuccess"));
navigate("/admin/exam-structures");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.structure.toastError")));
},
});
const steps: WizardStepDef<StructureWizardState>[] = [
{
id: "basics",
titleKey: "wizard.structure.step1.title",
descriptionKey: "wizard.structure.step1.description",
validate: (s) => {
if (!s.name.trim()) return t("wizard.structure.errors.nameRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="str-name">{t("wizard.structure.labels.name")}</Label>
<Input
id="str-name"
value={state.name}
onChange={(e) => update({ name: e.target.value })}
placeholder={t("wizard.structure.placeholders.name")}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="str-industry">{t("wizard.structure.labels.industry")}</Label>
<Input
id="str-industry"
value={state.industry}
onChange={(e) => update({ industry: e.target.value })}
placeholder={t("wizard.structure.placeholders.industry")}
/>
<p className="text-xs text-muted-foreground">{t("wizard.structure.industryHint")}</p>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.structure.labels.examType")}</Label>
<Select
value={state.exam_type}
onValueChange={(v) => update({ exam_type: v as StructureWizardState["exam_type"] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="academic">{t("wizard.structure.examTypes.academic")}</SelectItem>
<SelectItem value="general">{t("wizard.structure.examTypes.general")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
),
},
{
id: "modules",
titleKey: "wizard.structure.step2.title",
descriptionKey: "wizard.structure.step2.description",
validate: (s) => {
if (s.modules.length === 0) return t("wizard.structure.errors.moduleRequired");
return null;
},
render: ({ state, update }) => (
<div className="space-y-2">
{ALL_MODULES.map((m) => {
const checked = state.modules.includes(m);
return (
<label
key={m}
className="flex items-start gap-3 rounded-md border p-3 cursor-pointer hover:bg-muted/40"
>
<Checkbox
checked={checked}
onCheckedChange={(v) => {
const next = v
? [...state.modules, m]
: state.modules.filter((x) => x !== m);
update({ modules: next });
}}
/>
<div className="flex-1">
<div className="font-medium">{t(`wizard.structure.modules.${m}.title`)}</div>
<div className="text-xs text-muted-foreground">
{t(`wizard.structure.modules.${m}.description`)}
</div>
</div>
</label>
);
})}
</div>
),
},
{
id: "writing-tasks",
titleKey: "wizard.structure.step3.title",
descriptionKey: "wizard.structure.step3.description",
validate: (s) => {
if (!s.modules.includes("writing")) return null;
if (s.writing_tasks.length === 0) {
return t("wizard.structure.errors.writingTaskRequired");
}
for (const task of s.writing_tasks) {
if (!task.label.trim()) return t("wizard.structure.errors.writingTaskLabel");
if (task.min_words < 1) return t("wizard.structure.errors.writingTaskWords");
}
return null;
},
render: ({ state, update }) => {
if (!state.modules.includes("writing")) {
return (
<p className="text-sm text-muted-foreground">
{t("wizard.structure.writingSkipped")}
</p>
);
}
return (
<div className="space-y-3">
{state.writing_tasks.map((task, i) => (
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
<div className="flex-1 space-y-1.5">
<Label htmlFor={`task-label-${i}`}>
{t("wizard.structure.labels.taskLabel")}
</Label>
<Input
id={`task-label-${i}`}
value={task.label}
onChange={(e) => {
const next = [...state.writing_tasks];
next[i] = { ...task, label: e.target.value };
update({ writing_tasks: next });
}}
/>
</div>
<div className="w-28 space-y-1.5">
<Label htmlFor={`task-words-${i}`}>
{t("wizard.structure.labels.minWords")}
</Label>
<Input
id={`task-words-${i}`}
type="number"
min={1}
value={task.min_words}
onChange={(e) => {
const next = [...state.writing_tasks];
next[i] = { ...task, min_words: Number(e.target.value) || 0 };
update({ writing_tasks: next });
}}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("wizard.structure.removeTask")}
onClick={() => {
update({
writing_tasks: state.writing_tasks.filter((_, idx) => idx !== i),
});
}}
disabled={state.writing_tasks.length === 1}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() =>
update({
writing_tasks: [
...state.writing_tasks,
{
type: `task_${state.writing_tasks.length + 1}`,
label: `Task ${state.writing_tasks.length + 1}`,
min_words: 250,
},
],
})
}
>
<Plus className="mr-1 h-4 w-4" />
{t("wizard.structure.addTask")}
</Button>
</div>
);
},
},
{
id: "review",
titleKey: "wizard.structure.step4.title",
descriptionKey: "wizard.structure.step4.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.structure.labels.name")} value={state.name} />
<ReviewRow
label={t("wizard.structure.labels.industry")}
value={state.industry || "—"}
/>
<ReviewRow
label={t("wizard.structure.labels.examType")}
value={t(`wizard.structure.examTypes.${state.exam_type}`)}
/>
<ReviewRow
label={t("wizard.structure.labels.modules")}
value={state.modules
.map((m) => t(`wizard.structure.modules.${m}.title`))
.join(", ")}
/>
{state.modules.includes("writing") && (
<div>
<div className="font-medium mb-1">{t("wizard.structure.step3.title")}</div>
<ul className="rounded-md border divide-y">
{state.writing_tasks.map((task, i) => (
<li key={i} className="flex items-center justify-between p-2">
<span>{task.label}</span>
<span className="text-muted-foreground tabular-nums">
{task.min_words} {t("wizard.structure.wordsSuffix")}
</span>
</li>
))}
</ul>
</div>
)}
</div>
),
},
];
return (
<StepWizard<StructureWizardState>
titleKey="wizard.structure.title"
subtitleKey="wizard.structure.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.structure.finish"
onFinish={async (state) => {
const config: Record<string, unknown> = {
exam_type: state.exam_type,
};
if (state.modules.includes("writing")) {
config.writing = {
tasks: state.writing_tasks.map((t) => ({
type: t.type,
min_words: t.min_words,
label: t.label,
})),
};
}
await createMut.mutateAsync({
name: state.name.trim(),
industry: state.industry.trim(),
modules: state.modules,
config,
} as unknown as Partial<ExamStructure>);
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[140px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -0,0 +1,320 @@
import { useNavigate } from "react-router-dom";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Plus, Trash2 } from "lucide-react";
import { StepWizard, type WizardStepDef } from "@/components/wizard/StepWizard";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { examsService } from "@/services/exams.service";
import { describeApiError } from "@/lib/api-client";
import type { ExamModule } from "@/types";
/**
* Rubric creation wizard (4 steps).
*
* 1. Basics — name, skill (module), description
* 2. Criteria — rows of name + max score
* 3. Descriptors — per-criterion optional descriptors
* 4. Review — summary then Finish calls examsService.createRubric
*
* Only Writing / Speaking make sense as rubric targets because the other
* two modules (Listening / Reading) are auto-graded; QA flagged this as an
* explicit UX requirement earlier in the project.
*/
interface CriterionDraft {
name: string;
max_score: number;
description: string;
}
interface RubricWizardState {
name: string;
module: ExamModule;
description: string;
criteria: CriterionDraft[];
}
const INITIAL: RubricWizardState = {
name: "",
module: "writing",
description: "",
criteria: [
{ name: "Task Response", max_score: 9, description: "" },
{ name: "Coherence & Cohesion", max_score: 9, description: "" },
],
};
export default function RubricWizard() {
const { t } = useTranslation();
const navigate = useNavigate();
const qc = useQueryClient();
const createMut = useMutation({
mutationFn: (data: Record<string, unknown>) =>
examsService.createRubric(data as never),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["rubrics"] });
toast.success(t("wizard.rubric.toastSuccess"));
navigate("/admin/rubrics");
},
onError: (err: unknown) => {
toast.error(describeApiError(err, t("wizard.rubric.toastError")));
},
});
const steps: WizardStepDef<RubricWizardState>[] = [
{
id: "basics",
titleKey: "wizard.rubric.step1.title",
descriptionKey: "wizard.rubric.step1.description",
validate: (s) => {
if (!s.name.trim()) return t("wizard.rubric.errors.nameRequired");
if (s.module !== "writing" && s.module !== "speaking") {
return t("wizard.rubric.errors.moduleRestricted");
}
return null;
},
render: ({ state, update }) => (
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="rubric-name">{t("wizard.rubric.labels.name")}</Label>
<Input
id="rubric-name"
value={state.name}
onChange={(e) => update({ name: e.target.value })}
placeholder={t("wizard.rubric.placeholders.name")}
/>
</div>
<div className="space-y-1.5">
<Label>{t("wizard.rubric.labels.module")}</Label>
<Select value={state.module} onValueChange={(v) => update({ module: v as ExamModule })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="writing">{t("wizard.rubric.modules.writing")}</SelectItem>
<SelectItem value="speaking">{t("wizard.rubric.modules.speaking")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("wizard.rubric.moduleHint")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="rubric-desc">{t("wizard.rubric.labels.description")}</Label>
<Textarea
id="rubric-desc"
value={state.description}
onChange={(e) => update({ description: e.target.value })}
placeholder={t("wizard.rubric.placeholders.description")}
rows={3}
/>
</div>
</div>
),
},
{
id: "criteria",
titleKey: "wizard.rubric.step2.title",
descriptionKey: "wizard.rubric.step2.description",
validate: (s) => {
if (s.criteria.length === 0) return t("wizard.rubric.errors.criterionRequired");
for (const c of s.criteria) {
if (!c.name.trim()) return t("wizard.rubric.errors.criterionName");
if (c.max_score < 1 || c.max_score > 100) {
return t("wizard.rubric.errors.criterionScore");
}
}
return null;
},
render: ({ state, update }) => (
<div className="space-y-3">
<div className="space-y-2">
{state.criteria.map((c, i) => (
<div key={i} className="flex items-end gap-2 rounded-md border p-3">
<div className="flex-1 space-y-1.5">
<Label htmlFor={`crit-name-${i}`}>{t("wizard.rubric.labels.criterionName")}</Label>
<Input
id={`crit-name-${i}`}
value={c.name}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, name: e.target.value };
update({ criteria: next });
}}
placeholder={t("wizard.rubric.placeholders.criterionName")}
/>
</div>
<div className="w-24 space-y-1.5">
<Label htmlFor={`crit-score-${i}`}>{t("wizard.rubric.labels.maxScore")}</Label>
<Input
id={`crit-score-${i}`}
type="number"
min={1}
max={100}
value={c.max_score}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, max_score: Number(e.target.value) || 0 };
update({ criteria: next });
}}
/>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("wizard.rubric.removeCriterion")}
onClick={() => {
const next = state.criteria.filter((_, idx) => idx !== i);
update({ criteria: next });
}}
disabled={state.criteria.length === 1}
className="text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
update({
criteria: [
...state.criteria,
{ name: "", max_score: 9, description: "" },
],
})
}
>
<Plus className="mr-1 h-4 w-4" />
{t("wizard.rubric.addCriterion")}
</Button>
</div>
),
},
{
id: "descriptors",
titleKey: "wizard.rubric.step3.title",
descriptionKey: "wizard.rubric.step3.description",
// Descriptors are optional — no validation.
render: ({ state, update }) => (
<div className="space-y-3">
{state.criteria.map((c, i) => (
<div key={i} className="space-y-1.5">
<Label htmlFor={`crit-desc-${i}`}>
{c.name || t("wizard.rubric.unnamedCriterion")}
</Label>
<Textarea
id={`crit-desc-${i}`}
value={c.description}
onChange={(e) => {
const next = [...state.criteria];
next[i] = { ...c, description: e.target.value };
update({ criteria: next });
}}
placeholder={t("wizard.rubric.placeholders.descriptor")}
rows={2}
/>
</div>
))}
<p className="text-xs text-muted-foreground">
{t("wizard.rubric.descriptorHint")}
</p>
</div>
),
},
{
id: "review",
titleKey: "wizard.rubric.step4.title",
descriptionKey: "wizard.rubric.step4.description",
render: ({ state }) => (
<div className="space-y-4 text-sm">
<ReviewRow label={t("wizard.rubric.labels.name")} value={state.name} />
<ReviewRow
label={t("wizard.rubric.labels.module")}
value={t(`wizard.rubric.modules.${state.module}`)}
/>
{state.description && (
<ReviewRow
label={t("wizard.rubric.labels.description")}
value={state.description}
wrap
/>
)}
<div>
<div className="font-medium mb-1">
{t("wizard.rubric.step2.title")} ({state.criteria.length})
</div>
<ul className="rounded-md border divide-y">
{state.criteria.map((c, i) => (
<li key={i} className="flex items-start justify-between gap-3 p-2">
<div className="flex-1 min-w-0">
<div className="font-medium">{c.name}</div>
{c.description && (
<div className="text-muted-foreground text-xs mt-0.5">
{c.description}
</div>
)}
</div>
<div className="shrink-0 tabular-nums text-muted-foreground">
{t("wizard.rubric.maxLabel")}: {c.max_score}
</div>
</li>
))}
</ul>
</div>
</div>
),
},
];
return (
<StepWizard<RubricWizardState>
titleKey="wizard.rubric.title"
subtitleKey="wizard.rubric.subtitle"
backTo="/admin/smart-wizard"
steps={steps}
initialState={INITIAL}
submitting={createMut.isPending}
finishLabelKey="wizard.rubric.finish"
onFinish={async (state) => {
await createMut.mutateAsync({
name: state.name.trim(),
module: state.module,
description: state.description.trim(),
criteria: state.criteria.map((c) => ({
name: c.name.trim(),
description: c.description.trim(),
max_score: c.max_score,
})),
});
}}
/>
);
}
function ReviewRow({ label, value, wrap }: { label: string; value: string; wrap?: boolean }) {
return (
<div className="grid grid-cols-[120px_1fr] gap-2">
<div className="text-muted-foreground">{label}</div>
<div className={wrap ? "whitespace-pre-wrap" : ""}>{value || "—"}</div>
</div>
);
}

View File

@@ -166,7 +166,7 @@ export default function AiIeltsCourse() {
const band = Number(targetBand || course?.target_level || 7);
createIelts.mutate(
{
skill: skillsRanked[0]?.skill ?? "writing",
skill: (skillsRanked[0]?.skill ?? "writing") as "reading" | "listening" | "writing" | "speaking",
target_band: Number.isFinite(band) ? band : 7,
},
{

View File

@@ -117,7 +117,7 @@ export default function DiagnosticTest() {
)}
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
Submit Answer <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
</Button>
</CardContent>
</Card>

View File

@@ -78,7 +78,7 @@ export default function ExamResults() {
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
queryKey: ["exam-results", examId],
queryFn: () => examSessionService.getResults(Number(examId)),
queryFn: () => examSessionService.getResults<ExamResultsData>(Number(examId)),
enabled: !!examId,
});

View File

@@ -106,7 +106,7 @@ export default function LearningPlanPage() {
</div>
{(item.status === "available" || item.status === "in_progress") && (
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Button>
)}
{item.status === "completed" && (

View File

@@ -41,7 +41,7 @@ export default function ProficiencyProfile() {
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" /></Button>
</div>
</div>

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