26 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-25 17:13:01 +04:00
Yamen Ahmad
01a32e7785 ci(frontend): add Gitea staging deploy workflow under frontend/ (monorepo mirror of encoach_frontend New_v2 main)
Made-with: Cursor
2026-04-25 11:55:20 +04:00
devops
724edea349 ci: add auto-deploy workflow to staging
All checks were successful
Deploy Frontend to Staging / Deploy frontend to staging (push) Successful in 52s
2026-04-25 09:25:47 +02:00
Yamen Ahmad
45408acd5c feat(ai): LangGraph as core runtime + AI Agents/Tools console + full-demo seed
Core AI runtime
- New encoach.ai.agent + encoach.ai.tool models with M2M tool binding,
  graph topology (simple|plan_review_revise|rag|react), model + fallback,
  temperature, max_tokens, response_format, max_revisions, quality checks
  and system prompt fields.
- services/agent_runtime.py compiles a langgraph.StateGraph per agent
  and caches the build per (key, write_date). Emits a structured trace
  (output, tool_calls, retrieval_hits, revisions, quality_issues,
  ms, model_used, fallback_used) and auto-falls-back on rate-limit/5xx.
- services/agent_tools.py registers 11 tool handlers wrapping existing
  services: resources.search, rubric.fetch, outcomes.fetch,
  student.profile, quality.cefr_check, quality.ai_detect,
  quality.content_gate, course_plan.save (mutates),
  course_plan.save_materials (mutates), scoring.grade_writing,
  scoring.grade_speaking.
- 7 default agents seeded via data/agents_defaults.xml: course_planner,
  course_week_materials, exam_generator, exercise_generator, lms_tutor,
  writing_grader, speaking_grader.
- Feature flag encoach_ai.use_langgraph_runtime (default True).
- encoach_ai_course pipeline now routes through AgentRuntime when on,
  legacy SDK path kept as fallback.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-19 10:58:43 +04:00
d6413a0496 Merge pull request 'docs: update all SRS documents to reflect implemented state' (#2) from docs/update-srs-documents into main
Reviewed-on: #2
2026-04-06 11:02:40 +02:00
4383db7fa1 docs: update all SRS documents to reflect implemented state
- ENCOACH_UNIFIED_SRS.md v2.0: updated header, added 8 new Part VIII-B
  sections (student leave, fees, lessons, gradebook, student progress,
  library, activities, facilities), extended permissions with roles CRUD
  and authority matrix, updated tech specs (93 pages, ~377 API routes,
  41 modules), added implementation traceability to all sections
- ENCOACH_ODOO19_BACKEND_SRS.md v3.0: updated status to implemented,
  added beyond-SRS features section, updated module count to 41,
  endpoint count to ~377
- ODOO_DEVELOPER_HANDOFF.md: rewritten with current repo references
  and implementation status
- ENCOACH_SYSTEM_FEATURES_GUIDE.md: added system features guide
- Superseded notices added to ODOO_BACKEND_SRS_v3.md,
  ODOO_MIGRATION_SRS_v2.md, ODOO_MIGRATION_SRS.md,
  ODOO_MIGRATION_FRONTEND_SRS.md, MATH_IT_ADAPTIVE_LEARNING_SRS.md

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

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

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

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

Made-with: Cursor
2026-03-30 19:40:44 +04:00
164 changed files with 21138 additions and 1731 deletions

View File

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

View File

@@ -18,13 +18,25 @@ server {
root /usr/share/nginx/html;
index index.html;
# Default nginx body cap is 1 MB which causes HTTP 413 on:
# * resource uploads (PDF / audio / video)
# * /api/exam/generation/submit (whole exam with generated questions)
# * /api/approval-requests (attachments)
# Match Odoo's raised limit in odoo-docker.conf (128 MB).
client_max_body_size 128m;
client_body_buffer_size 1m;
location /api/ {
proxy_pass http://backend:8069/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
# OpenAI streaming calls + multi-module AI generation can run >3 min.
# Keep read/send timeouts above Odoo's limit_time_real (900s).
proxy_read_timeout 900s;
proxy_send_timeout 900s;
proxy_request_buffering off;
}
location / {

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

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

View File

@@ -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,178 @@ 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 AdminBranches = lazy(() => import("@/pages/admin/AdminBranches"));
const AdminBatches = lazy(() => import("@/pages/admin/AdminBatches"));
const AdminBatchDetail = lazy(() => import("@/pages/admin/AdminBatchDetail"));
const AdminTimetable = lazy(() => import("@/pages/admin/AdminTimetable"));
const AdminReports = lazy(() => import("@/pages/admin/AdminReports"));
const AdminSettings = lazy(() => import("@/pages/admin/AdminSettings"));
const AdminProfileLms = lazy(() => import("@/pages/admin/AdminProfile"));
const TaxonomyManager = lazy(() => import("@/pages/admin/TaxonomyManager"));
const ResourceManager = lazy(() => import("@/pages/admin/ResourceManager"));
const AcademicYearManager = lazy(() => import("@/pages/admin/AcademicYearManager"));
const DepartmentManager = lazy(() => import("@/pages/admin/DepartmentManager"));
const AdmissionList = lazy(() => import("@/pages/admin/AdmissionList"));
const AdmissionDetail = lazy(() => import("@/pages/admin/AdmissionDetail"));
const AdmissionRegisterPage = lazy(() => import("@/pages/admin/AdmissionRegisterPage"));
const InstitutionalExamSessions = lazy(() => import("@/pages/admin/InstitutionalExamSessions"));
const MarksheetManager = lazy(() => import("@/pages/admin/MarksheetManager"));
const AdminStudentLeave = lazy(() => import("@/pages/admin/AdminStudentLeave"));
const AdminFees = lazy(() => import("@/pages/admin/AdminFees"));
const AdminLessons = lazy(() => import("@/pages/admin/AdminLessons"));
const AdminGradebook = lazy(() => import("@/pages/admin/AdminGradebook"));
const AdminStudentProgress = lazy(() => import("@/pages/admin/AdminStudentProgress"));
const AdminLibrary = lazy(() => import("@/pages/admin/AdminLibrary"));
const AdminActivities = lazy(() => import("@/pages/admin/AdminActivities"));
const AdminFacilities = lazy(() => import("@/pages/admin/AdminFacilities"));
const AdmissionApplication = lazy(() => import("@/pages/AdmissionApplication"));
const SubjectRegistrationPage = lazy(() => import("@/pages/student/SubjectRegistrationPage"));
// Courseware pages
const CourseChapters = lazy(() => import("@/pages/teacher/CourseChapters"));
const ChapterDetail = lazy(() => import("@/pages/teacher/ChapterDetail"));
const AiWorkbench = lazy(() => import("@/pages/teacher/AiWorkbench"));
const TeacherDiscussionBoard = lazy(() => import("@/pages/teacher/TeacherDiscussionBoard"));
const TeacherAnnouncements = lazy(() => import("@/pages/teacher/TeacherAnnouncements"));
const StudentChapterView = lazy(() => import("@/pages/student/StudentChapterView"));
const StudentDiscussionBoard = lazy(() => import("@/pages/student/StudentDiscussionBoard"));
const StudentAnnouncements = lazy(() => import("@/pages/student/StudentAnnouncements"));
const StudentMessages = lazy(() => import("@/pages/student/StudentMessages"));
const StudentJourney = lazy(() => import("@/pages/student/StudentJourney"));
const StudentCoursePlans = lazy(() => import("@/pages/student/StudentCoursePlans"));
const StudentCoursePlanDetail = lazy(() => import("@/pages/student/StudentCoursePlanDetail"));
const AiEnglishCourse = lazy(() => import("@/pages/student/AiEnglishCourse"));
const AiIeltsCourse = lazy(() => import("@/pages/student/AiIeltsCourse"));
const ExamSession = lazy(() => import("@/pages/student/ExamSession"));
const ExamStatus = lazy(() => import("@/pages/student/ExamStatus"));
const ExamResults = lazy(() => import("@/pages/student/ExamResults"));
const GapAnalysis = lazy(() => import("@/pages/student/GapAnalysis"));
const CourseDelivery = lazy(() => import("@/pages/student/CourseDelivery"));
const GradingQueue = lazy(() => import("@/pages/admin/GradingQueue"));
const CourseConfig = lazy(() => import("@/pages/admin/CourseConfig"));
const ModuleBuilder = lazy(() => import("@/pages/admin/ModuleBuilder"));
const CourseProgress = lazy(() => import("@/pages/teacher/CourseProgress"));
const PlacementBriefing = lazy(() => import("@/pages/student/PlacementBriefing"));
const PlacementTest = lazy(() => import("@/pages/student/PlacementTest"));
const PlacementResults = lazy(() => import("@/pages/student/PlacementResults"));
const PlacementAccess = lazy(() => import("@/pages/student/PlacementAccess"));
const FaqManager = lazy(() => import("@/pages/admin/FaqManager"));
const NotificationRules = lazy(() => import("@/pages/admin/NotificationRules"));
const ApprovalWorkflowConfig = lazy(() => import("@/pages/admin/ApprovalWorkflowConfig"));
const RolesPermissions = lazy(() => import("@/pages/admin/RolesPermissions"));
const AuthorityMatrix = lazy(() => import("@/pages/admin/AuthorityMatrix"));
const UserRoles = lazy(() => import("@/pages/admin/UserRoles"));
const BulkStudentUpload = lazy(() => import("@/pages/admin/BulkStudentUpload"));
const CredentialDashboard = lazy(() => import("@/pages/admin/CredentialDashboard"));
const AiEnglishQuality = lazy(() => import("@/pages/admin/AiEnglishQuality"));
const AiEnglishTaxonomy = lazy(() => import("@/pages/admin/AiEnglishTaxonomy"));
const AiIeltsValidation = lazy(() => import("@/pages/admin/AiIeltsValidation"));
const AdaptiveDashboard = lazy(() => import("@/pages/admin/AdaptiveDashboard"));
const AdaptiveStudentDetail = lazy(() => import("@/pages/admin/AdaptiveStudentDetail"));
const LevelMappingConfig = lazy(() => import("@/pages/admin/LevelMappingConfig"));
const WhiteLabelBranding = lazy(() => import("@/pages/admin/WhiteLabelBranding"));
const ScoreApprovalQueue = lazy(() => import("@/pages/admin/ScoreApprovalQueue"));
const ExamTemplateSelection = lazy(() => import("@/pages/admin/ExamTemplateSelection"));
const IeltsExamCreate = lazy(() => import("@/pages/admin/IeltsExamCreate"));
const IeltsSkillConfig = lazy(() => import("@/pages/admin/IeltsSkillConfig"));
const IeltsContentPool = lazy(() => import("@/pages/admin/IeltsContentPool"));
const IeltsExamValidation = lazy(() => import("@/pages/admin/IeltsExamValidation"));
const CustomExamCreate = lazy(() => import("@/pages/admin/CustomExamCreate"));
const ExamReviewQueue = lazy(() => import("@/pages/admin/ExamReviewQueue"));
const ExamReviewDetail = lazy(() => import("@/pages/admin/ExamReviewDetail"));
const AIPromptEditor = lazy(() => import("@/pages/admin/AIPromptEditor"));
const AIFeedbackTriage = lazy(() => import("@/pages/admin/AIFeedbackTriage"));
const PrivacyCenter = lazy(() => import("@/pages/PrivacyCenter"));
const OfficialExamAccess = lazy(() => import("@/pages/OfficialExamAccess"));
const FaqPage = lazy(() => import("@/pages/FaqPage"));
const NotFound = lazy(() => import("@/pages/NotFound"));
function StudentSubscriptionPlaceholder() {
const navigate = useNavigate();
return (
@@ -158,14 +192,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 +255,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 />} />
@@ -216,6 +272,8 @@ const App = () => (
<Route path="/student/placement/access" element={<PlacementAccess />} />
<Route path="/student/exam/:examId/results" element={<ExamResults />} />
<Route path="/student/course/generate" element={<GapAnalysis />} />
<Route path="/student/course-plans" element={<StudentCoursePlans />} />
<Route path="/student/course-plans/:planId" element={<StudentCoursePlanDetail />} />
<Route path="/student/course/ai-english/:courseId" element={<AiEnglishCourse />} />
<Route path="/student/course/ai-ielts/:courseId" element={<AiIeltsCourse />} />
<Route path="/student/course/:courseId" element={<CourseDelivery />} />
@@ -226,6 +284,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 +300,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,18 +311,28 @@ 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 */}
{/* LMS pages (entity-scoped, includes branch management) */}
<Route path="/admin/courses" element={<AdminCourses />} />
<Route path="/admin/students" element={<AdminStudents />} />
<Route path="/admin/teachers" element={<AdminTeachers />} />
<Route path="/admin/branches" element={<AdminBranches />} />
<Route path="/admin/batches" element={<AdminBatches />} />
<Route path="/admin/batches/:id" element={<AdminBatchDetail />} />
<Route path="/admin/timetable" element={<AdminTimetable />} />
<Route path="/admin/reports" element={<AdminReports />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/profile" element={<AdminProfileLms />} />
<Route path="/admin/privacy" element={<PrivacyCenter />} />
{/* Original academic pages */}
<Route path="/admin/assignments" element={<AssignmentsPage />} />
<Route path="/admin/examsList" element={<ExamsListPage />} />
@@ -285,13 +355,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 +413,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 { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -9,10 +10,13 @@ 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";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
@@ -29,115 +33,129 @@ 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.branches", url: "/admin/branches", icon: GitBranch },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
];
const academicItems: NavItem[] = [
{ 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.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 +163,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", branches: "nav.branches", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
generation: "nav.generation", "review-queue": "nav.reviewQueue",
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
feedback: "breadcrumb.feedback",
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
entities: "nav.entities", classrooms: "nav.classrooms",
"student-performance": "nav.studentPerformance",
"stats-corporate": "nav.statsCorporate", record: "nav.record",
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
tickets: "nav.tickets", "settings-platform": "nav.settings",
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
"academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
};
function AppBreadcrumbs() {
const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">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 +224,32 @@ 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 { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
// on the wizard's own Next/Finish footer (real bug repro: trying to
// click "Next" in the Course-plan wizard at the standard 1024×768
// viewport hits the pill instead). The AI Assistant orb also at the
// bottom-right is preserved — it's smaller and useful in-wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -213,13 +265,53 @@ 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">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t("nav.entities")}
</div>
<DropdownMenuSeparator />
{user.entities.map((entity) => (
<DropdownMenuItem
key={entity.id}
onClick={() => {
setSelectedEntityId(entity.id);
// Force page data to refresh against the newly
// selected entity scope.
window.location.reload();
}}
className="flex items-center justify-between gap-2"
>
<span className="truncate">{entity.name}</span>
{selectedEntity?.id === entity.id && (
<Badge variant="secondary" className="text-[10px]">
{t("common.active", "Active")}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<Button variant="ghost" size="icon" 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,37 +324,41 @@ 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"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
{!isWizardRoute && (
<Link
to="/admin/tickets"
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
)}
</SidebarProvider>
);
}
@@ -273,67 +369,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 +450,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 { Outlet, useLocation, Link, useNavigate, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import { AppSidebar } from "@/components/AppSidebar";
import {
@@ -6,14 +7,16 @@ import {
BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Ticket, Settings, User, LogOut, HelpCircle } from "lucide-react";
import { Ticket, Settings, User, LogOut, HelpCircle, Building2, ChevronDown } from "lucide-react";
import React from "react";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { useAuth } from "@/contexts/AuthContext";
const routeLabels: Record<string, string> = {
"dashboard": "Dashboard",
@@ -77,8 +80,26 @@ 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();
const { user, selectedEntity, setSelectedEntityId } = useAuth();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// The pill sits at `fixed bottom-6 right-6` with z-50 and was
// intercepting clicks on the wizard's own Next/Finish footer at most
// viewport heights, blocking users from finishing the flow. The AI
// Assistant orb (also bottom-right) stays — it's smaller and
// genuinely useful inside the wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
return (
<SidebarProvider>
@@ -92,6 +113,34 @@ export default function AppLayout() {
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? "Entity"}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
{user.entities.map((entity) => {
const active = selectedEntity?.id === entity.id;
return (
<DropdownMenuItem
key={entity.id}
onClick={() => setSelectedEntityId(entity.id)}
className="flex items-center justify-between gap-3"
>
<span className="truncate">{entity.name}</span>
{active ? <Badge variant="secondary">Current</Badge> : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button variant="ghost" size="icon" onClick={() => navigate("/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" />
</Button>
@@ -127,19 +176,22 @@ export default function AppLayout() {
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Outlet />
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
{/* Floating help button */}
<Link
to="/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
{!isWizardRoute && (
<Link
to="/tickets"
className="fixed bottom-6 right-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
</Link>
)}
</SidebarProvider>
);
}

View File

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

View File

@@ -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,53 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp, Sparkles,
} from "lucide-react";
/**
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
* resolves via `useTranslation`, so switching language does not require
* re-mounting the layout.
*/
const navGroups: NavGroup[] = [
{
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.myCoursePlans", url: "/student/course-plans", icon: Sparkles },
{ 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

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,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

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

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;

1134
src/i18n/locales/ar.ts Normal file

File diff suppressed because it is too large Load Diff

1196
src/i18n/locales/en.ts Normal file

File diff suppressed because it is too large Load Diff

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

File diff suppressed because it is too large Load Diff

View File

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

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,611 @@
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 AIProviderSettings from "@/pages/admin/AIProviderSettings";
import { AIToolsPanel } from "@/pages/admin/AIToolsPanel";
import type { AIPromptSummary } from "@/types/ai-prompt";
import {
Bot,
CheckCircle2,
FileText,
History,
KeyRound,
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>
<TabsTrigger
value="providers"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md border px-4 py-2"
>
<KeyRound className="me-1 h-4 w-4" />
{t("aiAdmin.tabs.providers", "Providers & Keys")}
</TabsTrigger>
</TabsList>
<TabsContent value="agents" className="mt-2">
<AIAgentsPanel />
</TabsContent>
<TabsContent value="tools" className="mt-2">
<AIToolsPanel />
</TabsContent>
<TabsContent value="prompts" className="mt-2">
<AIPromptsPanel />
</TabsContent>
<TabsContent value="providers" className="mt-2">
<AIProviderSettings />
</TabsContent>
</Tabs>
</div>
);
}

View File

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

View File

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

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

View File

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

File diff suppressed because it is too large Load Diff

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

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

View File

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

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

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

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