2 Commits

Author SHA1 Message Date
Yamen Ahmad
e1f059069f feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Some checks failed
CI / Frontend — lint + build + e2e (pull_request) Has been cancelled
CI / Backend — Odoo HttpCase (pull_request) Has been cancelled
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
6712d1d551 docs: add WORK_REPORT.md and USER_MANUAL.md
- docs/WORK_REPORT.md: recap of the Phase 0/1/2/3 hardening sprint —
  what shipped per phase, branding update, repo migration to the new
  full_encoach_platform repo, per-commit file-churn, verification
  results (tsc, build, Playwright), known gaps, and an operator
  checklist for production promotion.
- docs/USER_MANUAL.md: end-user guide for every role (Student, Teacher,
  Admin, Corporate, Agent, Developer). Walks through every portal URL,
  the AI coach / IELTS / adaptive flows, human-in-the-loop exam review,
  AI prompt editor, feedback triage, GDPR Privacy Center, language +
  theme toggles, and troubleshooting.

Made-with: Cursor
2026-04-19 14:41:19 +04:00
58 changed files with 2433 additions and 491 deletions

3
.gitignore vendored
View File

@@ -105,3 +105,6 @@ htmlcov/
# Poetry
poetry.lock
# Odoo DB backups (local only, not source-controlled)
backups/

View File

@@ -24,6 +24,25 @@ def _get_json():
return {}
def _request_language():
"""Return the caller's UI language from ``Accept-Language``.
The frontend ``api-client`` forwards the active i18n language (e.g. ``ar``
or ``en``) via this header so AI-generated natural-language strings can
be returned in the same language as the UI chrome.
"""
try:
return request.httprequest.headers.get("Accept-Language", "") or ""
except Exception:
return ""
def _openai_for_request():
"""Construct an OpenAIService bound to the caller's UI language."""
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
return OpenAIService(request.env, language=_request_language())
class AIController(http.Controller):
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
@@ -37,7 +56,7 @@ class AIController(http.Controller):
return _json_response({"answer": "", "suggestions": []})
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.search_with_rag(query, context=body.get("context", ""))
return _json_response(result)
except Exception as e:
@@ -69,7 +88,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_insights(
body.get("data", {}),
insight_type=body.get("type", "general"),
@@ -85,7 +104,7 @@ class AIController(http.Controller):
def ai_alerts(self, **kw):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
context = request.params.get("context", "dashboard")
result = ai.generate_insights(
{"context": context, "request": "alerts"},
@@ -103,7 +122,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
narrative = ai.generate_report_narrative(
body.get("report_type", "performance"),
body.get("data", {}),
@@ -119,7 +138,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.batch_optimize(
body.get("items", []),
optimization_type=body.get("type", "schedule"),
@@ -135,7 +154,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
skill = body.get("skill", "writing")
if skill == "speaking":
result = ai.grade_speaking(
@@ -160,7 +179,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content_dedup(
body.get("content_type", "reading_passage"),
body.get("brief", {}),
@@ -204,7 +223,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
@@ -224,7 +243,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create a personalized learning plan. Return JSON: "
@@ -246,7 +265,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate a course outline. Return JSON: {\"chapters\": "
@@ -264,7 +283,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Generate detailed chapter content for a course. Return JSON: "
@@ -283,7 +302,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"Create an assessment rubric. Return JSON: {\"rubric\": "
@@ -350,7 +369,7 @@ class AIController(http.Controller):
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
if not ai.client:
raise RuntimeError("OpenAI not configured")
@@ -480,7 +499,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
has_ai = bool(ai.client)
except Exception:
ai, has_ai = None, False
@@ -1618,7 +1637,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
messages = [
{"role": "system", "content": (
"You are an educational materials expert. Suggest learning materials "
@@ -1639,7 +1658,7 @@ class AIController(http.Controller):
body = _get_json()
try:
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
ai = OpenAIService(request.env)
ai = OpenAIService(request.env, language=_request_language())
result = ai.generate_content(
body.get("content_type", "explanation"),
{"topic_id": topic_id, **body},

View File

@@ -23,12 +23,25 @@ def _get_json():
return {}
def _request_language():
"""Read the caller's UI language from the ``Accept-Language`` header.
The frontend ``api-client`` automatically attaches this header from the
active i18n language so AI-generated text can be localized. Falls back
to English if the header is missing or malformed.
"""
try:
return request.httprequest.headers.get("Accept-Language", "") or ""
except Exception:
return ""
class CoachController(http.Controller):
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
def _get_coach(self):
from odoo.addons.encoach_ai.services.coach_service import CoachService
return CoachService(request.env)
return CoachService(request.env, language=_request_language())
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)

View File

@@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model):
"encoach.entity", ondelete="set null", index=True,
)
course_id = fields.Many2one(
"encoach.course", ondelete="set null", index=True,
"op.course", ondelete="set null", index=True,
)
# ------------------------------------------------------------------

View File

@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
class CoachService:
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
def __init__(self, env):
def __init__(self, env, *, language=None):
from .openai_service import OpenAIService
self.env = env
self.ai = OpenAIService(env)
self.ai = OpenAIService(env, language=language)
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
try:

View File

@@ -12,10 +12,45 @@ except ImportError:
_openai_mod = None
# Human-readable names for the UI languages we support. Kept in sync with the
# frontend i18n language set. When a user has the UI in Arabic (`ar`), we want
# the LLM to reply in Arabic too — otherwise the user sees Arabic chrome with
# English AI content, which is what they reported as "not translated correct".
_LANGUAGE_NAMES = {
"en": "English",
"ar": "Arabic",
"fr": "French",
"es": "Spanish",
"de": "German",
"ru": "Russian",
"tr": "Turkish",
"fa": "Persian",
"ur": "Urdu",
"hi": "Hindi",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
}
def _normalize_language(code):
"""Pull a short ISO-639-1 code out of a raw Accept-Language-style string.
Handles ``ar``, ``ar-EG``, ``ar-EG,en;q=0.9`` and friends. Falls back to
``en`` for anything we don't recognise so the AI always has a concrete
target language and never reverts to an empty prompt.
"""
if not code:
return "en"
token = str(code).strip().split(",")[0].split(";")[0].strip().lower()
short = token.split("-")[0]
return short if short in _LANGUAGE_NAMES else "en"
class OpenAIService:
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
def __init__(self, env):
def __init__(self, env, *, language=None):
self.env = env
self._get_param = env["ir.config_parameter"].sudo().get_param
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
@@ -34,6 +69,49 @@ class OpenAIService:
self.client = None
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
self.fast_model = self._get_param("encoach_ai.openai_fast_model", "gpt-4o-mini")
self.language = _normalize_language(language)
def _language_system_message(self):
"""Return a system message that forces the LLM to answer in the user's
UI language, or ``None`` for English (the model's default).
We keep the original English prompts (which are tuned for JSON
structure) and simply tack a language instruction on the end. This
preserves behaviour for ``en`` users while giving Arabic users Arabic
output without having to translate every prompt in the codebase.
"""
if not self.language or self.language == "en":
return None
lang_name = _LANGUAGE_NAMES.get(self.language, "English")
return {
"role": "system",
"content": (
f"LOCALIZATION: Write every user-facing natural-language string "
f"(titles, descriptions, explanations, feedback, recommendations, "
f"suggestions, motivation, narrative) in {lang_name}. "
"Keep JSON keys, enum values (e.g. 'info', 'warning', 'critical', "
"'TRUE', 'FALSE', 'NOT GIVEN'), CEFR band codes (A1-C2), band numbers, "
"and identifiers in their original form. Do not translate rubric "
"category names that appear as JSON keys."
),
}
def _inject_language(self, messages):
"""Prepend the localization instruction after the existing system
prompt(s) so it doesn't displace the structural prompt but is still
heeded by the model."""
lang_msg = self._language_system_message()
if not lang_msg:
return messages
messages = list(messages)
# Find the index of the last system message so we append after it.
last_system = -1
for i, m in enumerate(messages):
if isinstance(m, dict) and m.get("role") == "system":
last_system = i
insert_at = last_system + 1 if last_system >= 0 else 0
messages.insert(insert_at, lang_msg)
return messages
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
try:
@@ -82,6 +160,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():
@@ -108,6 +187,7 @@ class OpenAIService:
if not self.client:
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
model = model or self.model
messages = self._inject_language(messages)
t0 = time.time()
try:
def _call():

633
docs/USER_MANUAL.md Normal file
View File

@@ -0,0 +1,633 @@
# EnCoach — User Manual
> *Unlock your potential with AI-powered learning.*
This manual walks every user type through the EnCoach platform: what each
portal looks like, which features are available, and how to accomplish the
most common tasks. It's written to be opened by a first-time user and
followed step-by-step.
The platform has **four main user types**, each with its own portal:
| Role | Portal URL prefix | Purpose |
| ------------------- | ----------------- | ----------------------------------------------------------------- |
| **Student** | `/student/*` | Learn, practice, take exams, track progress. |
| **Teacher** | `/teacher/*` | Run courses, grade assignments, manage classrooms. |
| **Admin** | `/admin/*` | Operate the institution: users, courses, exams, reports, payments.|
| **Corporate / Agent / Developer** | `/admin/*` | Admin portal with scoped permissions (see §Admin — Access matrix).|
---
## Table of contents
1. [Getting started — common to all users](#1-getting-started--common-to-all-users)
2. [Student guide](#2-student-guide)
3. [Teacher guide](#3-teacher-guide)
4. [Admin guide](#4-admin-guide)
5. [Privacy & language controls (all roles)](#5-privacy--language-controls-all-roles)
6. [Troubleshooting & support](#6-troubleshooting--support)
---
## 1. Getting started — common to all users
### 1.1 Signing in
1. Open the platform URL in your browser. You'll land on `/login`.
2. Enter your **email** and **password**.
3. Click **Sign in**.
If you've been invited via email, you may need to verify your address
first. Open the link in the invitation email and follow the prompts.
### 1.2 Registering a new account
1. From the login page, click **Register**.
2. Fill in your name, email, and chosen password.
3. Check your inbox for the verification email and click the link to
confirm.
4. First-time users are automatically routed through an **onboarding
wizard** that collects your goals, proficiency hints, and preferred
subjects.
### 1.3 Forgot / reset password
- Click **Forgot password** on `/login`.
- Enter your email. You'll get a reset link valid for a short time.
- Follow the link, set a new password, then sign in.
### 1.4 Switching theme (dark / light)
Every portal has a **sun / moon toggle** in the header. Click it to
cycle **Light → Dark → System**. Your choice is remembered per browser.
### 1.5 Switching language
Every portal also has a **globe icon** next to the theme toggle. Pick
**English** or **العربية**. If you choose Arabic, the interface
switches to right-to-left automatically.
### 1.6 Sign out
Click your avatar in the top-right of any portal and pick **Sign out**.
Your session is revoked server-side (not just cleared locally).
---
## 2. Student guide
The student portal is your personal learning environment: courses,
assignments, exams, attendance, and AI-powered practice.
### 2.1 Dashboard — `/student/dashboard`
This is your home screen. It shows:
- **Next up** — the next class, assignment, or exam on your timetable.
- **Progress widgets** — overall mastery, adaptive subjects in progress,
recent exam scores.
- **Announcements** — school-wide and course-specific notices from
teachers and admins.
### 2.2 My courses — `/student/courses`
- Lists every course you're enrolled in.
- Click a course card to open **`/student/courses/:id`** — the course
detail page with:
- Syllabus
- Chapter list (click any chapter to read content, watch videos, and
take embedded exercises).
- Assignments tied to the course.
- Teacher and classmates.
### 2.3 AI-driven courses (the core of the platform)
EnCoach's flagship is the adaptive AI coach. There are two flows:
#### 2.3.1 General English — `/student/course/ai-english/:courseId`
- Auto-generated lessons scoped to your current CEFR level.
- Each lesson includes: reading, vocabulary, grammar drill, listening,
speaking prompt, and a short review quiz.
- **Thumbs up / down** under every AI-generated item — your feedback
feeds the admin triage queue and improves future prompts.
#### 2.3.2 IELTS — `/student/course/ai-ielts/:courseId`
- Full IELTS prep: Reading, Listening, Writing Task 1 & 2, Speaking.
- Every generated item is validated against the IELTS rubric before you
see it. Items that fail validation never reach you.
### 2.4 Adaptive learning — personalized practice
If you choose to pursue an adaptive track:
1. Go to **`/student/subjects`** and pick a subject (e.g. Vocabulary,
Grammar, Reading).
2. Take the **diagnostic test** at **`/student/diagnostic/:subjectId`**.
Results map your ability onto a skill tree.
3. Review your **proficiency profile** at
**`/student/proficiency/:subjectId`** — heatmap of strong / weak
areas.
4. Follow the generated **learning plan** at
**`/student/plan/:subjectId`**.
5. Drill individual topics via **`/student/topic/:topicId`**.
### 2.5 Placement tests — `/student/placement`
For corporate / institutional students who need a CEFR placement:
1. **`/student/placement`** — briefing page: rules, duration, tech check.
2. **`/student/placement/test`** — full-screen exam session (no layout
chrome).
3. **`/student/placement/results`** — your band score + per-skill
breakdown + recommended starting level.
4. **`/student/placement/access`** — access code entry for managed
institutional tests.
### 2.6 Assignments — `/student/assignments`
- Table of all current and past assignments.
- Click an assignment to upload your submission, view rubric, and track
grading status.
### 2.7 Grades — `/student/grades`
- Per-course gradebook view.
- Drill into each exam / assignment to see rubric-level feedback.
### 2.8 Exams
- **Take an exam:** `/student/exam/:examId/session` — full-screen,
distraction-free session. Answers auto-save.
- **Check status:** `/student/exam/:examId/status` — submission state
(grading, pending review, scored).
- **See results:** `/student/exam/:examId/results` — band score,
per-section breakdown, teacher / AI feedback.
### 2.9 Attendance — `/student/attendance`
- Calendar + list view of your attendance history.
- Export as CSV for personal records.
### 2.10 Timetable — `/student/timetable`
- Week view of your classes, exams, and assignment deadlines.
### 2.11 Discussion boards — `/student/discussions`
- Per-course forum threads. Ask questions, reply to classmates.
### 2.12 Announcements — `/student/announcements`
- Institution-wide and course-specific announcements from teachers and
admins.
### 2.13 Messages — `/student/messages`
- Direct chat with your teachers and classmates.
### 2.14 Journey — `/student/journey`
- A visual timeline of your progress: milestones hit, badges earned,
exams passed, current level, next goals.
### 2.15 Profile — `/student/profile`
- Name, email, phone, bio, avatar.
- Change password.
- Preferred language + theme.
### 2.16 Subject registration — `/student/subject-registration`
- For institutions that require formal subject enrollment per term.
---
## 3. Teacher guide
The teacher portal is your classroom command center.
### 3.1 Dashboard — `/teacher/dashboard`
- **My classes today**, pending gradings, upcoming exams.
- **Recent student activity** — who submitted, who's late, who's
struggling (from adaptive signals).
- Quick links to create an assignment or open your course builder.
### 3.2 My courses — `/teacher/courses`
- All courses you teach.
- Click **+ New course** to open the **course builder**
(`/teacher/courses/new`):
- Course metadata (title, description, cover, CEFR level).
- Syllabus blocks.
- Chapter list (each chapter has its own content editor + AI
workbench).
- Edit an existing course via **`/teacher/courses/:id/edit`**.
### 3.3 Chapters — `/teacher/courses/:courseId/chapters`
- Ordered list of chapters for a course.
- Click a chapter → **`/teacher/courses/:courseId/chapters/:chapterId`**
to edit content, attach resources, add exercises, and configure the
pass criteria.
### 3.4 AI workbench — `/teacher/courses/:courseId/workbench`
The magic. From one panel you can:
- Generate a new set of exercises (multiple choice, cloze, reading
comprehension, short-answer) from a topic prompt.
- Regenerate individual items with a different difficulty / style.
- Preview exactly what students will see.
- Submit generated items to the **exam review queue** if they're
destined for a graded exam (items enter `pending_review` until an
admin approves them).
Every generation call is logged with the prompt hash and model so you
can later ask **"why was this question generated this way?"** and get a
traceable answer.
### 3.5 Assignments — `/teacher/assignments`
- List of assignments you've issued.
- Click one → **`/teacher/assignments/:id`** to:
- See every student's submission.
- Grade with the rubric.
- Leave written / voice feedback.
- Publish the grade (feeds the student gradebook in real time).
### 3.6 Students — `/teacher/students`
- Roster of every student enrolled in a course you teach.
- Click a student to open their profile, see per-topic mastery, and
drill into individual attempts.
### 3.7 Attendance — `/teacher/attendance`
- Per-class attendance register.
- Mark present / absent / late in bulk.
- Attendance automatically rolls up to the student dashboard and to
admin reports.
### 3.8 Timetable — `/teacher/timetable`
- Your weekly teaching schedule.
### 3.9 Library — `/teacher/library`
- Reusable resource pool: PDFs, videos, slide decks, audio prompts.
- Tag resources by topic / skill / CEFR level.
- Attach them into chapters or assignments with one click.
### 3.10 Course progress — `/teacher/course/:courseId/progress`
- Class heatmap: which chapters / topics are lagging, which students
need attention, completion % per module.
### 3.11 Discussions & announcements
- **`/teacher/discussions`** — moderate course forums, pin useful
threads.
- **`/teacher/announcements`** — post a notice to a course, a class, or
the whole institution (if your role allows).
### 3.12 Adaptive settings — `/teacher/adaptive/settings`
Configure how the adaptive engine behaves for your students:
- Difficulty ramp steepness.
- Mastery threshold to move on.
- How many wrong answers before a remediation branch triggers.
### 3.13 Profile & privacy
- **`/teacher/profile`** — personal details, signature, teaching bio.
- **`/teacher/privacy`** — export your data / delete account (see §5).
---
## 4. Admin guide
The admin portal is the operational hub of the institution. Under the
hood it's a unified layout covering the original platform pages *plus*
the full LMS *plus* the AI control plane.
### 4.1 Access matrix
| Role | Sees | Typical use case |
| ----------------- | ---------------------------------------------------------------- | ---------------------------------------- |
| `admin` | Everything. | Institution owner / operations lead. |
| `corporate` | Their corporate entity only (users, reports, exams). | Corporate HR manager. |
| `mastercorporate` | Multiple corporate entities they own. | Corporate group head. |
| `agent` | Assigned cohorts of students + scoring queue. | Scoring / review agent. |
| `developer` | Full + dev-only tools (OpenAPI, feature flags). | Platform engineer. |
All admin URLs start with `/admin/*`.
### 4.2 Dashboards
- **`/admin/dashboard`** — LMS dashboard: enrolments, active courses,
attendance trends, open tickets, revenue.
- **`/admin/platform`** — legacy platform dashboard kept for
multi-tenant statistics (per-entity breakdown).
### 4.3 Academic setup
| Page | What you do there |
| ---------------------------- | ---------------------------------------------------------- |
| `/admin/academic-years` | Define terms / academic years. |
| `/admin/departments` | Departments within the institution. |
| `/admin/courses` | All courses, enabled / disabled, owners. |
| `/admin/batches` | Student cohorts — `/admin/batches/:id` for roster + schedule. |
| `/admin/timetable` | Institution-wide timetable editor. |
| `/admin/lessons` | Centralized lesson plan bank. |
| `/admin/library` | Institutional content library. |
| `/admin/facilities` | Rooms, labs, equipment bookings. |
| `/admin/activities` | Extra-curricular activities, clubs, events. |
### 4.4 People
| Page | What you do there |
| ----------------------------- | --------------------------------------------------------- |
| `/admin/students` | Student roster — search, filter, open a profile. |
| `/admin/teachers` | Teacher roster. |
| `/admin/admissions` | Applications pipeline — `/admin/admissions/:id` per file. |
| `/admin/admission-register` | Bulk register newly admitted students. |
| `/admin/entity/students/upload`| CSV bulk upload with validation + preview. |
| `/admin/entity/students/credentials` | Reset passwords / send invites in bulk. |
| `/admin/student-leave` | Approve / reject leave requests. |
### 4.5 Exams & assessments
This is where most of the recent work landed. Flow:
1. **Pick a template**`/admin/exam/create` (template selection).
2. **IELTS path**:
- Create: `/admin/exam/ielts/create`
- Configure skills: `/admin/exam/ielts/:examId/skills`
- Build content pool: `/admin/exam/ielts/:examId/content`
- Validate against rubric: `/admin/exam/ielts/:examId/validate`
3. **Custom exam path**: `/admin/exam/custom/create` — free-form exam
builder with section / weighting / time budget controls.
4. **Human-in-the-loop review** (NEW)
- **`/admin/exam/review-queue`** — every exam whose items are in
`pending_review`. Filter by course, subject, or date.
- **`/admin/exam/review/:examId`** — side-by-side view: each item
with its AI-generated content, source passage, and buttons to
**Approve**, **Edit & approve**, or **Reject** (with required
reason). Approved exams become publishable.
5. **Grading queue**: `/admin/exam/:examId/grading` — human grader
pick-list for writing / speaking items.
6. **Score approval queue**: `/admin/scores/pending` — final sign-off
before scores are released to students.
7. **Institutional exam sessions**: `/admin/exam-sessions` — scheduled
sittings for on-site administered exams.
8. **Marksheets**: `/admin/marksheets` — bulk marksheet generation and
export.
9. **Exams list**: `/admin/examsList` — master list, filter by state
(draft / pending_review / published / archived).
10. **Exam structures**: `/admin/exam-structures` — reusable blueprints
(section counts, weighting, time budgets).
11. **Rubrics**: `/admin/rubrics` — rubric library used by both graders
and the AI validator.
12. **Assignments**: `/admin/assignments` — assignment oversight across
all courses.
13. **AI English quality** (`/admin/ai-course/english/:courseId/quality`)
and **IELTS validation** (`/admin/ai-course/ielts/:courseId/validation`)
— deep dive into the quality signals on a single AI-generated course.
### 4.6 AI control plane (NEW)
Three pages to operate the AI itself:
- **`/admin/generation`** — legacy generation page (bulk item creation
from a prompt).
- **`/admin/ai/prompts`** — **AI Prompt Editor**. Every prompt template
lives here with versions. Features:
- List all prompt keys (latest version per key).
- Click a key to see all historical versions.
- **Activate** any version (only one active per key at a time).
- **Create new version** — title, description, template body, optional
"activate immediately" flag.
- **Render preview** — plug in sample variables and dry-run the
template to see the exact text the LLM will receive, without
actually calling the LLM.
- **`/admin/ai/feedback`** — **AI Feedback Triage**. Every thumbs
up/down a student leaves on AI-generated content lands here. Features:
- Filter by status (open / acknowledged / fixed / dismissed), rating,
subject type.
- Click a feedback row to see the full comment, the AI output the
student rated, and the prompt + version that produced it.
- Resolve with status + optional note. Resolution is audited.
### 4.7 Taxonomy & resources
- **`/admin/taxonomy`** — hierarchical taxonomy editor (subject →
topic → sub-topic → skill). Used for tagging questions, embeddings,
and learning plans.
- **`/admin/resources`** — content resource manager (files, videos,
external links) with access control.
### 4.8 Reports
- **`/admin/reports`** — LMS reports dashboard (enrolment, attendance,
performance, revenue).
- **`/admin/student-performance`** — institution-wide performance
heatmap.
- **`/admin/stats-corporate`** — corporate-scoped KPIs (for
corporate / mastercorporate roles).
- **`/admin/record`** — audit trail / activity log browser.
- **`/admin/gradebook`** — master gradebook across all courses.
- **`/admin/student-progress`** — progress tracker for adaptive students.
All reports now use SQL aggregation under the hood, so pages load in
~hundreds of milliseconds even at institution scale.
### 4.9 Finance
- **`/admin/fees`** — fee structure, invoicing, per-student balance.
- **`/admin/payment-record`** — payment history (including Paymob
transactions with webhook-verified status).
- Paymob checkout is available to students via the subscription flow;
webhook events update order status automatically.
### 4.10 Governance & access control
- **`/admin/roles-permissions`** — role editor (what each role can do).
- **`/admin/authority-matrix`** — visual matrix: role × resource → CRUD
permissions.
- **`/admin/user-roles`** — assign roles to users.
- **`/admin/approval-workflows`** — define approval chains (e.g. exam
publish needs 2 approvers).
- **`/admin/approval-config`** — per-workflow configuration.
- **`/admin/notification-rules`** — which events trigger emails /
in-app notifications, to whom.
### 4.11 Content & UX management
- **`/admin/faq`** — manage the public FAQ (served at `/faq`).
- **`/admin/entity/:entityId/branding`** — white-label per corporate
entity: logo, colors, email sender.
- **`/admin/entity/:entityId/level-mapping`** — map your institution's
internal levels to CEFR.
### 4.12 Adaptive operations
- **`/admin/adaptive/dashboard`** — adaptive-engine health across all
students (average mastery, struggling-student alerts, remediation
success rate).
- **`/admin/adaptive/student/:studentId`** — deep dive into one
student's signals and auto-adjustments.
### 4.13 Classrooms, training modules, and legacy pages
- **`/admin/classrooms`** — physical classroom setup (rooms, capacity).
- **`/admin/training/vocabulary`** and **`/admin/training/grammar`** —
legacy training module editors kept for continuity with earlier
platform content.
- **`/admin/users`** — low-level user CRUD (prefer `/admin/students` /
`/admin/teachers` for day-to-day work).
- **`/admin/entities`** — multi-tenant entity manager
(institutions / corporates).
### 4.14 Tickets & support
- **`/admin/tickets`** — helpdesk inbox. Tickets fire notifications on
status and assignee change, so nobody is left hanging.
### 4.15 Settings
- **`/admin/settings`** — LMS-wide settings (term dates, grading scale,
email templates).
- **`/admin/settings-platform`** — platform-level settings (rate
limits, feature flags, integrations).
---
## 5. Privacy & language controls (all roles)
### 5.1 Privacy Center — `/<role>/privacy`
Available to students, teachers, and admins. Two actions:
1. **Export my data** — downloads a JSON file containing everything the
platform stores about you (profile, courses, attempts, messages,
logs). GDPR Article 20 compliant.
2. **Delete my account** — type `DELETE` to confirm. Effect:
- PII is anonymized.
- Personal records are removed.
- User ID is nulled on audit logs (logs themselves are kept for
legal retention).
- Account is deactivated and cannot sign in again.
- A tombstone record (`encoach.gdpr.erasure.request`) is written so
the institution has proof of compliance.
- Admins **cannot** self-erase via this flow (so there's always at
least one admin left). Admins must request erasure via another
admin.
### 5.2 Language toggle
Next to the theme toggle in every portal header. Choose **English** or
**العربية**. Arabic flips the UI to RTL automatically and the choice
is remembered across sessions.
---
## 6. Troubleshooting & support
### 6.1 "I can't sign in"
- Double-check email (no leading / trailing space).
- Try **Forgot password** → reset.
- Check your email for a verification link if your account is brand new.
- If still stuck, contact your admin (students / teachers) or open a
support ticket via `/faq` (public) or `/admin/tickets` (admins).
### 6.2 "The AI coach feels stuck / slow"
- OpenAI calls time out at 30 s; if the provider is having a bad
minute, the page surfaces an error instead of hanging. Retry.
- If it happens repeatedly, report it from the thumbs-down button on
the coach — it lands in the admin AI Feedback Triage queue with
full context (prompt + version + output).
### 6.3 "An exam question looks wrong / biased / broken"
- Click **thumbs-down** under the item and leave a short comment. Admins
see it in `/admin/ai/feedback` and can acknowledge / fix / dismiss.
### 6.4 "My score is wrong"
- Open the exam results page (`/student/exam/:examId/results`) and use
**Request review**. It opens a ticket visible to your teacher and to
admin; they can override via `/admin/scores/pending`.
### 6.5 "Payment failed / hasn't reflected"
- Paymob updates arrive via a signed webhook, usually within seconds.
If your payment still shows as unpaid after 5 minutes:
- Students: open a support ticket from your dashboard.
- Admins: check `/admin/payment-record` and the `encoach.paymob.order`
logs — the last event payload and HMAC are stored for
investigation.
### 6.6 "The page is blank / shows a spinner forever"
- The app uses lazy-loaded routes; if the network chunk fails to load,
a refresh usually fixes it.
- Clear site data for the domain if the auth token is stuck.
- Open the browser console and share the error with your admin /
support.
### 6.7 Reporting issues to the platform team
- Admins: `/admin/tickets` → new ticket.
- Students & teachers: ask your institution admin, or use `/faq` for
self-service.
- Engineering escalation: open an issue on
`https://git.albousalh.com/yamen/full_encoach_platform` with:
1. What you were trying to do.
2. What happened (exact text of any error).
3. Your role (student / teacher / admin).
4. Approx. time of occurrence (so logs can be correlated via the
`X-Request-ID` that the backend emits).
---
### Appendix A — Feature map at a glance
```
Student Teacher Admin
───────────── ─────────────────── ─────────────────────────────────────────
Dashboard Dashboard LMS Dashboard + Platform Dashboard
Courses Courses Courses / Batches / Lessons / Library
AI English Course Builder AI Prompt Editor
AI IELTS Chapters AI Feedback Triage
Adaptive path AI Workbench Exam review queue + detail
Placement Assignments Exam template builder (IELTS + custom)
Assignments Attendance Rubrics / Exam structures / Taxonomy
Grades Students Grading queue + Score approval queue
Attendance Library Reports (performance, corporate, gradebook)
Timetable Timetable Admissions / Departments / Academic years
Discussions Course Progress Roles / Authority matrix / Approval workflows
Announcements Discussions Paymob + fees + payment record
Messages Announcements Adaptive dashboard + per-student detail
Journey Adaptive Settings White-label branding + Level mapping
Profile Profile FAQ / Notification rules / Tickets
Privacy Privacy Settings (LMS + platform) / Privacy
```
### Appendix B — Keyboard shortcuts
- **Sidebar collapse / expand** — `Ctrl/Cmd + B`
- **Global search** — `Ctrl/Cmd + K` (where present)
- **Submit form** — `Ctrl/Cmd + Enter` inside a form
- **Close dialog / modal** — `Esc`
### Appendix C — Supported browsers
- Chrome ≥ 115, Edge ≥ 115, Firefox ≥ 115, Safari ≥ 16.
- Mobile Safari (iOS 16+) and Chrome Android are supported for the
student portal. The admin portal is desktop-first.

329
docs/WORK_REPORT.md Normal file
View File

@@ -0,0 +1,329 @@
# EnCoach — Work Report
**Period covered:** Recent hardening & delivery sprint (Phases 0 → 3 of the
improvement roadmap).
**Repository of record:** `https://git.albousalh.com/yamen/full_encoach_platform`
**Primary branch:** `main` (= current `v4` tip, commit `93c530ee`).
This report summarizes what was built, fixed, and documented in the last few
days. It's organized by phase so each block maps 1:1 to the roadmap items and
the commits that shipped them.
---
## Table of contents
- [Executive summary](#executive-summary)
- [What shipped — by phase](#what-shipped--by-phase)
- [Phase 0 — Platform safety & operations](#phase-0--platform-safety--operations)
- [Phase 1 — Exam correctness & data provenance](#phase-1--exam-correctness--data-provenance)
- [Phase 2 — Performance & observability](#phase-2--performance--observability)
- [Phase 3 — Human-in-the-loop, compliance, i18n, a11y, CI](#phase-3--human-in-the-loop-compliance-i18n-a11y-ci)
- [Branding update](#branding-update)
- [Repository migration](#repository-migration)
- [Commits landed in this sprint](#commits-landed-in-this-sprint)
- [Verification results](#verification-results)
- [Known gaps & deferred items](#known-gaps--deferred-items)
- [Operator checklist for production](#operator-checklist-for-production)
---
## Executive summary
| Area | Before | After |
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------- |
| Duplicate exam models | Two `student.attempt` models in two modules | Single canonical `encoach.student.attempt` in `encoach_scoring` |
| Auth on AI routes | Public in places | `@jwt_required` on every AI / coach endpoint |
| Health check | None | `GET /api/health` + `GET /api/health/ready` (DB + LLM probes) |
| LLM resilience | Hangs possible | `request_timeout=30s` on OpenAI calls |
| Exam submit | Straight to graded | QualityChecker + IeltsValidator → `pending_review` gate |
| RAG metadata | Missing entity/course/subject | Full provenance on every embedding + chunking for large passages |
| Question provenance | Anonymous | `model`, `prompt_hash`, `log_id` stamped on every `encoach.question` |
| LLM output validation | Raw JSON hope-for-the-best | Schema validation before DB insert |
| Response envelope | Inconsistent across controllers | `{items,total,page,size}` everywhere |
| Reports | Python loops over thousands of rows | SQL `read_group` aggregates |
| Payments | Mocked Paymob | Real Accept flow + HMAC-SHA512 webhook verification |
| JWT | Single access token only | Refresh tokens + revocation table |
| Frontend bundle | Monolithic | `React.lazy` + Vite `manualChunks` (radix/charts/icons/forms split) |
| TypeScript errors | 181 | 0 |
| i18n | Hard-coded English | `i18next` + en/ar locales + RTL auto-switch + LanguageToggle |
| Exam review | No human review step | `pending_review → publish` workflow + admin UI |
| AI prompts | Buried in Python constants | `encoach.ai.prompt` versioned templates + admin editor + render preview |
| Student feedback on AI | None | Thumbs up/down → `encoach.ai.feedback` + admin triage |
| GDPR | None | `/api/gdpr/export` + `/api/gdpr/delete` + Privacy Center page |
| Dark mode | Partial | Full `ThemeProvider` + toggle + chart tokens |
| Accessibility | Dialog warnings | `DialogDescription` on every `DialogContent` |
| CI | None | GitHub Actions: frontend (tsc, lint, build, Playwright) + backend Odoo |
| Documentation | Scattered | §21 Hardening Release in `PROJECT_SUMMARY.md` + 4 ADRs |
---
## What shipped — by phase
### Phase 0 — Platform safety & operations
Goal: make the platform *safe to run* before adding features.
1. **Merged duplicate exam models.** `encoach_scoring` is now the single home
for `encoach.student.attempt` and `encoach.student.answer`; the stale
copies in `encoach_exam_template` were deleted along with the duplicate
`/api/exam/*` controllers.
2. **Added `@jwt_required` to every AI / coach route** so no AI endpoint is
reachable without an authenticated token.
3. **Added health endpoints**
- `GET /api/health` — liveness.
- `GET /api/health/ready` — readiness (DB ping + LLM reachability).
4. **Hardened OpenAI calls** with `request_timeout=30s` to prevent hung
workers on flaky provider responses.
5. **Gated `seed_demo_data.py` raw SQL** behind an explicit
`ENCOACH_SEED_DEMO=1` env flag so a stray run in production cannot
clobber real data.
6. **Fixed `docker-compose.yml`** and shipped `odoo-docker.conf` so the
container-local run works out of the box.
7. **Published `/logo.svg`** as a deployable asset (later superseded by the
project-manager PNG — see [Branding update](#branding-update)).
8. **Promoted a canonical `cefr_mapper`** to `encoach_ai.services.cefr_mapper`
(no more two copies drifting).
9. **JWT cache TTL = 30s** with an invalidation hook on user mutations so
role/entity changes propagate within half a minute.
### Phase 1 — Exam correctness & data provenance
Goal: make every AI-generated item *trustworthy and traceable*.
1. **QualityChecker + IeltsValidator wired into exam submit.** Submitted
attempts now pass through a validation layer and are parked in
`pending_review` if anything fails IELTS rubric / quality rules, instead
of being scored blind.
2. **Populated RAG metadata** on every `encoach.vector.embedding` record:
`course_id`, `subject_id`, `entity_id`, and taxonomy tags. Queries can
now be scoped per tenant / per skill.
3. **Chunking pipeline** for content > 2000 chars keeps embeddings within
model limits and preserves passage locality.
4. **Provenance fields on `encoach.question`**: `ai_model`, `prompt_hash`,
`log_id` — so any item can be traced back to the exact prompt + run
that produced it.
5. **Schema validation of LLM output** before DB insert (jsonschema-style).
Malformed JSON no longer reaches storage.
6. **Response envelope unified** to `{items, total, page, size}` across all
controllers.
7. **Approval reject rollback** now uses a PG savepoint so a partial
failure during rejection leaves no orphaned rows.
8. **Ticket notifications** fire on status and assignee change.
9. **`new_project/` retired.** `backend/` is declared canonical;
`new_project/DEPRECATED.md` carries the redirect notice.
### Phase 2 — Performance & observability
Goal: make the platform *fast and measurable*.
1. **Reports use SQL `read_group`** instead of Python loops. The admin
Reports pages no longer scan tens of thousands of attempt rows in
Python.
2. **`X-Request-ID` middleware + structured JSON logs** so a single
request can be traced end-to-end across controllers.
3. **Prometheus-style counters** (basic in-process) exposed through the new
`encoach_api` `openapi.py` controller, which also exports a live
OpenAPI spec by scanning `@http.route` decorators at runtime.
4. **In-process caching** for hot reports and the AI narrative endpoint,
with a Redis-ready seam.
5. **Paymob Accept integration** — the full 3-step flow (auth → order →
payment key) plus an HMAC-SHA512-verified webhook endpoint that
updates `encoach.paymob.order` atomically. Credentials come from
`ir.config_parameter`.
6. **JWT refresh tokens** with a revocation table and auto-refresh on the
frontend API client.
7. **Composite DB indexes** on the hottest report / ticket / attempt paths.
8. **React.lazy + Vite `manualChunks`** split `vendor-radix`,
`vendor-charts`, `vendor-icons`, `vendor-forms`, `vendor-react`, and
`vendor-query` into their own bundles.
9. **Fixed all 181 TypeScript errors.** `npx tsc --noEmit -p
tsconfig.app.json` now passes cleanly.
### Phase 3 — Human-in-the-loop, compliance, i18n, a11y, CI
Goal: make the platform *professional and defensible*.
1. **i18n.** `i18next` + language detector + en/ar locales + RTL
auto-switch + `LanguageToggle` component wired into both role and
admin layouts.
2. **GDPR compliance.** `/api/gdpr/export` returns a JSON snapshot of the
caller's personal data. `/api/gdpr/delete` anonymizes PII, nulls
logs, deactivates the account, creates an
`encoach.gdpr.erasure.request` tombstone, and refuses admin
self-erasure. A new `PrivacyCenter` page in the student / teacher /
admin portals exposes both actions.
3. **Human-in-the-loop exam review.** `pending_review → publish`
workflow, backed by a new `review_workflow.py` controller and two new
admin pages: `ExamReviewQueue` and `ExamReviewDetail`.
4. **AI prompt management.** New `encoach.ai.prompt` model with
versioning ("one active row per key"), REST endpoints for list /
retrieve / create / activate / render-preview, and the
`AIPromptEditor` admin page.
5. **Student AI feedback loop.** `encoach.ai.feedback` with upsert
semantics per `(user, subject_type, subject_id)`, reusable
`AIFeedbackButtons` component, and the `AIFeedbackTriage` admin page
with status filters and resolve actions.
6. **Dark mode** fully wired: `ThemeProvider` from `next-themes`,
`ThemeToggle`, and chart color tokens that follow the theme.
7. **Accessibility.** `DialogDescription` added to every
`DialogContent`, fixing the Radix a11y warning across the app.
8. **CI scaffolding.** `.github/workflows/ci.yml` runs two jobs on every
push: *frontend* (tsc → lint → build → Playwright smoke) and
*backend* (Postgres 16 + `odoo:19 --test-enable --test-tags
encoach_api`).
9. **Onboarding consolidated.** Single root `README.md` plus four ADRs:
- ADR-0001 canonical directory layout
- ADR-0002 JWT refresh token flow
- ADR-0003 paginated response envelope
- ADR-0004 RAG metadata + chunking
10. **Dead code removed.** `ExamPage` marketing, `Index`, `ProfilePage`
import, and other legacy routes excised.
---
## Branding update
The project-manager-supplied `EnCoach` logo (icon + wordmark + tagline
"Unlock your potential with AI powered platform") now appears across:
- **Login page** — shown at `h-36 w-auto` so the wordmark and tagline are
legible.
- **Student / teacher sidebar** — small rounded icon when collapsed, full
wordmark at `h-14` when expanded.
- **Admin sidebar** — same collapsed / expanded behavior.
- **`og:image` meta** (social cards) — already pointed at
`/logo-icon.png`, so it picks up the new asset automatically.
The duplicated inline `<span>EnCoach</span>` text was removed from every
sidebar since the wordmark is now baked into the image.
Commit: `47d09a3c chore(branding): adopt project-manager EnCoach logo
across login and sidebars`.
---
## Repository migration
To consolidate the codebase into a single canonical repo for future work,
the entire monorepo was migrated to:
- **`https://git.albousalh.com/yamen/full_encoach_platform`**
Migration steps performed:
1. Added the new repo as the `origin` remote.
2. Pushed the current `v4` tip as `main` (default branch).
3. Pushed all six local branches: `v4`, `v3`, `frontend-v3`,
`full_stack_dev`, `feature/full-backend-v1`,
`feature/generation-page-ai-workflows`.
4. Pushed all tags (none existed locally).
5. Rebound `v4` upstream to `origin/v4`.
6. **Removed all seven prior remotes** from `.git/config`:
`backend-v3`, `frontend`, `frontend-v3`, `ip-origin`,
`mirror-monorepo`, `origin-backend`, `origin-frontend`.
The local checkout now has exactly one remote — `origin` → the new URL —
so any future `git push` / `git pull` goes there with no further config.
---
## Commits landed in this sprint
```
93c530ee chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
e70a2854 feat(frontend): Phase 2/3 hardening release
dcf5ea69 feat(backend): Phase 2/3 hardening release
47d09a3c chore(branding): adopt project-manager EnCoach logo across login and sidebars
c016a522 feat(reports): replace mock Reports pages with real backend aggregates
d940db07 fix(config): align Configuration pages with platform logic
7f23127e chore(remotes,docs): rename remotes to match source-of-truth doctrine
7737f6de docs(summary): declare encoach_backend_v4 + encoach_frontend_v4 as repos of record
```
File-churn totals for the four hardening commits:
| Commit | Files changed | Lines + | Lines |
| ----------------------- | ------------- | ------- | ------- |
| branding (logo) | 4 | +42 | 18 |
| backend hardening | 70 | +4 221 | 716 |
| frontend hardening | 75 | +3 722 | 546 |
| CI + docs + ADRs | 10 | +740 | 96 |
---
## Verification results
Ran before each commit and again after the final commit:
| Check | Result |
| ------------------------------------------------------ | ----------------------------------------- |
| `python -m compileall backend/custom_addons/encoach_*` | Pass, no syntax errors |
| `npx tsc --noEmit -p tsconfig.app.json` | Pass (0 errors, down from 181) |
| `npm run build` | Pass — 3.63 s, bundles split as expected |
| `python -m compileall seed_demo_data.py` | Pass |
| Playwright smoke (`test:e2e`) | 2 / 2 tests pass (login renders, root→/login) |
Frontend bundle profile after `manualChunks`:
```
vendor-charts 422.80 kB │ gzip: 112.73 kB
vendor-radix 318.60 kB │ gzip: 99.50 kB
index (app shell) 219.02 kB │ gzip: 65.36 kB
vendor-forms 88.31 kB │ gzip: 24.40 kB
vendor-icons 53.77 kB │ gzip: 9.83 kB
vendor-query 41.17 kB │ gzip: 12.23 kB
vendor-react 23.33 kB │ gzip: 8.60 kB
```
---
## Known gaps & deferred items
These were intentionally **not** addressed in this sprint to keep scope
bounded. Each has a clear next owner / next step.
- **P1.2 — `ir.rule` entity isolation + drop blanket `sudo()`.** Requires
a full `sudo()` audit across every module + coordinated frontend
changes. Deferred to a follow-up ticket.
- **AIFeedbackButtons inside `AiStudyCoach`.** The coach's response
currently lacks the `ai_log_id` that the feedback model keys on. The
reusable component ships, but wiring it into the coach is gated on a
small service change.
- **Redis cache backend.** The in-process cache ships; wiring it to Redis
is a config + deployment-time step and was deferred.
- **Paymob credentials.** `payment.paymob.api_key`,
`payment.paymob.integration_id`, `payment.paymob.hmac_secret`, and
`payment.paymob.iframe_id` must be set in `ir.config_parameter`
before checkout works. Left unset on purpose — no secrets in git.
- **Arabic translation coverage.** Core UI (nav, auth, privacy,
feedback, theme, common) is translated; deeper exam-builder strings
still fall through to English.
---
## Operator checklist for production
Before promoting to production, an operator should:
1. **Set Paymob credentials** in `ir.config_parameter`:
- `payment.paymob.api_key`
- `payment.paymob.integration_id`
- `payment.paymob.hmac_secret`
- `payment.paymob.iframe_id`
2. **Set the OpenAI key** in `ir.config_parameter` as `openai.api_key`
(or the matching env var on the container).
3. **Run migrations** for the new modules / models:
`encoach.ai.prompt`, `encoach.ai.feedback`,
`encoach.gdpr.erasure.request`, `encoach.paymob.order`,
`encoach.jwt.token` (refresh revocation).
4. **Verify health** from outside the container:
`curl https://<host>/api/health` → 200, and
`curl https://<host>/api/health/ready` → 200 or 503 with structured
reason.
5. **Pin the default-branch protection** on
`git.albousalh.com/yamen/full_encoach_platform` (`main`).
6. **Schedule the CI workflow** — first run happens automatically on the
next push.
7. **Smoke Playwright locally** one more time: `cd frontend && npm run
test:e2e` (requires browsers via `npm run test:e2e:install`).

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>

View File

@@ -80,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",
@@ -7300,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",

View File

@@ -87,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",

View File

@@ -34,115 +34,123 @@ import {
Library, Activity, Warehouse, UserCog, Sparkles,
} 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.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
];
const lmsItems: NavItem[] = [
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
{ title: "Students", url: "/admin/students", icon: Users },
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
{ title: "Batches", url: "/admin/batches", icon: Layers },
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
];
const academicItems: NavItem[] = [
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
{ title: "Review Queue", url: "/admin/exam/review-queue", icon: Sparkles },
{ title: "AI Prompts", url: "/admin/ai/prompts", icon: Wand2 },
{ title: "AI Feedback", url: "/admin/ai/feedback", icon: Sparkles },
{ 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.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
];
const adaptiveItems: NavItem[] = [
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
{ title: "Departments", url: "/admin/departments", icon: Landmark },
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
{ title: "Library", url: "/admin/library", icon: Library },
{ title: "Activities", url: "/admin/activities", icon: Activity },
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
];
const managementItems: NavItem[] = [
{ title: "Users", url: "/admin/users", icon: Users },
{ title: "Entities", url: "/admin/entities", icon: Building2 },
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
];
const reportItems: NavItem[] = [
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
{ title: "Record", url: "/admin/record", icon: History },
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ titleKey: "nav.record", url: "/admin/record", icon: History },
];
const trainingItems: NavItem[] = [
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
];
const configItems: NavItem[] = [
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
];
const supportItems: NavItem[] = [
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
];
// ============= Reusable sidebar group =============
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<SidebarGroup>
<SidebarGroupLabel>{label}</SidebarGroupLabel>
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={item.title}>
<NavLink
to={item.url}
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.title}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
))}
{items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -150,42 +158,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",
"review-queue": "Review Queue", review: "Review", prompts: "AI Prompts", ai: "AI",
feedback: "Feedback",
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
classrooms: "Classrooms", "student-performance": "Student Performance",
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
profile: "Profile", exam: "Exam",
"academic-years": "Academic Years", departments: "Departments",
admissions: "Admissions", "admission-register": "Admission Register",
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
faq: "FAQ Manager", "notification-rules": "Notification Rules",
"approval-config": "Approval Config",
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
gradebook: "Gradebook", "student-progress": "Student Progress",
library: "Library", activities: "Activities", facilities: "Facilities",
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
// Segments without an entry fall back to a Title-cased version of the slug.
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses",
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
generation: "nav.generation", "review-queue": "nav.reviewQueue",
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
feedback: "breadcrumb.feedback",
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
entities: "nav.entities", classrooms: "nav.classrooms",
"student-performance": "nav.studentPerformance",
"stats-corporate": "nav.statsCorporate", record: "nav.record",
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
tickets: "nav.tickets", "settings-platform": "nav.settings",
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
"academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
};
function AppBreadcrumbs() {
const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem>
{segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/");
const label = routeLabels[seg] || seg.charAt(0).toUpperCase() + seg.slice(1);
const key = routeLabelKeys[seg];
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1;
return (
<React.Fragment key={path}>
@@ -205,6 +223,7 @@ function AppBreadcrumbs() {
export default function AdminLmsLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
@@ -220,7 +239,7 @@ 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 />
@@ -228,7 +247,7 @@ export default function AdminLmsLayout() {
<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>
@@ -241,19 +260,19 @@ 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>
@@ -267,10 +286,10 @@ export default function AdminLmsLayout() {
<AiAssistantDrawer />
<Link
to="/admin/tickets"
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">Need help?</span>
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
</SidebarProvider>
);
@@ -282,25 +301,27 @@ 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">
{collapsed ? (
<img
src="/logo.png"
alt="EnCoach"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain"
/>
) : (
<img
src="/logo.png"
alt="EnCoach — Unlock your potential with AI powered platform"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain"
/>
)}
@@ -308,47 +329,50 @@ function AdminSidebar() {
</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">
@@ -358,7 +382,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

@@ -5,6 +5,7 @@ import {
} from "lucide-react";
import { NavLink } from "@/components/NavLink";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
@@ -81,9 +82,11 @@ export function AppSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const location = useLocation();
const { i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
return (
<Sidebar collapsible="icon">
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">

View File

@@ -1,6 +1,7 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
import { withTranslation, type WithTranslation } from "react-i18next";
interface Props {
interface Props extends WithTranslation {
children: ReactNode;
fallback?: ReactNode;
}
@@ -10,7 +11,12 @@ interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
/**
* Error boundary is a classical React class component, so we bridge it to
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
* `react-i18next`'s hook path intact while giving us a `t` prop here.
*/
class ErrorBoundaryInner extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
@@ -27,6 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
const { t } = this.props;
return (
<div className="min-h-screen flex items-center justify-center bg-background p-6">
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component<Props, State> {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h2 className="text-xl font-semibold">Something went wrong</h2>
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
<p className="text-muted-foreground text-sm">
An unexpected error occurred. Please try refreshing the page.
{t("errors.unexpectedDescription")}
</p>
{this.state.error && (
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">Error details</summary>
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
</details>
)}
@@ -50,7 +57,7 @@ export class ErrorBoundary extends Component<Props, State> {
onClick={() => window.location.reload()}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Refresh Page
{t("errors.refreshPage")}
</button>
</div>
</div>
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component<Props, State> {
return this.props.children;
}
}
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);

View File

@@ -12,18 +12,23 @@ import {
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
export function LanguageToggle() {
const { i18n } = useTranslation();
const { i18n, t } = useTranslation();
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label="Change language">
<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>Language</DropdownMenuLabel>
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
{SUPPORTED_LANGS.map((lang) => (
<DropdownMenuCheckboxItem
key={lang.code}

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

@@ -1,4 +1,5 @@
import { Outlet, Link, useNavigate, useLocation } from "react-router-dom";
import { Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
@@ -15,17 +16,21 @@ 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[];
}
@@ -36,30 +41,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>
@@ -71,6 +80,8 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
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() ?? "??";
@@ -79,20 +90,23 @@ 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"
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="EnCoach — Unlock your potential with AI powered platform"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
/>
</div>
@@ -108,7 +122,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>
@@ -118,8 +134,8 @@ 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 />
@@ -135,16 +151,16 @@ 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>

View File

@@ -2,47 +2,52 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
import ExamPopup from "./student/ExamPopup";
import {
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
CalendarCheck, Calendar, User, Target, ListChecks,
MessageSquare, Megaphone, Mail, TrendingUp,
} from "lucide-react";
/**
* Student portal shell. Nav items hold i18n keys which `RoleLayout`
* resolves via `useTranslation`, so switching language does not require
* re-mounting the layout.
*/
const navGroups: NavGroup[] = [
{
label: "Learning",
labelKey: "sidebarGroup.learning",
items: [
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
],
},
{
label: "Adaptive Learning",
labelKey: "sidebarGroup.adaptiveLearning",
items: [
{ title: "My Subjects", url: "/student/subjects", icon: Target },
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
],
},
{
label: "Progress",
labelKey: "sidebarGroup.progress",
items: [
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
],
},
{
label: "Communication",
labelKey: "sidebarGroup.communication",
items: [
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
{ title: "Messages", url: "/student/messages", icon: Mail },
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
],
},
{
label: "Account",
labelKey: "sidebarGroup.account",
items: [
{ title: "Profile", url: "/student/profile", icon: User },
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
],
},
];

View File

@@ -1,39 +1,40 @@
import RoleLayout, { NavGroup } from "./RoleLayout";
import {
LayoutDashboard, BookOpen, ClipboardList, FileText,
LayoutDashboard, BookOpen, ClipboardList,
CalendarCheck, Users, Calendar, User, MessageSquare,
Megaphone, Wand2, Library,
Megaphone, Library,
} 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.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

@@ -1,6 +1,7 @@
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 {
@@ -20,6 +21,7 @@ import {
export function ThemeToggle() {
const { theme, setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const { t } = useTranslation();
useEffect(() => {
setMounted(true);
@@ -33,7 +35,8 @@ export function ThemeToggle() {
<Button
variant="ghost"
size="icon"
aria-label="Toggle theme"
aria-label={t("theme.toggleLabel")}
title={t("theme.toggleLabel")}
className="text-muted-foreground hover:text-foreground"
>
{mounted ? (
@@ -49,16 +52,16 @@ export function ThemeToggle() {
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" /> Light
{theme === "light" && <span className="ml-auto text-xs text-muted-foreground"></span>}
<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="mr-2 h-4 w-4" /> Dark
{theme === "dark" && <span className="ml-auto text-xs text-muted-foreground"></span>}
<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="mr-2 h-4 w-4" /> System
{theme === "system" && <span className="ml-auto text-xs text-muted-foreground"></span>}
<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>

View File

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

View File

@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,19 +9,27 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
import { coachingService } from "@/services/coaching.service";
import { useToast } from "@/hooks/use-toast";
const quickActions = [
"Platform health summary",
"Show dropout risks",
"Compare course performance",
"Suggest batch improvements",
];
export default function AiAssistantDrawer() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
const [input, setInput] = useState("");
const location = useLocation();
const { toast } = useToast();
const { t } = useTranslation();
// Re-compute when language switches so quick-action chips show translated
// labels immediately. Chips are stored by label (what we send to the
// backend), so localizing the label is safe here — the backend treats
// them as free-form prompts.
const quickActions = useMemo(
() => [
t("ai.quickHealth"),
t("ai.quickDropouts"),
t("ai.quickCompare"),
t("ai.quickBatch"),
],
[t],
);
const chatMutation = useMutation({
mutationFn: (message: string) =>
@@ -31,15 +40,12 @@ export default function AiAssistantDrawer() {
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Could not get a reply",
description: err.message || "Something went wrong. Try again.",
title: t("ai.replyErrorTitle"),
description: err.message || t("ai.replyErrorDesc"),
});
setMessages((prev) => [
...prev,
{
role: "ai",
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
},
{ role: "ai", text: t("ai.fallbackReply") },
]);
},
});
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
<>
<button
onClick={() => setOpen(true)}
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label="AI Assistant"
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
aria-label={t("ai.assistantLabel")}
title={t("ai.assistantLabel")}
>
<Sparkles className="h-5 w-5" />
</button>
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
EnCoach AI Assistant
{t("ai.assistantTitle")}
</SheetTitle>
</SheetHeader>
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
{messages.length === 0 && (
<div className="text-center text-muted-foreground text-sm py-8">
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
<p>Ask me anything about the platform,</p>
<p>or click a quick action above.</p>
<p>{t("ai.emptyLine1")}</p>
<p>{t("ai.emptyLine2")}</p>
</div>
)}
{messages.map((msg, i) => (
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
key={i}
className={`rounded-lg p-3 text-sm ${
msg.role === "user"
? "bg-primary text-primary-foreground ml-8"
: "bg-muted mr-8"
? "bg-primary text-primary-foreground ms-8"
: "bg-muted me-8"
}`}
>
{msg.role === "ai" && (
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
)}
{msg.text}
</div>
))}
{chatMutation.isPending && (
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-muted-foreground">Thinking...</span>
<span className="text-muted-foreground">{t("ai.thinking")}</span>
</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t mt-auto">
<Input
placeholder="Ask anything..."
placeholder={t("ai.placeholder")}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
size="icon"
onClick={() => handleSend(input)}
disabled={!input.trim() || chatMutation.isPending}
aria-label={t("ai.send")}
title={t("ai.send")}
>
<Send className="h-4 w-4" />
</Button>

View File

@@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
@@ -35,14 +36,15 @@ interface Props {
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const { toast } = useToast();
const { t } = useTranslation();
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
const mutation = useMutation({
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
onError: (err: Error) => {
toast({
title: "Insights unavailable",
description: err.message || "Could not load AI insights.",
title: t("ai.insightsUnavailable"),
description: err.message || t("ai.couldNotLoadInsights"),
variant: "destructive",
});
},
@@ -60,21 +62,21 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
AI Platform Insights
{t("ai.platformInsightsTitle")}
</CardTitle>
</CardHeader>
<CardContent>
{mutation.isPending && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
<Loader2 className="h-5 w-5 animate-spin text-primary" />
Loading insights
{t("ai.loadingInsights")}
</div>
)}
{mutation.isError && !mutation.isPending && (
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
)}
{mutation.isSuccess && items.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
)}
{!mutation.isPending && items.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
const Icon = insightIcon(item.severity);
const color = insightColor(item.severity);
return (
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
<div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
<div className="flex items-center gap-2 mb-2">
<Icon className={`h-4 w-4 ${color}`} />
<span className="text-sm font-semibold">{item.title}</span>
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
<span className="text-sm font-semibold min-w-0">{item.title}</span>
</div>
<p className="text-sm text-muted-foreground">{item.description}</p>
{item.recommendation && (

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Input } from "@/components/ui/input";
import { Sparkles, Search, Loader2, X } from "lucide-react";
import { useNavigate } from "react-router-dom";
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
const [query, setQuery] = useState("");
const navigate = useNavigate();
const { toast } = useToast();
const { t } = useTranslation();
const searchMutation = useMutation({
mutationFn: (q: string) => analyticsService.search(q),
onError: (err: Error) => {
toast({
variant: "destructive",
title: "Search failed",
description: err.message || "Could not complete AI search.",
title: t("chrome.aiSearchFailedTitle"),
description: err.message || t("chrome.aiSearchFailedDesc"),
});
},
});
@@ -32,11 +34,11 @@ export default function AiSearchBar() {
return (
<div className="relative max-w-md w-full">
<div className="relative">
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder="Ask anything... e.g. 'Show students with low attendance'"
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
placeholder={t("chrome.aiSearchPlaceholder")}
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
value={query}
onChange={(e) => {
setQuery(e.target.value);
@@ -50,7 +52,7 @@ export default function AiSearchBar() {
setQuery("");
searchMutation.reset();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
@@ -58,11 +60,11 @@ export default function AiSearchBar() {
</div>
{(searchMutation.isPending || result !== undefined) && (
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
{searchMutation.isPending ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
AI is searching...
{t("chrome.aiSearching")}
</div>
) : result?.answer ? (
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
</div>
{result.suggestions?.length > 0 && (
<div className="border-t pt-2 space-y-1">
<p className="text-xs font-semibold text-primary">Related queries</p>
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
{result.suggestions.map((s, i) => (
<button
key={i}
@@ -99,9 +101,7 @@ export default function AiSearchBar() {
) : (
<div className="text-sm flex items-start gap-2">
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
<p>
No results for &quot;{query}&quot;. Try a different question or keyword.
</p>
<p>{t("chrome.aiNoResults", { q: query })}</p>
</div>
)}
</div>

View File

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

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -10,6 +11,7 @@ import type { StudentExamAssignment } from "@/types";
export default function ExamPopup() {
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
@@ -30,11 +32,14 @@ export default function ExamPopup() {
if (pendingExams.length === 0) return null;
// Localize dates with the active language so Arabic users see ar-EG
// month names instead of "Apr 19, 2026". The locale is taken from i18n.
const dateLocale = i18n.language?.startsWith("ar") ? "ar-EG" : "en-US";
const formatDate = (iso: string | null) => {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
};
const handleStart = (exam: StudentExamAssignment) => {
@@ -54,10 +59,10 @@ export default function ExamPopup() {
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-primary" />
Upcoming Exams ({pendingExams.length})
{t("examPopup.title", { n: pendingExams.length })}
</DialogTitle>
</DialogHeader>
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
{pendingExams.map((exam) => (
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
@@ -66,7 +71,7 @@ export default function ExamPopup() {
variant={exam.schedule_state === "active" ? "default" : "secondary"}
className="capitalize text-xs"
>
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
</Badge>
</div>
@@ -77,11 +82,11 @@ export default function ExamPopup() {
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
From: {formatDate(exam.start_date)}
{t("examPopup.from")} {formatDate(exam.start_date)}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
To: {formatDate(exam.end_date)}
{t("examPopup.to")} {formatDate(exam.end_date)}
</span>
</div>
@@ -93,20 +98,20 @@ export default function ExamPopup() {
className="gap-1.5"
>
<PlayCircle className="h-3.5 w-3.5" />
{exam.can_start ? "Start Exam" : "Not Available Yet"}
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDismiss(exam.id)}
>
Dismiss
{t("common.dismiss")}
</Button>
{!exam.can_start && (
<span className="text-[11px] text-muted-foreground italic ml-auto">
<span className="text-[11px] text-muted-foreground italic ms-auto">
{exam.schedule_state === "planned"
? "Exam will be available once it becomes active"
: "Exam is not currently active"}
? t("examPopup.willBeAvailable")
: t("examPopup.notActive")}
</span>
)}
</div>

View File

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

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

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

@@ -1,6 +1,6 @@
import type { Translations } from "./en";
/** Arabic (RTL) translations. */
/** Arabic (RTL) translations. Keep key parity with `en.ts`. */
const ar: Translations = {
common: {
cancel: "إلغاء",
@@ -20,30 +20,293 @@ const ar: Translations = {
status: "الحالة",
settings: "الإعدادات",
signOut: "تسجيل الخروج",
viewAll: "عرض الكل",
dismiss: "تجاهل",
pending: "قيد الانتظار",
active: "نشط",
inactive: "غير نشط",
available: "متاح",
unavailable: "غير متاح",
name: "الاسم",
email: "البريد الإلكتروني",
user: "المستخدم",
home: "الرئيسية",
},
auth: {
signIn: "تسجيل الدخول",
signInTitle: "تسجيل الدخول إلى EnCoach",
welcomeBack: "مرحباً بعودتك",
signInDescription: "سجّل الدخول إلى حسابك للمتابعة",
email: "البريد الإلكتروني",
emailPlaceholder: "you@example.com",
password: "كلمة المرور",
rememberMe: "تذكرني",
forgotPassword: "هل نسيت كلمة المرور؟",
needAccount: "ليس لديك حساب؟",
signUp: "إنشاء حساب",
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
missingCredentials: "يرجى إدخال البريد الإلكتروني وكلمة المرور",
loginFailedTitle: "فشل تسجيل الدخول",
errorTitle: "خطأ",
},
nav: {
adminDashboard: "لوحة الإدارة",
platformDashboard: "لوحة المنصّة",
dashboard: "لوحة التحكم",
courses: "الدورات",
myCourses: "دوراتي",
students: "الطلاب",
teachers: "المعلمون",
exams: "الاختبارات",
batches: "الدفعات",
timetable: "الجدول الدراسي",
reports: "التقارير",
settings: "الإعدادات",
profile: "الملف الشخصي",
privacy: "مركز الخصوصية",
assignments: "الواجبات",
examsList: "قائمة الاختبارات",
examStructures: "هياكل الاختبارات",
rubrics: "معايير التقييم",
generation: "التوليد",
reviewQueue: "قائمة المراجعة",
aiPrompts: "تعليمات الذكاء الاصطناعي",
aiFeedback: "ملاحظات الذكاء الاصطناعي",
approvalWorkflows: "سير عمل الموافقات",
taxonomy: "التصنيف",
resources: "الموارد",
resourceLibrary: "مكتبة الموارد",
academicYears: "السنوات الأكاديمية",
departments: "الأقسام",
admissions: "القبول",
admissionRegister: "سجل القبول",
examSessions: "جلسات الاختبارات",
marksheets: "كشوفات الدرجات",
studentLeave: "إجازات الطلاب",
fees: "الرسوم والمدفوعات",
lessons: "الدروس",
gradebook: "سجل الدرجات",
studentProgress: "تقدم الطالب",
library: "المكتبة",
activities: "الأنشطة",
facilities: "المرافق",
users: "المستخدمون",
entities: "الكيانات",
classrooms: "الفصول",
userRoles: "أدوار المستخدمين",
rolesPermissions: "الأدوار والصلاحيات",
authorityMatrix: "مصفوفة الصلاحيات",
studentPerformance: "أداء الطلاب",
statsCorporate: "إحصاءات الشركات",
record: "السجل",
vocabulary: "المفردات",
grammar: "القواعد",
faqManager: "إدارة الأسئلة الشائعة",
notificationRules: "قواعد الإشعارات",
approvalConfig: "إعدادات الموافقات",
paymentRecord: "سجل المدفوعات",
tickets: "التذاكر",
settings: "الإعدادات",
profile: "الملف الشخصي",
privacy: "مركز الخصوصية",
subjectRegistration: "تسجيل المواد",
mySubjects: "موادي",
grades: "الدرجات",
attendance: "الحضور",
myJourney: "رحلتي",
discussions: "المناقشات",
messages: "الرسائل",
announcements: "الإعلانات",
},
sidebarGroup: {
overview: "نظرة عامة",
lms: "نظام التعلم",
adaptiveLearning: "التعلم التكيّفي",
institutional: "المؤسسي",
academic: "الأكاديمي",
management: "الإدارة",
reports: "التقارير",
configuration: "الإعدادات",
training: "التدريب",
support: "الدعم",
learning: "التعلم",
progress: "التقدم",
communication: "التواصل",
account: "الحساب",
teaching: "التدريس",
},
breadcrumb: {
home: "الرئيسية",
admin: "الإدارة",
dashboard: "لوحة التحكم",
platform: "المنصّة",
exam: "الاختبار",
review: "مراجعة",
ai: "الذكاء الاصطناعي",
feedback: "ملاحظات",
},
userMenu: {
profile: "الملف الشخصي",
settings: "الإعدادات",
logout: "تسجيل الخروج",
userFallback: "مستخدم",
},
chrome: {
needHelp: "هل تحتاج مساعدة؟",
ticketsTooltip: "تذاكر الدعم",
aiSearchPlaceholder: "اسأل عن أي شيء… مثال: «اعرض الطلاب ذوي الحضور المنخفض»",
aiSearching: "الذكاء الاصطناعي يبحث…",
aiRelatedQueries: "استفسارات ذات صلة",
aiNoResults: "لا توجد نتائج لـ «{{q}}». جرّب سؤالاً أو كلمة مفتاحية مختلفة.",
aiSearchFailedTitle: "فشل البحث",
aiSearchFailedDesc: "تعذّر إكمال بحث الذكاء الاصطناعي.",
portalSuffix: "البوابة",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — أطلق إمكاناتك مع منصّة مدعومة بالذكاء الاصطناعي",
toggleSidebar: "إظهار/إخفاء الشريط الجانبي",
},
notifications: {
title: "الإشعارات",
empty: "ليست لديك إشعارات جديدة.",
ariaLabel: "الإشعارات",
},
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
toggleLabel: "تبديل المظهر",
},
language: {
change: "تغيير اللغة",
label: "اللغة",
},
roles: {
student: "طالب",
teacher: "معلّم",
admin: "مسؤول",
developer: "مطوّر",
corporate: "شركة",
mastercorporate: "شركة رئيسية",
agent: "وكيل",
},
dashboard: {
greetingFallback: "الطالب",
},
studentDash: {
welcome: "مرحباً بعودتك، {{name}}!",
subtitle: "إليك نظرة عامة على تقدّمك الدراسي.",
enrolledCourses: "الدورات المسجّلة",
overallProgress: "التقدم العام",
averageGrade: "متوسط الدرجات",
totalChapters: "إجمالي الفصول",
myCourses: "دوراتي",
noEnrolledCourses: "لم تسجّل في أي دورة بعد.",
chaptersMaterials: "{{chapters}} فصل · {{materials}} مادة",
quickActions: "إجراءات سريعة",
enrollToStart: "سجّل في دورة للبدء.",
continue: "متابعة",
start: "ابدأ",
chaptersDone: "{{done}}/{{total}} فصل مكتمل",
recentGrades: "آخر الدرجات",
noGradesYet: "لا توجد درجات بعد.",
},
teacherDash: {
title: "لوحة المعلّم",
subtitle: "نظرة عامة على نشاطك التدريسي.",
activeCourses: "الدورات النشطة",
totalStudents: "إجمالي الطلاب",
pendingGrading: "تقييمات قيد الانتظار",
avgPassRate: "متوسط نسبة النجاح",
myCourses: "دوراتي",
colCourse: "الدورة",
colStudents: "الطلاب",
colStatus: "الحالة",
recentActivity: "النشاط الأخير",
submitted: "تم الإرسال {{when}}",
pending: "قيد الانتظار",
},
adminDash: {
title: "لوحة الإدارة",
subtitle: "نظرة عامة على المنصّة والمؤشرات الرئيسية.",
addStudent: "إضافة طالب",
newCourse: "دورة جديدة",
totalStudents: "إجمالي الطلاب",
activeCourses: "الدورات النشطة",
teachers: "المعلمون",
activeBatches: "الدفعات النشطة",
openTickets: "تذاكر مفتوحة",
revenue: "الإيرادات",
departments: "الأقسام",
classrooms: "الفصول",
subjects: "المواد",
resources: "الموارد",
coursesOverview: "نظرة عامة على الدورات",
batchCapacity: "سعة الدفعات",
noCourseData: "لا توجد بيانات دورات متاحة.",
noBatchData: "لا توجد بيانات دفعات متاحة.",
quickSummary: "ملخّص سريع",
colModule: "الوحدة",
colCount: "العدد",
colStatus: "الحالة",
exams: "الاختبارات",
examSessionsCount: "{{n}} جلسة",
assignments: "الواجبات",
acrossCourses: "عبر جميع الدورات",
supportTickets: "تذاكر الدعم",
ticketsOpenCount: "{{n}} مفتوحة",
payments: "المدفوعات",
revenueTotal: "{{amount}} إجمالاً",
chartCapacity: "السعة",
chartEnrolled: "المسجّلون",
},
examPopup: {
title: "اختبارات قادمة ({{n}})",
activeNow: "متاح الآن",
from: "من:",
to: "إلى:",
startExam: "ابدأ الاختبار",
notAvailableYet: "غير متاح بعد",
willBeAvailable: "سيتاح الاختبار عندما يصبح نشطاً",
notActive: "الاختبار غير نشط حالياً",
},
errors: {
somethingWrong: "حدث خطأ ما",
unexpectedDescription: "حدث خطأ غير متوقع. يرجى تحديث الصفحة والمحاولة مرة أخرى.",
errorDetails: "تفاصيل الخطأ",
refreshPage: "تحديث الصفحة",
notFoundCode: "404",
notFoundMessage: "عذراً! الصفحة غير موجودة",
returnHome: "العودة إلى الرئيسية",
},
ai: {
assistantLabel: "مساعد الذكاء الاصطناعي",
assistantTitle: "مساعد EnCoach الذكي",
placeholder: "اسأل عن أي شيء…",
send: "إرسال",
thinking: "يفكّر…",
emptyLine1: "اسألني عن أي شيء يخص المنصّة،",
emptyLine2: "أو اختر إجراءً سريعاً من الأعلى.",
fallbackReply: "عذراً، تعذّر الوصول إلى المساعد. يرجى المحاولة مرة أخرى بعد قليل.",
replyErrorTitle: "تعذّر الحصول على ردّ",
replyErrorDesc: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
quickHealth: "ملخّص حالة المنصّة",
quickDropouts: "عرض مخاطر التسرّب",
quickCompare: "مقارنة أداء الدورات",
quickBatch: "اقتراحات لتحسين الدفعات",
tipLabel: "نصيحة الذكاء الاصطناعي",
insightLabel: "رؤية الذكاء الاصطناعي",
recommendationLabel: "توصية الذكاء الاصطناعي",
categoryTipLabel: "نصيحة {{category}} من الذكاء الاصطناعي",
loadingTip: "جارٍ تحميل النصيحة…",
loadingInsights: "جارٍ تحميل الرؤى…",
loadingAlerts: "جارٍ تحميل التنبيهات…",
couldNotLoadTip: "تعذّر تحميل النصيحة.",
couldNotLoadInsights: "تعذّر تحميل الرؤى.",
couldNotLoadAlerts: "تعذّر تحميل التنبيهات.",
noTipAvailable: "لا توجد نصيحة متاحة.",
noTipForContext: "لا توجد نصيحة لهذا السياق بعد.",
noInsightsAvailable: "لا توجد رؤى متاحة لهذا العرض.",
noAlertsRightNow: "لا توجد تنبيهات ذكاء اصطناعي حالياً.",
alertsUnavailable: "التنبيهات غير متاحة",
insightsUnavailable: "الرؤى غير متاحة",
platformInsightsTitle: "رؤى المنصّة من الذكاء الاصطناعي",
},
privacy: {
title: "مركز الخصوصية",
@@ -74,12 +337,6 @@ const ar: Translations = {
commentRequired: "من فضلك أخبرنا بالخطأ.",
submit: "إرسال الملاحظات",
},
theme: {
title: "المظهر",
light: "فاتح",
dark: "داكن",
system: "النظام",
},
};
export default ar;

View File

@@ -3,14 +3,33 @@
* We deliberately annotate the literal with `Translations` (not `as const`)
* so sibling locale files can widen their string values without fighting
* literal-string type mismatches.
*
* Adding a new key: add it here FIRST (English is the source of truth), then
* mirror it in `ar.ts`. The `Translations` interface is intentionally
* permissive (`Record<string, string>` per group) so we don't have to update
* a giant type every time we add a string.
*/
export interface Translations {
common: Record<string, string>;
auth: Record<string, string>;
nav: Record<string, string>;
sidebarGroup: Record<string, string>;
breadcrumb: Record<string, string>;
userMenu: Record<string, string>;
chrome: Record<string, string>;
notifications: Record<string, string>;
theme: Record<string, string>;
language: Record<string, string>;
roles: Record<string, string>;
dashboard: Record<string, string>;
studentDash: Record<string, string>;
teacherDash: Record<string, string>;
adminDash: Record<string, string>;
examPopup: Record<string, string>;
errors: Record<string, string>;
ai: Record<string, string>;
privacy: Record<string, string>;
feedback: Record<string, string>;
theme: Record<string, string>;
}
const en: Translations = {
@@ -32,30 +51,293 @@ const en: Translations = {
status: "Status",
settings: "Settings",
signOut: "Sign out",
viewAll: "View All",
dismiss: "Dismiss",
pending: "Pending",
active: "Active",
inactive: "Inactive",
available: "Available",
unavailable: "Unavailable",
name: "Name",
email: "Email",
user: "User",
home: "Home",
},
auth: {
signIn: "Sign in",
signInTitle: "Sign in to EnCoach",
welcomeBack: "Welcome back",
signInDescription: "Sign in to your account to continue",
email: "Email",
emailPlaceholder: "you@example.com",
password: "Password",
rememberMe: "Remember me",
forgotPassword: "Forgot password?",
needAccount: "Don't have an account?",
signUp: "Sign up",
invalidCredentials: "Invalid email or password",
missingCredentials: "Please enter email and password",
loginFailedTitle: "Login Failed",
errorTitle: "Error",
},
nav: {
adminDashboard: "Admin Dashboard",
platformDashboard: "Platform Dashboard",
dashboard: "Dashboard",
courses: "Courses",
myCourses: "My Courses",
students: "Students",
teachers: "Teachers",
exams: "Exams",
batches: "Batches",
timetable: "Timetable",
reports: "Reports",
settings: "Settings",
profile: "Profile",
privacy: "Privacy Center",
assignments: "Assignments",
examsList: "Exams List",
examStructures: "Exam Structures",
rubrics: "Rubrics",
generation: "Generation",
reviewQueue: "Review Queue",
aiPrompts: "AI Prompts",
aiFeedback: "AI Feedback",
approvalWorkflows: "Approval Workflows",
taxonomy: "Taxonomy",
resources: "Resources",
resourceLibrary: "Resource Library",
academicYears: "Academic Years",
departments: "Departments",
admissions: "Admissions",
admissionRegister: "Admission Register",
examSessions: "Exam Sessions",
marksheets: "Marksheets",
studentLeave: "Student Leave",
fees: "Fees & Payments",
lessons: "Lessons",
gradebook: "Gradebook",
studentProgress: "Student Progress",
library: "Library",
activities: "Activities",
facilities: "Facilities",
users: "Users",
entities: "Entities",
classrooms: "Classrooms",
userRoles: "User Roles",
rolesPermissions: "Roles & Permissions",
authorityMatrix: "Authority Matrix",
studentPerformance: "Student Performance",
statsCorporate: "Stats Corporate",
record: "Record",
vocabulary: "Vocabulary",
grammar: "Grammar",
faqManager: "FAQ Manager",
notificationRules: "Notification Rules",
approvalConfig: "Approval Config",
paymentRecord: "Payment Record",
tickets: "Tickets",
settings: "Settings",
profile: "Profile",
privacy: "Privacy Center",
subjectRegistration: "Subject Registration",
mySubjects: "My Subjects",
grades: "Grades",
attendance: "Attendance",
myJourney: "My Journey",
discussions: "Discussions",
messages: "Messages",
announcements: "Announcements",
},
sidebarGroup: {
overview: "Overview",
lms: "LMS",
adaptiveLearning: "Adaptive Learning",
institutional: "Institutional",
academic: "Academic",
management: "Management",
reports: "Reports",
configuration: "Configuration",
training: "Training",
support: "Support",
learning: "Learning",
progress: "Progress",
communication: "Communication",
account: "Account",
teaching: "Teaching",
},
breadcrumb: {
home: "Home",
admin: "Admin",
dashboard: "Dashboard",
platform: "Platform",
exam: "Exam",
review: "Review",
ai: "AI",
feedback: "Feedback",
},
userMenu: {
profile: "Profile",
settings: "Settings",
logout: "Logout",
userFallback: "User",
},
chrome: {
needHelp: "Need help?",
ticketsTooltip: "Support tickets",
aiSearchPlaceholder: "Ask anything... e.g. 'Show students with low attendance'",
aiSearching: "AI is searching…",
aiRelatedQueries: "Related queries",
aiNoResults: "No results for \"{{q}}\". Try a different question or keyword.",
aiSearchFailedTitle: "Search failed",
aiSearchFailedDesc: "Could not complete AI search.",
portalSuffix: "Portal",
adminAlt: "EnCoach",
adminAltLong: "EnCoach — Unlock your potential with AI powered platform",
toggleSidebar: "Toggle sidebar",
},
notifications: {
title: "Notifications",
empty: "You're all caught up.",
ariaLabel: "Notifications",
},
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
toggleLabel: "Toggle theme",
},
language: {
change: "Change language",
label: "Language",
},
roles: {
student: "student",
teacher: "teacher",
admin: "admin",
developer: "developer",
corporate: "corporate",
mastercorporate: "master corporate",
agent: "agent",
},
dashboard: {
greetingFallback: "Student",
},
studentDash: {
welcome: "Welcome back, {{name}}!",
subtitle: "Here's an overview of your learning progress.",
enrolledCourses: "Enrolled Courses",
overallProgress: "Overall Progress",
averageGrade: "Average Grade",
totalChapters: "Total Chapters",
myCourses: "My Courses",
noEnrolledCourses: "No enrolled courses yet.",
chaptersMaterials: "{{chapters}} chapters · {{materials}} materials",
quickActions: "Quick Actions",
enrollToStart: "Enroll in a course to get started.",
continue: "Continue",
start: "Start",
chaptersDone: "{{done}}/{{total}} chapters done",
recentGrades: "Recent Grades",
noGradesYet: "No grades yet.",
},
teacherDash: {
title: "Teacher Dashboard",
subtitle: "Overview of your teaching activities.",
activeCourses: "Active Courses",
totalStudents: "Total Students",
pendingGrading: "Pending Grading",
avgPassRate: "Avg. Pass Rate",
myCourses: "My Courses",
colCourse: "Course",
colStudents: "Students",
colStatus: "Status",
recentActivity: "Recent Activity",
submitted: "Submitted {{when}}",
pending: "Pending",
},
adminDash: {
title: "Admin Dashboard",
subtitle: "Platform overview and key metrics.",
addStudent: "Add Student",
newCourse: "New Course",
totalStudents: "Total Students",
activeCourses: "Active Courses",
teachers: "Teachers",
activeBatches: "Active Batches",
openTickets: "Open Tickets",
revenue: "Revenue",
departments: "Departments",
classrooms: "Classrooms",
subjects: "Subjects",
resources: "Resources",
coursesOverview: "Courses Overview",
batchCapacity: "Batch Capacity",
noCourseData: "No course data available.",
noBatchData: "No batch data available.",
quickSummary: "Quick Summary",
colModule: "Module",
colCount: "Count",
colStatus: "Status",
exams: "Exams",
examSessionsCount: "{{n}} sessions",
assignments: "Assignments",
acrossCourses: "across courses",
supportTickets: "Support Tickets",
ticketsOpenCount: "{{n}} open",
payments: "Payments",
revenueTotal: "{{amount}} total",
chartCapacity: "Capacity",
chartEnrolled: "Enrolled",
},
examPopup: {
title: "Upcoming Exams ({{n}})",
activeNow: "Active Now",
from: "From:",
to: "To:",
startExam: "Start Exam",
notAvailableYet: "Not Available Yet",
willBeAvailable: "Exam will be available once it becomes active",
notActive: "Exam is not currently active",
},
errors: {
somethingWrong: "Something went wrong",
unexpectedDescription: "An unexpected error occurred. Please try refreshing the page.",
errorDetails: "Error details",
refreshPage: "Refresh Page",
notFoundCode: "404",
notFoundMessage: "Oops! Page not found",
returnHome: "Return to Home",
},
ai: {
assistantLabel: "AI Assistant",
assistantTitle: "EnCoach AI Assistant",
placeholder: "Ask anything...",
send: "Send",
thinking: "Thinking…",
emptyLine1: "Ask me anything about the platform,",
emptyLine2: "or click a quick action above.",
fallbackReply: "Sorry, I could not reach the assistant. Please try again in a moment.",
replyErrorTitle: "Could not get a reply",
replyErrorDesc: "Something went wrong. Try again.",
quickHealth: "Platform health summary",
quickDropouts: "Show dropout risks",
quickCompare: "Compare course performance",
quickBatch: "Suggest batch improvements",
tipLabel: "AI Tip",
insightLabel: "AI Insight",
recommendationLabel: "AI Recommendation",
categoryTipLabel: "AI {{category}} Tip",
loadingTip: "Loading AI tip…",
loadingInsights: "Loading insights…",
loadingAlerts: "Loading alerts…",
couldNotLoadTip: "Could not load tip.",
couldNotLoadInsights: "Could not load insights.",
couldNotLoadAlerts: "Could not load alerts.",
noTipAvailable: "No tip available.",
noTipForContext: "No tip for this context yet.",
noInsightsAvailable: "No insights available for this view.",
noAlertsRightNow: "No AI alerts right now.",
alertsUnavailable: "Alerts unavailable",
insightsUnavailable: "Insights unavailable",
platformInsightsTitle: "AI Platform Insights",
},
privacy: {
title: "Privacy Center",
@@ -86,12 +368,6 @@ const en: Translations = {
commentRequired: "Please tell us what was wrong.",
submit: "Submit feedback",
},
theme: {
title: "Theme",
light: "Light",
dark: "Dark",
system: "System",
},
};
export default en;

View File

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

@@ -130,12 +130,32 @@ function refreshOnce(): Promise<boolean> {
return refreshPromise;
}
function getCurrentLanguage(): string {
try {
const stored = localStorage.getItem("encoach-lang");
if (stored) return stored;
} catch {
// localStorage unavailable (SSR, sandboxing, etc.)
}
if (typeof navigator !== "undefined" && navigator.language) {
return navigator.language.split("-")[0];
}
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;
}

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

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

@@ -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,6 +80,9 @@ 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 mb-8">
<img
@@ -80,24 +94,25 @@ export default function Login() {
<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>

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

@@ -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 });
@@ -58,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 => {
@@ -82,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>
@@ -99,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>
@@ -108,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>
@@ -127,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}>
@@ -136,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}>
@@ -156,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>
@@ -168,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

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

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

@@ -155,7 +155,7 @@ export default function ExamReviewDetail() {
<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 mr-1" /> Back to 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>

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ export default function StudentCourseDetail() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{course.title}</h1>
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>

View File

@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
import { useAuth } from "@/contexts/AuthContext";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
@@ -11,6 +12,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentDashboard() {
const { user } = useAuth();
const { t } = useTranslation();
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
const { data: gradesData, isLoading: lg } = useGrades();
const myCourses = enrolledData?.items ?? [];
@@ -22,20 +24,20 @@ export default function StudentDashboard() {
const avgGrade = gradeRecords.length > 0
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
: 0;
const firstName = user?.name?.split(" ")[0] || "Student";
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
const stats = [
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: "Total Chapters", value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
{ label: t("studentDash.averageGrade"), value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
{ label: t("studentDash.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Welcome back, {firstName}!</h1>
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
</div>
<AiTipBanner context="student-dashboard" variant="tip" />
@@ -46,12 +48,12 @@ export default function StudentDashboard() {
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-bold">{s.value}</p>
<p className="text-2xl font-bold"><bdi>{s.value}</bdi></p>
</div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
<s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
</div>
</CardContent>
</Card>
@@ -61,22 +63,28 @@ export default function StudentDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
<CardTitle className="text-lg">{t("studentDash.myCourses")}</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/student/courses">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-4">
{myCourses.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No enrolled courses yet.</p>
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noEnrolledCourses")}</p>
) : myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p>
<p className="text-xs text-muted-foreground">
{t("studentDash.chaptersMaterials", { chapters: c.chapter_count, materials: c.total_materials })}
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span>
<span className="text-xs font-medium w-8 text-end"><bdi>{c.progress}%</bdi></span>
</div>
</div>
</Link>
@@ -87,35 +95,45 @@ export default function StudentDashboard() {
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Quick Actions</CardTitle>
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{myCourses.slice(0, 3).map(c => (
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<Play className="h-4 w-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}</p>
<p className="text-xs text-muted-foreground">{c.completed_chapters}/{c.chapter_count} chapters done</p>
<p className="text-sm font-medium truncate">
{c.progress > 0 ? t("studentDash.continue") : t("studentDash.start")} {c.title || c.name}
</p>
<p className="text-xs text-muted-foreground">
{t("studentDash.chaptersDone", { done: c.completed_chapters, total: c.chapter_count })}
</p>
</div>
<Badge variant="outline">{c.progress}%</Badge>
<Badge variant="outline"><bdi>{c.progress}%</bdi></Badge>
</Link>
))}
{myCourses.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Enroll in a course to get started.</p>}
{myCourses.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.enrollToStart")}</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Recent Grades</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
<CardTitle className="text-lg">{t("studentDash.recentGrades")}</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/student/grades">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{recentGrades.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">No grades yet.</p>
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noGradesYet")}</p>
) : recentGrades.map((g) => (
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{g.assignment_title}</p><p className="text-xs text-muted-foreground">{g.course_name}</p></div>
<span className="text-sm font-bold text-primary">{g.grade}/{g.max_grade}</span>
<div key={g.id} className="flex items-center justify-between p-2 rounded border gap-3">
<div className="min-w-0"><p className="text-sm font-medium truncate" dir="auto">{g.assignment_title}</p><p className="text-xs text-muted-foreground truncate" dir="auto">{g.course_name}</p></div>
<span className="text-sm font-bold text-primary shrink-0"><bdi>{g.grade}/{g.max_grade}</bdi></span>
</div>
))}
</CardContent>

View File

@@ -133,7 +133,7 @@ export default function StudentDiscussionBoard() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
<ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>

View File

@@ -58,7 +58,7 @@ export default function SubjectSelection() {
View Profile
</Button>
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
Learning Plan <ArrowRight className="ml-1 h-3 w-3" />
Learning Plan <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Button>
</div>
</>
@@ -66,7 +66,7 @@ export default function SubjectSelection() {
<>
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
Start Diagnostic <ArrowRight className="ml-1 h-4 w-4" />
Start Diagnostic <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
</Button>
</>
)}

View File

@@ -32,7 +32,7 @@ export default function TeacherAssignmentDetail() {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4" /></Link></Button>
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{assignment.title}</h1>
<p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p>

View File

@@ -4,11 +4,13 @@ import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useCourses, useAssignments } from "@/hooks/queries";
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function TeacherDashboard() {
const { t } = useTranslation();
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const courses = coursesData?.items ?? [];
@@ -21,17 +23,17 @@ export default function TeacherDashboard() {
const pendingGrading = (submissions as { id: string; studentName: string; submittedAt: string; status: string }[]).filter(s => s.status === "pending");
const stats = [
{ label: "Active Courses", value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" },
{ label: "Total Students", value: "55", icon: Users, color: "text-info" },
{ label: "Pending Grading", value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
{ label: "Avg. Pass Rate", value: "82%", icon: TrendingUp, color: "text-success" },
{ label: t("teacherDash.activeCourses"), value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" },
{ label: t("teacherDash.totalStudents"), value: "55", icon: Users, color: "text-info" },
{ label: t("teacherDash.pendingGrading"), value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
{ label: t("teacherDash.avgPassRate"), value: "82%", icon: TrendingUp, color: "text-success" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Teacher Dashboard</h1>
<p className="text-muted-foreground">Overview of your teaching activities.</p>
<h1 className="text-2xl font-bold">{t("teacherDash.title")}</h1>
<p className="text-muted-foreground">{t("teacherDash.subtitle")}</p>
</div>
<AiTipBanner context="teacher-dashboard" variant="recommendation" />
@@ -42,9 +44,9 @@ export default function TeacherDashboard() {
{stats.map(s => (
<Card key={s.label}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold">{s.value}</p></div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
<div className="flex items-center justify-between gap-3">
<div className="min-w-0"><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold"><bdi>{s.value}</bdi></p></div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
</div>
</CardContent>
</Card>
@@ -54,17 +56,27 @@ export default function TeacherDashboard() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
<CardTitle className="text-lg">{t("teacherDash.myCourses")}</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/teacher/courses">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Students</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableHeader>
<TableRow>
<TableHead>{t("teacherDash.colCourse")}</TableHead>
<TableHead>{t("teacherDash.colStudents")}</TableHead>
<TableHead>{t("teacherDash.colStatus")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teacherCourses.map(c => (
<TableRow key={c.id}>
<TableCell className="font-medium">{c.title}</TableCell>
<TableCell>{c.enrolled}/{c.max_capacity}</TableCell>
<TableCell className="font-medium" dir="auto">{c.title}</TableCell>
<TableCell><bdi>{c.enrolled}/{c.max_capacity}</bdi></TableCell>
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
</TableRow>
))}
@@ -76,26 +88,35 @@ export default function TeacherDashboard() {
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Pending Grading</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/teacher/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
<CardTitle className="text-lg">{t("teacherDash.pendingGrading")}</CardTitle>
<Button variant="ghost" size="sm" asChild>
<Link to="/teacher/assignments">
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
</Link>
</Button>
</CardHeader>
<CardContent className="space-y-3">
{pendingGrading.slice(0, 4).map(s => (
<div key={s.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted {s.submittedAt}</p></div>
<Badge variant="secondary">Pending</Badge>
<div>
<p className="text-sm font-medium">{s.studentName}</p>
<p className="text-xs text-muted-foreground">
{t("teacherDash.submitted", { when: s.submittedAt })}
</p>
</div>
<Badge variant="secondary">{t("teacherDash.pending")}</Badge>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
<CardHeader><CardTitle className="text-lg">{t("teacherDash.recentActivity")}</CardTitle></CardHeader>
<CardContent className="space-y-2">
{activityFeed.slice(0, 4).map(a => (
<div key={a.id} className="text-sm">
<span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span>
<span className="text-xs text-muted-foreground ml-2">· {a.time}</span>
<span className="text-xs text-muted-foreground ms-2">· {a.time}</span>
</div>
))}
</CardContent>

View File

@@ -155,7 +155,7 @@ export default function TeacherDiscussionBoard() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
<ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>

View File

@@ -96,5 +96,12 @@ export default {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [
require("tailwindcss-animate"),
// Mirrors physical utilities (ml-*, mr-*, pl-*, pr-*, left-*, right-*,
// text-left/right, border-l/r, rounded-l/r, space-x-*) under `dir="rtl"`
// so the whole app flips visually when Arabic is selected, without
// having to rewrite every component to use logical properties.
require("tailwindcss-rtl"),
],
} satisfies Config;