diff --git a/.gitignore b/.gitignore index 321926c2..a7e10a12 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ htmlcov/ # Poetry poetry.lock + +# Odoo DB backups (local only, not source-controlled) +backups/ diff --git a/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index 18655c9f..fdfeca8b 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/ai_controller.py @@ -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}, diff --git a/backend/custom_addons/encoach_ai/controllers/coach_controller.py b/backend/custom_addons/encoach_ai/controllers/coach_controller.py index 340d001f..73abcb32 100644 --- a/backend/custom_addons/encoach_ai/controllers/coach_controller.py +++ b/backend/custom_addons/encoach_ai/controllers/coach_controller.py @@ -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) diff --git a/backend/custom_addons/encoach_ai/models/ai_feedback.py b/backend/custom_addons/encoach_ai/models/ai_feedback.py index ea5cf00d..4a9bef2c 100644 --- a/backend/custom_addons/encoach_ai/models/ai_feedback.py +++ b/backend/custom_addons/encoach_ai/models/ai_feedback.py @@ -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, ) # ------------------------------------------------------------------ diff --git a/backend/custom_addons/encoach_ai/services/coach_service.py b/backend/custom_addons/encoach_ai/services/coach_service.py index 8b0bf2e4..5bf10cf4 100644 --- a/backend/custom_addons/encoach_ai/services/coach_service.py +++ b/backend/custom_addons/encoach_ai/services/coach_service.py @@ -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: diff --git a/backend/custom_addons/encoach_ai/services/openai_service.py b/backend/custom_addons/encoach_ai/services/openai_service.py index ac9b9475..2570fff9 100644 --- a/backend/custom_addons/encoach_ai/services/openai_service.py +++ b/backend/custom_addons/encoach_ai/services/openai_service.py @@ -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(): diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md new file mode 100644 index 00000000..cf1b83b8 --- /dev/null +++ b/docs/USER_MANUAL.md @@ -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 — `//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. diff --git a/docs/WORK_REPORT.md b/docs/WORK_REPORT.md new file mode 100644 index 00000000..061b61d8 --- /dev/null +++ b/docs/WORK_REPORT.md @@ -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 `EnCoach` 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:///api/health` → 200, and + `curl https:///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`). diff --git a/frontend/index.html b/frontend/index.html index ea5a3388..173935e1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -17,7 +17,7 @@ - +
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 497f4fad..0bd05df2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 230daf3a..eac4bdb1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/AdminLmsLayout.tsx b/frontend/src/components/AdminLmsLayout.tsx index cc95573f..b734e537 100644 --- a/frontend/src/components/AdminLmsLayout.tsx +++ b/frontend/src/components/AdminLmsLayout.tsx @@ -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 ( - {label} + {t(labelKey)} - {items.map((item) => ( - - - - - {!collapsed && {item.title}} - - - - ))} + {items.map((item) => { + const label = t(item.titleKey); + return ( + + + + + {!collapsed && {label}} + + + + ); + })} @@ -150,42 +158,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) } // ============= Breadcrumbs ============= -const routeLabels: Record = { - 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 = { + 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 ( - Home + {t("common.home")} {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 ( @@ -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() {
- +
@@ -228,7 +247,7 @@ export default function AdminLmsLayout() { - @@ -241,19 +260,19 @@ export default function AdminLmsLayout() {
-

{user?.name ?? "User"}

+

{user?.name ?? t("userMenu.userFallback")}

{user?.email ?? ""}

navigate("/admin/profile")}> - Profile + {t("userMenu.profile")} navigate("/admin/settings-platform")}> - Settings + {t("userMenu.settings")} - Logout + {t("userMenu.logout")}
@@ -267,10 +286,10 @@ export default function AdminLmsLayout() { - Need help? + {t("chrome.needHelp")} ); @@ -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 ( - +
{collapsed ? ( EnCoach ) : ( EnCoach — Unlock your potential with AI powered platform )} @@ -308,47 +329,50 @@ function AdminSidebar() { - - - - - - {showManagement && } - {showReports && } - + + + + + + {showManagement && } + {showReports && } + - Training + {t("sidebarGroup.training")} {!collapsed && } - {trainingItems.map((item) => ( - - - - - {!collapsed && {item.title}} - - - - ))} + {trainingItems.map((item) => { + const label = t(item.titleKey); + return ( + + + + + {!collapsed && {label}} + + + + ); + })} - i.url !== "/admin/payment-record")} /> + i.url !== "/admin/payment-record")} /> @@ -358,7 +382,7 @@ function AdminSidebar() {
{!collapsed && (
- {user?.name ?? "User"} + {user?.name ?? t("userMenu.userFallback")} {user?.email ?? ""}
)} diff --git a/frontend/src/components/AppSidebar.tsx b/frontend/src/components/AppSidebar.tsx index f557045b..9f0bfb9f 100644 --- a/frontend/src/components/AppSidebar.tsx +++ b/frontend/src/components/AppSidebar.tsx @@ -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 ( - +
diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 5bb83327..18556978 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -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 { +/** + * 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 { constructor(props: Props) { super(props); this.state = { hasError: false, error: null }; @@ -27,6 +33,7 @@ export class ErrorBoundary extends Component { render() { if (this.state.hasError) { if (this.props.fallback) return this.props.fallback; + const { t } = this.props; return (
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component {
-

Something went wrong

+

{t("errors.somethingWrong")}

- An unexpected error occurred. Please try refreshing the page. + {t("errors.unexpectedDescription")}

{this.state.error && ( -
- Error details +
+ {t("errors.errorDetails")}
{this.state.error.message}
)} @@ -50,7 +57,7 @@ export class ErrorBoundary extends Component { 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")}
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component { return this.props.children; } } + +export const ErrorBoundary = withTranslation()(ErrorBoundaryInner); diff --git a/frontend/src/components/LanguageToggle.tsx b/frontend/src/components/LanguageToggle.tsx index ae6d1f5a..b27cc589 100644 --- a/frontend/src/components/LanguageToggle.tsx +++ b/frontend/src/components/LanguageToggle.tsx @@ -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 ( - - Language + {t("language.label")} {SUPPORTED_LANGS.map((lang) => ( !n.read).length; return ( - -
Notifications
+
{t("notifications.title")}
- {notifications.slice(0, 4).map((n) => ( - - {n.title} - {n.message} - {n.time} - - ))} + {notifications.length === 0 ? ( +
+ {t("notifications.empty")} +
+ ) : ( + notifications.slice(0, 4).map((n) => ( + + {n.title} + {n.message} + {n.time} + + )) + )}
); diff --git a/frontend/src/components/RoleLayout.tsx b/frontend/src/components/RoleLayout.tsx index 57c94001..d0698840 100644 --- a/frontend/src/components/RoleLayout.tsx +++ b/frontend/src/components/RoleLayout.tsx @@ -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) => ( - - {group.label} + + {t(group.labelKey)} - {group.items.map((item) => ( - - - - - {!collapsed && {item.title}} - - - - ))} + {group.items.map((item) => { + const label = t(item.titleKey); + return ( + + + + + {!collapsed && {label}} + + + + ); + })} @@ -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 (
- +
EnCoach EnCoach — Unlock your potential with AI powered platform
@@ -108,7 +122,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) { {initials}
- {user?.name} + + {user?.name ?? t("userMenu.userFallback")} + {user?.email}
@@ -118,8 +134,8 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
- - {role} Portal + + {portalTitle}
@@ -135,16 +151,16 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
-

{user?.name}

+

{user?.name ?? t("userMenu.userFallback")}

{user?.email}

navigate(`/${role}/profile`)}> - Profile + {t("userMenu.profile")} - Logout + {t("userMenu.logout")}
diff --git a/frontend/src/components/StudentLayout.tsx b/frontend/src/components/StudentLayout.tsx index 9fca00fd..201c0606 100644 --- a/frontend/src/components/StudentLayout.tsx +++ b/frontend/src/components/StudentLayout.tsx @@ -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 }, ], }, ]; diff --git a/frontend/src/components/TeacherLayout.tsx b/frontend/src/components/TeacherLayout.tsx index a939eb84..768d4813 100644 --- a/frontend/src/components/TeacherLayout.tsx +++ b/frontend/src/components/TeacherLayout.tsx @@ -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 }, ], }, ]; diff --git a/frontend/src/components/ThemeToggle.tsx b/frontend/src/components/ThemeToggle.tsx index 3569fed9..bb7ef40a 100644 --- a/frontend/src/components/ThemeToggle.tsx +++ b/frontend/src/components/ThemeToggle.tsx @@ -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() { @@ -67,7 +74,7 @@ export default function AiAssistantDrawer() { - EnCoach AI Assistant + {t("ai.assistantTitle")} @@ -90,8 +97,8 @@ export default function AiAssistantDrawer() { {messages.length === 0 && (
-

Ask me anything about the platform,

-

or click a quick action above.

+

{t("ai.emptyLine1")}

+

{t("ai.emptyLine2")}

)} {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" && ( - + )} {msg.text}
))} {chatMutation.isPending && ( -
+
- Thinking... + {t("ai.thinking")}
)}
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")} > diff --git a/frontend/src/components/ai/AiInsightsPanel.tsx b/frontend/src/components/ai/AiInsightsPanel.tsx index 4bb83f21..ccc82206 100644 --- a/frontend/src/components/ai/AiInsightsPanel.tsx +++ b/frontend/src/components/ai/AiInsightsPanel.tsx @@ -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) => 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) { - AI Platform Insights + {t("ai.platformInsightsTitle")} {mutation.isPending && (
- Loading insights… + {t("ai.loadingInsights")}
)} {mutation.isError && !mutation.isPending && ( -

Could not load insights.

+

{t("ai.couldNotLoadInsights")}

)} {mutation.isSuccess && items.length === 0 && ( -

No insights available for this view.

+

{t("ai.noInsightsAvailable")}

)} {!mutation.isPending && items.length > 0 && (
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) { const Icon = insightIcon(item.severity); const color = insightColor(item.severity); return ( -
+
- - {item.title} + + {item.title}

{item.description}

{item.recommendation && ( diff --git a/frontend/src/components/ai/AiSearchBar.tsx b/frontend/src/components/ai/AiSearchBar.tsx index 9a31c526..f67b1736 100644 --- a/frontend/src/components/ai/AiSearchBar.tsx +++ b/frontend/src/components/ai/AiSearchBar.tsx @@ -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 (
- - + + { 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" > @@ -58,11 +60,11 @@ export default function AiSearchBar() {
{(searchMutation.isPending || result !== undefined) && ( -
+
{searchMutation.isPending ? (
- AI is searching... + {t("chrome.aiSearching")}
) : result?.answer ? (
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
{result.suggestions?.length > 0 && (
-

Related queries

+

{t("chrome.aiRelatedQueries")}

{result.suggestions.map((s, i) => (
)}
diff --git a/frontend/src/components/ai/AiTipBanner.tsx b/frontend/src/components/ai/AiTipBanner.tsx index 3484e704..48893143 100644 --- a/frontend/src/components/ai/AiTipBanner.tsx +++ b/frontend/src/components/ai/AiTipBanner.tsx @@ -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 (
- Loading AI tip… + {t("ai.loadingTip")}
); } if (isError || !data) { return ( -
+
-
- AI Tip +
+ {t("ai.tipLabel")}

- {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")}

{dismissible && ( @@ -52,25 +61,25 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di if (!data.tip?.trim()) { return ( -
+
-
- AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"} -

No tip for this context yet.

+
+ {variantLabel} +

{t("ai.noTipForContext")}

); } 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 ( -
+
-
- {label} +
+ {label}

{data.tip}

{dismissible && ( diff --git a/frontend/src/components/student/ExamPopup.tsx b/frontend/src/components/student/ExamPopup.tsx index 5fc037f1..7370c1a1 100644 --- a/frontend/src/components/student/ExamPopup.tsx +++ b/frontend/src/components/student/ExamPopup.tsx @@ -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>(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() { - Upcoming Exams ({pendingExams.length}) + {t("examPopup.title", { n: pendingExams.length })} -
+
{pendingExams.map((exam) => (
@@ -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}
@@ -77,11 +82,11 @@ export default function ExamPopup() {
- From: {formatDate(exam.start_date)} + {t("examPopup.from")} {formatDate(exam.start_date)} - To: {formatDate(exam.end_date)} + {t("examPopup.to")} {formatDate(exam.end_date)}
@@ -93,20 +98,20 @@ export default function ExamPopup() { className="gap-1.5" > - {exam.can_start ? "Start Exam" : "Not Available Yet"} + {exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")} {!exam.can_start && ( - + {exam.schedule_state === "planned" - ? "Exam will be available once it becomes active" - : "Exam is not currently active"} + ? t("examPopup.willBeAvailable") + : t("examPopup.notActive")} )}
diff --git a/frontend/src/components/ui/breadcrumb.tsx b/frontend/src/components/ui/breadcrumb.tsx index ca91ff53..8adff199 100644 --- a/frontend/src/components/ui/breadcrumb.tsx +++ b/frontend/src/components/ui/breadcrumb.tsx @@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef) => ( - ); diff --git a/frontend/src/components/ui/calendar.tsx b/frontend/src/components/ui/calendar.tsx index 900a69e4..16c08099 100644 --- a/frontend/src/components/ui/calendar.tsx +++ b/frontend/src/components/ui/calendar.tsx @@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C ...classNames, }} components={{ - IconLeft: ({ ..._props }) => , - IconRight: ({ ..._props }) => , + IconLeft: ({ ..._props }) => , + IconRight: ({ ..._props }) => , }} {...props} /> diff --git a/frontend/src/components/ui/carousel.tsx b/frontend/src/components/ui/carousel.tsx index 3aa0f31f..0fd41339 100644 --- a/frontend/src/components/ui/carousel.tsx +++ b/frontend/src/components/ui/carousel.tsx @@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef - + Previous slide ); @@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef - + Next slide ); diff --git a/frontend/src/components/ui/context-menu.tsx b/frontend/src/components/ui/context-menu.tsx index b5d9db06..0d154ff6 100644 --- a/frontend/src/components/ui/context-menu.tsx +++ b/frontend/src/components/ui/context-menu.tsx @@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef< {...props} > {children} - + )); ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName; diff --git a/frontend/src/components/ui/dropdown-menu.tsx b/frontend/src/components/ui/dropdown-menu.tsx index ddabcfdd..8249f84e 100644 --- a/frontend/src/components/ui/dropdown-menu.tsx +++ b/frontend/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef< {...props} > {children} - + )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; diff --git a/frontend/src/components/ui/menubar.tsx b/frontend/src/components/ui/menubar.tsx index 15687e4c..f2924af6 100644 --- a/frontend/src/components/ui/menubar.tsx +++ b/frontend/src/components/ui/menubar.tsx @@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef< {...props} > {children} - + )); MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName; diff --git a/frontend/src/components/ui/pagination.tsx b/frontend/src/components/ui/pagination.tsx index 31ae1be1..25ead1fd 100644 --- a/frontend/src/components/ui/pagination.tsx +++ b/frontend/src/components/ui/pagination.tsx @@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina PaginationLink.displayName = "PaginationLink"; const PaginationPrevious = ({ className, ...props }: React.ComponentProps) => ( - - + + Previous ); PaginationPrevious.displayName = "PaginationPrevious"; const PaginationNext = ({ className, ...props }: React.ComponentProps) => ( - + Next - + ); PaginationNext.displayName = "PaginationNext"; diff --git a/frontend/src/components/ui/sidebar.tsx b/frontend/src/components/ui/sidebar.tsx index beb1a591..cb48f4b3 100644 --- a/frontend/src/components/ui/sidebar.tsx +++ b/frontend/src/components/ui/sidebar.tsx @@ -217,7 +217,7 @@ const Sidebar = React.forwardRef< Sidebar.displayName = "Sidebar"; const SidebarTrigger = React.forwardRef, React.ComponentProps>( - ({ className, onClick, ...props }, ref) => { + ({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => { const { toggleSidebar } = useSidebar(); return ( @@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef, 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.C }} {...props} > - - Toggle Sidebar + + {ariaLabel ?? "Toggle sidebar"} ); }, diff --git a/frontend/src/i18n/locales/ar.ts b/frontend/src/i18n/locales/ar.ts index 5f5bfb9f..1a978882 100644 --- a/frontend/src/i18n/locales/ar.ts +++ b/frontend/src/i18n/locales/ar.ts @@ -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; diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 0e668459..0c083503 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -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` per group) so we don't have to update + * a giant type every time we add a string. */ export interface Translations { common: Record; auth: Record; nav: Record; + sidebarGroup: Record; + breadcrumb: Record; + userMenu: Record; + chrome: Record; + notifications: Record; + theme: Record; + language: Record; + roles: Record; + dashboard: Record; + studentDash: Record; + teacherDash: Record; + adminDash: Record; + examPopup: Record; + errors: Record; + ai: Record; privacy: Record; feedback: Record; - theme: Record; } 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; diff --git a/frontend/src/index.css b/frontend/src/index.css index f316264d..a6a9bc97 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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. + 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; +} + diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index 1fcf691f..bdce30b0 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -130,12 +130,32 @@ function refreshOnce(): Promise { 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, withJson = true): Record { const headers: Record = { ...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; } diff --git a/frontend/src/pages/ExamStructuresPage.tsx b/frontend/src/pages/ExamStructuresPage.tsx index 32a1faf4..98cdb477 100644 --- a/frontend/src/pages/ExamStructuresPage.tsx +++ b/frontend/src/pages/ExamStructuresPage.tsx @@ -1355,11 +1355,11 @@ export default function ExamStructuresPage() { const examType = getExamTypeBadge(s); return ( setEditTarget(s)}> - -
- - {s.name} - + +
+ + {s.name} +
-
-
- +
+
+
{s.entity_name && Entity: {s.entity_name}} {s.industry && Industry: {s.industry}} @@ -1382,9 +1382,9 @@ export default function ExamStructuresPage() { {(Array.isArray(s.modules) ? s.modules : []).map((m) => ( {m} ))} -
-
-
+
+ + ); })}
diff --git a/frontend/src/pages/ForgotPassword.tsx b/frontend/src/pages/ForgotPassword.tsx index 29902c00..bf0050fe 100644 --- a/frontend/src/pages/ForgotPassword.tsx +++ b/frontend/src/pages/ForgotPassword.tsx @@ -48,7 +48,7 @@ export default function ForgotPassword() { ) : null}
- Back to sign in + Back to sign in
diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 792d7a8d..7794816d 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -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 (
+
+ +
- Welcome back - Sign in to your account to continue + {t("auth.welcomeBack")} + {t("auth.signInDescription")}
- + setEmail(e.target.value)} disabled={loading} + dir="ltr" />
- +
setPassword(e.target.value)} disabled={loading} + dir="ltr" /> @@ -119,19 +136,25 @@ export default function Login() {
- +
- Forgot password? + + {t("auth.forgotPassword")} +

- Don't have an account?{" "} - Sign up + {t("auth.needAccount")}{" "} + + {t("auth.signUp")} +

diff --git a/frontend/src/pages/NotFound.tsx b/frontend/src/pages/NotFound.tsx index 7bc22346..fcd571e2 100644 --- a/frontend/src/pages/NotFound.tsx +++ b/frontend/src/pages/NotFound.tsx @@ -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 (
-

404

-

Oops! Page not found

+

{t("errors.notFoundCode")}

+

{t("errors.notFoundMessage")}

- Return to Home + {t("errors.returnHome")}
diff --git a/frontend/src/pages/admin/AdminBatchDetail.tsx b/frontend/src/pages/admin/AdminBatchDetail.tsx index 8a2714fe..71975bd0 100644 --- a/frontend/src/pages/admin/AdminBatchDetail.tsx +++ b/frontend/src/pages/admin/AdminBatchDetail.tsx @@ -35,7 +35,7 @@ export default function AdminBatchDetail() { return (
- +

{batch.name}

{batch.course_name} · {batch.teacher_name}

diff --git a/frontend/src/pages/admin/AdminLmsDashboard.tsx b/frontend/src/pages/admin/AdminLmsDashboard.tsx index e09eb5b1..7ad4512c 100644 --- a/frontend/src/pages/admin/AdminLmsDashboard.tsx +++ b/frontend/src/pages/admin/AdminLmsDashboard.tsx @@ -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() {
-

Admin Dashboard

-

Platform overview and key metrics.

+

{t("adminDash.title")}

+

{t("adminDash.subtitle")}

- - + +
@@ -99,7 +105,7 @@ export default function AdminLmsDashboard() { -

{s.value}

+

{s.value}

{s.label}

@@ -108,16 +114,16 @@ export default function AdminLmsDashboard() {
{[ - { 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 => ( - -
-

{s.value}

+ +
+

{s.value}

{s.label}

@@ -127,7 +133,7 @@ export default function AdminLmsDashboard() {
- Courses Overview + {t("adminDash.coursesOverview")} {courseChartData.length > 0 ? ( @@ -136,18 +142,18 @@ export default function AdminLmsDashboard() { - - + + ) : ( -

No course data available.

+

{t("adminDash.noCourseData")}

)}
- Batch Capacity + {t("adminDash.batchCapacity")} {batchChartData.length > 0 ? ( @@ -156,11 +162,11 @@ export default function AdminLmsDashboard() { - + ) : ( -

No batch data available.

+

{t("adminDash.noBatchData")}

)}
@@ -168,31 +174,43 @@ export default function AdminLmsDashboard() { - Quick Summary + {t("adminDash.quickSummary")} - ModuleCountStatus + + + {t("adminDash.colModule")} + {t("adminDash.colCount")} + {t("adminDash.colStatus")} + + - Exams - {dbStats?.total_exams ?? 0} - {dbStats?.total_exam_sessions ?? 0} sessions + {t("adminDash.exams")} + {dbStats?.total_exams ?? 0} + + {t("adminDash.examSessionsCount", { n: dbStats?.total_exam_sessions ?? 0 })} + - Assignments - {dbStats?.total_assignments ?? 0} - across courses + {t("adminDash.assignments")} + {dbStats?.total_assignments ?? 0} + {t("adminDash.acrossCourses")} - Support Tickets - {dbStats?.total_tickets ?? 0} - {openTickets} open + {t("adminDash.supportTickets")} + {dbStats?.total_tickets ?? 0} + + {t("adminDash.ticketsOpenCount", { n: openTickets })} + - Payments - {dbStats?.total_payments ?? 0} - ${revenue.toLocaleString()} total + {t("adminDash.payments")} + {dbStats?.total_payments ?? 0} + + {t("adminDash.revenueTotal", { amount: `$${revenue.toLocaleString()}` })} +
diff --git a/frontend/src/pages/admin/AdmissionDetail.tsx b/frontend/src/pages/admin/AdmissionDetail.tsx index eb16fdf7..9c52d278 100644 --- a/frontend/src/pages/admin/AdmissionDetail.tsx +++ b/frontend/src/pages/admin/AdmissionDetail.tsx @@ -57,7 +57,7 @@ export default function AdmissionDetail() {

{admission.first_name} {admission.last_name}

diff --git a/frontend/src/pages/admin/ApprovalWorkflowConfig.tsx b/frontend/src/pages/admin/ApprovalWorkflowConfig.tsx index f93434a2..10d65ff7 100644 --- a/frontend/src/pages/admin/ApprovalWorkflowConfig.tsx +++ b/frontend/src/pages/admin/ApprovalWorkflowConfig.tsx @@ -216,7 +216,7 @@ export default function ApprovalWorkflowConfig() { {step.order} {step.approver_name}
- {i < wf.steps.length - 1 && } + {i < wf.steps.length - 1 && }
))}
diff --git a/frontend/src/pages/admin/ExamReviewDetail.tsx b/frontend/src/pages/admin/ExamReviewDetail.tsx index 34dfa0a7..a865207e 100644 --- a/frontend/src/pages/admin/ExamReviewDetail.tsx +++ b/frontend/src/pages/admin/ExamReviewDetail.tsx @@ -155,7 +155,7 @@ export default function ExamReviewDetail() {

{exam.title}

diff --git a/frontend/src/pages/student/DiagnosticTest.tsx b/frontend/src/pages/student/DiagnosticTest.tsx index 0ae3f7af..7dcdf114 100644 --- a/frontend/src/pages/student/DiagnosticTest.tsx +++ b/frontend/src/pages/student/DiagnosticTest.tsx @@ -117,7 +117,7 @@ export default function DiagnosticTest() { )} diff --git a/frontend/src/pages/student/LearningPlan.tsx b/frontend/src/pages/student/LearningPlan.tsx index 2b1dbd78..4bab24c4 100644 --- a/frontend/src/pages/student/LearningPlan.tsx +++ b/frontend/src/pages/student/LearningPlan.tsx @@ -106,7 +106,7 @@ export default function LearningPlanPage() {
{(item.status === "available" || item.status === "in_progress") && ( )} {item.status === "completed" && ( diff --git a/frontend/src/pages/student/ProficiencyProfile.tsx b/frontend/src/pages/student/ProficiencyProfile.tsx index 23c56c91..db765fab 100644 --- a/frontend/src/pages/student/ProficiencyProfile.tsx +++ b/frontend/src/pages/student/ProficiencyProfile.tsx @@ -41,7 +41,7 @@ export default function ProficiencyProfile() {
- +
diff --git a/frontend/src/pages/student/StudentCourseDetail.tsx b/frontend/src/pages/student/StudentCourseDetail.tsx index 8cae969b..f51193e7 100644 --- a/frontend/src/pages/student/StudentCourseDetail.tsx +++ b/frontend/src/pages/student/StudentCourseDetail.tsx @@ -28,7 +28,7 @@ export default function StudentCourseDetail() { return (
- +

{course.title}

{course.code} · {chapters.length} chapters

diff --git a/frontend/src/pages/student/StudentDashboard.tsx b/frontend/src/pages/student/StudentDashboard.tsx index e0526005..d92f4c14 100644 --- a/frontend/src/pages/student/StudentDashboard.tsx +++ b/frontend/src/pages/student/StudentDashboard.tsx @@ -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 (
-

Welcome back, {firstName}!

-

Here's an overview of your learning progress.

+

{t("studentDash.welcome", { name: firstName })}

+

{t("studentDash.subtitle")}

@@ -46,12 +48,12 @@ export default function StudentDashboard() { {stats.map((s) => ( -
-
+
+

{s.label}

-

{s.value}

+

{s.value}

- +
@@ -61,22 +63,28 @@ export default function StudentDashboard() {
- My Courses - + {t("studentDash.myCourses")} + {myCourses.length === 0 ? ( -

No enrolled courses yet.

+

{t("studentDash.noEnrolledCourses")}

) : myCourses.map((c) => (

{c.title || c.name}

-

{c.chapter_count} chapters · {c.total_materials} materials

+

+ {t("studentDash.chaptersMaterials", { chapters: c.chapter_count, materials: c.total_materials })} +

- {c.progress}% + {c.progress}%
@@ -87,35 +95,45 @@ export default function StudentDashboard() {
- Quick Actions + {t("studentDash.quickActions")} {myCourses.slice(0, 3).map(c => (
-

{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}

-

{c.completed_chapters}/{c.chapter_count} chapters done

+

+ {c.progress > 0 ? t("studentDash.continue") : t("studentDash.start")} {c.title || c.name} +

+

+ {t("studentDash.chaptersDone", { done: c.completed_chapters, total: c.chapter_count })} +

- {c.progress}% + {c.progress}% ))} - {myCourses.length === 0 &&

Enroll in a course to get started.

} + {myCourses.length === 0 && ( +

{t("studentDash.enrollToStart")}

+ )}
- Recent Grades - + {t("studentDash.recentGrades")} + {recentGrades.length === 0 ? ( -

No grades yet.

+

{t("studentDash.noGradesYet")}

) : recentGrades.map((g) => ( -
-

{g.assignment_title}

{g.course_name}

- {g.grade}/{g.max_grade} +
+

{g.assignment_title}

{g.course_name}

+ {g.grade}/{g.max_grade}
))} diff --git a/frontend/src/pages/student/StudentDiscussionBoard.tsx b/frontend/src/pages/student/StudentDiscussionBoard.tsx index 5d44c7f1..1f886eb7 100644 --- a/frontend/src/pages/student/StudentDiscussionBoard.tsx +++ b/frontend/src/pages/student/StudentDiscussionBoard.tsx @@ -133,7 +133,7 @@ export default function StudentDiscussionBoard() {
diff --git a/frontend/src/pages/student/SubjectSelection.tsx b/frontend/src/pages/student/SubjectSelection.tsx index c066c695..4b9773d3 100644 --- a/frontend/src/pages/student/SubjectSelection.tsx +++ b/frontend/src/pages/student/SubjectSelection.tsx @@ -58,7 +58,7 @@ export default function SubjectSelection() { View Profile
@@ -66,7 +66,7 @@ export default function SubjectSelection() { <>

Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.

)} diff --git a/frontend/src/pages/teacher/TeacherAssignmentDetail.tsx b/frontend/src/pages/teacher/TeacherAssignmentDetail.tsx index 04da86e6..caf97d40 100644 --- a/frontend/src/pages/teacher/TeacherAssignmentDetail.tsx +++ b/frontend/src/pages/teacher/TeacherAssignmentDetail.tsx @@ -32,7 +32,7 @@ export default function TeacherAssignmentDetail() { return (
- +

{assignment.title}

{assignment.entity_name} · Due: {assignment.end_date}

diff --git a/frontend/src/pages/teacher/TeacherDashboard.tsx b/frontend/src/pages/teacher/TeacherDashboard.tsx index 543076c0..303a3b40 100644 --- a/frontend/src/pages/teacher/TeacherDashboard.tsx +++ b/frontend/src/pages/teacher/TeacherDashboard.tsx @@ -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 (
-

Teacher Dashboard

-

Overview of your teaching activities.

+

{t("teacherDash.title")}

+

{t("teacherDash.subtitle")}

@@ -42,9 +44,9 @@ export default function TeacherDashboard() { {stats.map(s => ( -
-

{s.label}

{s.value}

- +
+

{s.label}

{s.value}

+
@@ -54,17 +56,27 @@ export default function TeacherDashboard() {
- My Courses - + {t("teacherDash.myCourses")} + - CourseStudentsStatus + + + {t("teacherDash.colCourse")} + {t("teacherDash.colStudents")} + {t("teacherDash.colStatus")} + + {teacherCourses.map(c => ( - {c.title} - {c.enrolled}/{c.max_capacity} + {c.title} + {c.enrolled}/{c.max_capacity} {c.status} ))} @@ -76,26 +88,35 @@ export default function TeacherDashboard() {
- Pending Grading - + {t("teacherDash.pendingGrading")} + {pendingGrading.slice(0, 4).map(s => (
-

{s.studentName}

Submitted {s.submittedAt}

- Pending +
+

{s.studentName}

+

+ {t("teacherDash.submitted", { when: s.submittedAt })} +

+
+ {t("teacherDash.pending")}
))}
- Recent Activity + {t("teacherDash.recentActivity")} {activityFeed.slice(0, 4).map(a => (
{a.user} {a.action} {a.target} - · {a.time} + · {a.time}
))}
diff --git a/frontend/src/pages/teacher/TeacherDiscussionBoard.tsx b/frontend/src/pages/teacher/TeacherDiscussionBoard.tsx index ea39410e..1f756d35 100644 --- a/frontend/src/pages/teacher/TeacherDiscussionBoard.tsx +++ b/frontend/src/pages/teacher/TeacherDiscussionBoard.tsx @@ -155,7 +155,7 @@ export default function TeacherDiscussionBoard() {
diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index 5a464bd0..40d85d4a 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -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;