Compare commits
2 Commits
93c530eef2
...
e1f059069f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1f059069f | ||
|
|
6712d1d551 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -105,3 +105,6 @@ htmlcov/
|
|||||||
|
|
||||||
# Poetry
|
# Poetry
|
||||||
poetry.lock
|
poetry.lock
|
||||||
|
|
||||||
|
# Odoo DB backups (local only, not source-controlled)
|
||||||
|
backups/
|
||||||
|
|||||||
@@ -24,6 +24,25 @@ def _get_json():
|
|||||||
return {}
|
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):
|
class AIController(http.Controller):
|
||||||
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
"""Handles /api/ai/* endpoints consumed by frontend AI components."""
|
||||||
|
|
||||||
@@ -37,7 +56,7 @@ class AIController(http.Controller):
|
|||||||
return _json_response({"answer": "", "suggestions": []})
|
return _json_response({"answer": "", "suggestions": []})
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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", ""))
|
result = ai.search_with_rag(query, context=body.get("context", ""))
|
||||||
return _json_response(result)
|
return _json_response(result)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -69,7 +88,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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(
|
result = ai.generate_insights(
|
||||||
body.get("data", {}),
|
body.get("data", {}),
|
||||||
insight_type=body.get("type", "general"),
|
insight_type=body.get("type", "general"),
|
||||||
@@ -85,7 +104,7 @@ class AIController(http.Controller):
|
|||||||
def ai_alerts(self, **kw):
|
def ai_alerts(self, **kw):
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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")
|
context = request.params.get("context", "dashboard")
|
||||||
result = ai.generate_insights(
|
result = ai.generate_insights(
|
||||||
{"context": context, "request": "alerts"},
|
{"context": context, "request": "alerts"},
|
||||||
@@ -103,7 +122,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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(
|
narrative = ai.generate_report_narrative(
|
||||||
body.get("report_type", "performance"),
|
body.get("report_type", "performance"),
|
||||||
body.get("data", {}),
|
body.get("data", {}),
|
||||||
@@ -119,7 +138,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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(
|
result = ai.batch_optimize(
|
||||||
body.get("items", []),
|
body.get("items", []),
|
||||||
optimization_type=body.get("type", "schedule"),
|
optimization_type=body.get("type", "schedule"),
|
||||||
@@ -135,7 +154,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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")
|
skill = body.get("skill", "writing")
|
||||||
if skill == "speaking":
|
if skill == "speaking":
|
||||||
result = ai.grade_speaking(
|
result = ai.grade_speaking(
|
||||||
@@ -160,7 +179,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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(
|
result = ai.generate_content_dedup(
|
||||||
body.get("content_type", "reading_passage"),
|
body.get("content_type", "reading_passage"),
|
||||||
body.get("brief", {}),
|
body.get("brief", {}),
|
||||||
@@ -204,7 +223,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"You are an educational taxonomy expert. Suggest topics for the given domain and level. "
|
"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()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Create a personalized learning plan. Return JSON: "
|
"Create a personalized learning plan. Return JSON: "
|
||||||
@@ -246,7 +265,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Generate a course outline. Return JSON: {\"chapters\": "
|
"Generate a course outline. Return JSON: {\"chapters\": "
|
||||||
@@ -264,7 +283,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Generate detailed chapter content for a course. Return JSON: "
|
"Generate detailed chapter content for a course. Return JSON: "
|
||||||
@@ -283,7 +302,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
"Create an assessment rubric. Return JSON: {\"rubric\": "
|
||||||
@@ -350,7 +369,7 @@ class AIController(http.Controller):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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:
|
if not ai.client:
|
||||||
raise RuntimeError("OpenAI not configured")
|
raise RuntimeError("OpenAI not configured")
|
||||||
|
|
||||||
@@ -480,7 +499,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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)
|
has_ai = bool(ai.client)
|
||||||
except Exception:
|
except Exception:
|
||||||
ai, has_ai = None, False
|
ai, has_ai = None, False
|
||||||
@@ -1618,7 +1637,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
||||||
ai = OpenAIService(request.env)
|
ai = OpenAIService(request.env, language=_request_language())
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "system", "content": (
|
{"role": "system", "content": (
|
||||||
"You are an educational materials expert. Suggest learning materials "
|
"You are an educational materials expert. Suggest learning materials "
|
||||||
@@ -1639,7 +1658,7 @@ class AIController(http.Controller):
|
|||||||
body = _get_json()
|
body = _get_json()
|
||||||
try:
|
try:
|
||||||
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
|
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(
|
result = ai.generate_content(
|
||||||
body.get("content_type", "explanation"),
|
body.get("content_type", "explanation"),
|
||||||
{"topic_id": topic_id, **body},
|
{"topic_id": topic_id, **body},
|
||||||
|
|||||||
@@ -23,12 +23,25 @@ def _get_json():
|
|||||||
return {}
|
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):
|
class CoachController(http.Controller):
|
||||||
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
|
"""Handles /api/coach/* endpoints consumed by frontend AI coaching components."""
|
||||||
|
|
||||||
def _get_coach(self):
|
def _get_coach(self):
|
||||||
from odoo.addons.encoach_ai.services.coach_service import CoachService
|
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 ──
|
# ── POST /api/coach/chat — AiAssistantDrawer.tsx ──
|
||||||
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
@http.route("/api/coach/chat", type="http", auth="none", methods=["POST"], csrf=False)
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class EncoachAIFeedback(models.Model):
|
|||||||
"encoach.entity", ondelete="set null", index=True,
|
"encoach.entity", ondelete="set null", index=True,
|
||||||
)
|
)
|
||||||
course_id = fields.Many2one(
|
course_id = fields.Many2one(
|
||||||
"encoach.course", ondelete="set null", index=True,
|
"op.course", ondelete="set null", index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ _logger = logging.getLogger(__name__)
|
|||||||
class CoachService:
|
class CoachService:
|
||||||
"""High-level AI coaching: chat, tips, explanations, writing help, study plans."""
|
"""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
|
from .openai_service import OpenAIService
|
||||||
self.env = env
|
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):
|
def _log(self, action, latency_ms=0, status="success", error=None, inp=None, out=None):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -12,10 +12,45 @@ except ImportError:
|
|||||||
_openai_mod = None
|
_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:
|
class OpenAIService:
|
||||||
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
|
"""Wraps the OpenAI Python SDK with Odoo settings and logging."""
|
||||||
|
|
||||||
def __init__(self, env):
|
def __init__(self, env, *, language=None):
|
||||||
self.env = env
|
self.env = env
|
||||||
self._get_param = env["ir.config_parameter"].sudo().get_param
|
self._get_param = env["ir.config_parameter"].sudo().get_param
|
||||||
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
|
self.enabled = self._get_param("encoach_ai.enabled", "True").lower() in ("1", "true", "yes")
|
||||||
@@ -34,6 +69,49 @@ class OpenAIService:
|
|||||||
self.client = None
|
self.client = None
|
||||||
self.model = self._get_param("encoach_ai.openai_model", "gpt-4o")
|
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.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):
|
def _log(self, action, model, usage, latency, status="success", error=None, inp=None, out=None):
|
||||||
try:
|
try:
|
||||||
@@ -82,6 +160,7 @@ class OpenAIService:
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
messages = self._inject_language(messages)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
@@ -108,6 +187,7 @@ class OpenAIService:
|
|||||||
if not self.client:
|
if not self.client:
|
||||||
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
raise RuntimeError("OpenAI not configured — set API key in AI Settings")
|
||||||
model = model or self.model
|
model = model or self.model
|
||||||
|
messages = self._inject_language(messages)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
def _call():
|
def _call():
|
||||||
|
|||||||
633
docs/USER_MANUAL.md
Normal file
633
docs/USER_MANUAL.md
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
# EnCoach — User Manual
|
||||||
|
|
||||||
|
> *Unlock your potential with AI-powered learning.*
|
||||||
|
|
||||||
|
This manual walks every user type through the EnCoach platform: what each
|
||||||
|
portal looks like, which features are available, and how to accomplish the
|
||||||
|
most common tasks. It's written to be opened by a first-time user and
|
||||||
|
followed step-by-step.
|
||||||
|
|
||||||
|
The platform has **four main user types**, each with its own portal:
|
||||||
|
|
||||||
|
| Role | Portal URL prefix | Purpose |
|
||||||
|
| ------------------- | ----------------- | ----------------------------------------------------------------- |
|
||||||
|
| **Student** | `/student/*` | Learn, practice, take exams, track progress. |
|
||||||
|
| **Teacher** | `/teacher/*` | Run courses, grade assignments, manage classrooms. |
|
||||||
|
| **Admin** | `/admin/*` | Operate the institution: users, courses, exams, reports, payments.|
|
||||||
|
| **Corporate / Agent / Developer** | `/admin/*` | Admin portal with scoped permissions (see §Admin — Access matrix).|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
1. [Getting started — common to all users](#1-getting-started--common-to-all-users)
|
||||||
|
2. [Student guide](#2-student-guide)
|
||||||
|
3. [Teacher guide](#3-teacher-guide)
|
||||||
|
4. [Admin guide](#4-admin-guide)
|
||||||
|
5. [Privacy & language controls (all roles)](#5-privacy--language-controls-all-roles)
|
||||||
|
6. [Troubleshooting & support](#6-troubleshooting--support)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Getting started — common to all users
|
||||||
|
|
||||||
|
### 1.1 Signing in
|
||||||
|
|
||||||
|
1. Open the platform URL in your browser. You'll land on `/login`.
|
||||||
|
2. Enter your **email** and **password**.
|
||||||
|
3. Click **Sign in**.
|
||||||
|
|
||||||
|
If you've been invited via email, you may need to verify your address
|
||||||
|
first. Open the link in the invitation email and follow the prompts.
|
||||||
|
|
||||||
|
### 1.2 Registering a new account
|
||||||
|
|
||||||
|
1. From the login page, click **Register**.
|
||||||
|
2. Fill in your name, email, and chosen password.
|
||||||
|
3. Check your inbox for the verification email and click the link to
|
||||||
|
confirm.
|
||||||
|
4. First-time users are automatically routed through an **onboarding
|
||||||
|
wizard** that collects your goals, proficiency hints, and preferred
|
||||||
|
subjects.
|
||||||
|
|
||||||
|
### 1.3 Forgot / reset password
|
||||||
|
|
||||||
|
- Click **Forgot password** on `/login`.
|
||||||
|
- Enter your email. You'll get a reset link valid for a short time.
|
||||||
|
- Follow the link, set a new password, then sign in.
|
||||||
|
|
||||||
|
### 1.4 Switching theme (dark / light)
|
||||||
|
|
||||||
|
Every portal has a **sun / moon toggle** in the header. Click it to
|
||||||
|
cycle **Light → Dark → System**. Your choice is remembered per browser.
|
||||||
|
|
||||||
|
### 1.5 Switching language
|
||||||
|
|
||||||
|
Every portal also has a **globe icon** next to the theme toggle. Pick
|
||||||
|
**English** or **العربية**. If you choose Arabic, the interface
|
||||||
|
switches to right-to-left automatically.
|
||||||
|
|
||||||
|
### 1.6 Sign out
|
||||||
|
|
||||||
|
Click your avatar in the top-right of any portal and pick **Sign out**.
|
||||||
|
Your session is revoked server-side (not just cleared locally).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Student guide
|
||||||
|
|
||||||
|
The student portal is your personal learning environment: courses,
|
||||||
|
assignments, exams, attendance, and AI-powered practice.
|
||||||
|
|
||||||
|
### 2.1 Dashboard — `/student/dashboard`
|
||||||
|
|
||||||
|
This is your home screen. It shows:
|
||||||
|
|
||||||
|
- **Next up** — the next class, assignment, or exam on your timetable.
|
||||||
|
- **Progress widgets** — overall mastery, adaptive subjects in progress,
|
||||||
|
recent exam scores.
|
||||||
|
- **Announcements** — school-wide and course-specific notices from
|
||||||
|
teachers and admins.
|
||||||
|
|
||||||
|
### 2.2 My courses — `/student/courses`
|
||||||
|
|
||||||
|
- Lists every course you're enrolled in.
|
||||||
|
- Click a course card to open **`/student/courses/:id`** — the course
|
||||||
|
detail page with:
|
||||||
|
- Syllabus
|
||||||
|
- Chapter list (click any chapter to read content, watch videos, and
|
||||||
|
take embedded exercises).
|
||||||
|
- Assignments tied to the course.
|
||||||
|
- Teacher and classmates.
|
||||||
|
|
||||||
|
### 2.3 AI-driven courses (the core of the platform)
|
||||||
|
|
||||||
|
EnCoach's flagship is the adaptive AI coach. There are two flows:
|
||||||
|
|
||||||
|
#### 2.3.1 General English — `/student/course/ai-english/:courseId`
|
||||||
|
|
||||||
|
- Auto-generated lessons scoped to your current CEFR level.
|
||||||
|
- Each lesson includes: reading, vocabulary, grammar drill, listening,
|
||||||
|
speaking prompt, and a short review quiz.
|
||||||
|
- **Thumbs up / down** under every AI-generated item — your feedback
|
||||||
|
feeds the admin triage queue and improves future prompts.
|
||||||
|
|
||||||
|
#### 2.3.2 IELTS — `/student/course/ai-ielts/:courseId`
|
||||||
|
|
||||||
|
- Full IELTS prep: Reading, Listening, Writing Task 1 & 2, Speaking.
|
||||||
|
- Every generated item is validated against the IELTS rubric before you
|
||||||
|
see it. Items that fail validation never reach you.
|
||||||
|
|
||||||
|
### 2.4 Adaptive learning — personalized practice
|
||||||
|
|
||||||
|
If you choose to pursue an adaptive track:
|
||||||
|
|
||||||
|
1. Go to **`/student/subjects`** and pick a subject (e.g. Vocabulary,
|
||||||
|
Grammar, Reading).
|
||||||
|
2. Take the **diagnostic test** at **`/student/diagnostic/:subjectId`**.
|
||||||
|
Results map your ability onto a skill tree.
|
||||||
|
3. Review your **proficiency profile** at
|
||||||
|
**`/student/proficiency/:subjectId`** — heatmap of strong / weak
|
||||||
|
areas.
|
||||||
|
4. Follow the generated **learning plan** at
|
||||||
|
**`/student/plan/:subjectId`**.
|
||||||
|
5. Drill individual topics via **`/student/topic/:topicId`**.
|
||||||
|
|
||||||
|
### 2.5 Placement tests — `/student/placement`
|
||||||
|
|
||||||
|
For corporate / institutional students who need a CEFR placement:
|
||||||
|
|
||||||
|
1. **`/student/placement`** — briefing page: rules, duration, tech check.
|
||||||
|
2. **`/student/placement/test`** — full-screen exam session (no layout
|
||||||
|
chrome).
|
||||||
|
3. **`/student/placement/results`** — your band score + per-skill
|
||||||
|
breakdown + recommended starting level.
|
||||||
|
4. **`/student/placement/access`** — access code entry for managed
|
||||||
|
institutional tests.
|
||||||
|
|
||||||
|
### 2.6 Assignments — `/student/assignments`
|
||||||
|
|
||||||
|
- Table of all current and past assignments.
|
||||||
|
- Click an assignment to upload your submission, view rubric, and track
|
||||||
|
grading status.
|
||||||
|
|
||||||
|
### 2.7 Grades — `/student/grades`
|
||||||
|
|
||||||
|
- Per-course gradebook view.
|
||||||
|
- Drill into each exam / assignment to see rubric-level feedback.
|
||||||
|
|
||||||
|
### 2.8 Exams
|
||||||
|
|
||||||
|
- **Take an exam:** `/student/exam/:examId/session` — full-screen,
|
||||||
|
distraction-free session. Answers auto-save.
|
||||||
|
- **Check status:** `/student/exam/:examId/status` — submission state
|
||||||
|
(grading, pending review, scored).
|
||||||
|
- **See results:** `/student/exam/:examId/results` — band score,
|
||||||
|
per-section breakdown, teacher / AI feedback.
|
||||||
|
|
||||||
|
### 2.9 Attendance — `/student/attendance`
|
||||||
|
|
||||||
|
- Calendar + list view of your attendance history.
|
||||||
|
- Export as CSV for personal records.
|
||||||
|
|
||||||
|
### 2.10 Timetable — `/student/timetable`
|
||||||
|
|
||||||
|
- Week view of your classes, exams, and assignment deadlines.
|
||||||
|
|
||||||
|
### 2.11 Discussion boards — `/student/discussions`
|
||||||
|
|
||||||
|
- Per-course forum threads. Ask questions, reply to classmates.
|
||||||
|
|
||||||
|
### 2.12 Announcements — `/student/announcements`
|
||||||
|
|
||||||
|
- Institution-wide and course-specific announcements from teachers and
|
||||||
|
admins.
|
||||||
|
|
||||||
|
### 2.13 Messages — `/student/messages`
|
||||||
|
|
||||||
|
- Direct chat with your teachers and classmates.
|
||||||
|
|
||||||
|
### 2.14 Journey — `/student/journey`
|
||||||
|
|
||||||
|
- A visual timeline of your progress: milestones hit, badges earned,
|
||||||
|
exams passed, current level, next goals.
|
||||||
|
|
||||||
|
### 2.15 Profile — `/student/profile`
|
||||||
|
|
||||||
|
- Name, email, phone, bio, avatar.
|
||||||
|
- Change password.
|
||||||
|
- Preferred language + theme.
|
||||||
|
|
||||||
|
### 2.16 Subject registration — `/student/subject-registration`
|
||||||
|
|
||||||
|
- For institutions that require formal subject enrollment per term.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Teacher guide
|
||||||
|
|
||||||
|
The teacher portal is your classroom command center.
|
||||||
|
|
||||||
|
### 3.1 Dashboard — `/teacher/dashboard`
|
||||||
|
|
||||||
|
- **My classes today**, pending gradings, upcoming exams.
|
||||||
|
- **Recent student activity** — who submitted, who's late, who's
|
||||||
|
struggling (from adaptive signals).
|
||||||
|
- Quick links to create an assignment or open your course builder.
|
||||||
|
|
||||||
|
### 3.2 My courses — `/teacher/courses`
|
||||||
|
|
||||||
|
- All courses you teach.
|
||||||
|
- Click **+ New course** to open the **course builder**
|
||||||
|
(`/teacher/courses/new`):
|
||||||
|
- Course metadata (title, description, cover, CEFR level).
|
||||||
|
- Syllabus blocks.
|
||||||
|
- Chapter list (each chapter has its own content editor + AI
|
||||||
|
workbench).
|
||||||
|
- Edit an existing course via **`/teacher/courses/:id/edit`**.
|
||||||
|
|
||||||
|
### 3.3 Chapters — `/teacher/courses/:courseId/chapters`
|
||||||
|
|
||||||
|
- Ordered list of chapters for a course.
|
||||||
|
- Click a chapter → **`/teacher/courses/:courseId/chapters/:chapterId`**
|
||||||
|
to edit content, attach resources, add exercises, and configure the
|
||||||
|
pass criteria.
|
||||||
|
|
||||||
|
### 3.4 AI workbench — `/teacher/courses/:courseId/workbench`
|
||||||
|
|
||||||
|
The magic. From one panel you can:
|
||||||
|
|
||||||
|
- Generate a new set of exercises (multiple choice, cloze, reading
|
||||||
|
comprehension, short-answer) from a topic prompt.
|
||||||
|
- Regenerate individual items with a different difficulty / style.
|
||||||
|
- Preview exactly what students will see.
|
||||||
|
- Submit generated items to the **exam review queue** if they're
|
||||||
|
destined for a graded exam (items enter `pending_review` until an
|
||||||
|
admin approves them).
|
||||||
|
|
||||||
|
Every generation call is logged with the prompt hash and model so you
|
||||||
|
can later ask **"why was this question generated this way?"** and get a
|
||||||
|
traceable answer.
|
||||||
|
|
||||||
|
### 3.5 Assignments — `/teacher/assignments`
|
||||||
|
|
||||||
|
- List of assignments you've issued.
|
||||||
|
- Click one → **`/teacher/assignments/:id`** to:
|
||||||
|
- See every student's submission.
|
||||||
|
- Grade with the rubric.
|
||||||
|
- Leave written / voice feedback.
|
||||||
|
- Publish the grade (feeds the student gradebook in real time).
|
||||||
|
|
||||||
|
### 3.6 Students — `/teacher/students`
|
||||||
|
|
||||||
|
- Roster of every student enrolled in a course you teach.
|
||||||
|
- Click a student to open their profile, see per-topic mastery, and
|
||||||
|
drill into individual attempts.
|
||||||
|
|
||||||
|
### 3.7 Attendance — `/teacher/attendance`
|
||||||
|
|
||||||
|
- Per-class attendance register.
|
||||||
|
- Mark present / absent / late in bulk.
|
||||||
|
- Attendance automatically rolls up to the student dashboard and to
|
||||||
|
admin reports.
|
||||||
|
|
||||||
|
### 3.8 Timetable — `/teacher/timetable`
|
||||||
|
|
||||||
|
- Your weekly teaching schedule.
|
||||||
|
|
||||||
|
### 3.9 Library — `/teacher/library`
|
||||||
|
|
||||||
|
- Reusable resource pool: PDFs, videos, slide decks, audio prompts.
|
||||||
|
- Tag resources by topic / skill / CEFR level.
|
||||||
|
- Attach them into chapters or assignments with one click.
|
||||||
|
|
||||||
|
### 3.10 Course progress — `/teacher/course/:courseId/progress`
|
||||||
|
|
||||||
|
- Class heatmap: which chapters / topics are lagging, which students
|
||||||
|
need attention, completion % per module.
|
||||||
|
|
||||||
|
### 3.11 Discussions & announcements
|
||||||
|
|
||||||
|
- **`/teacher/discussions`** — moderate course forums, pin useful
|
||||||
|
threads.
|
||||||
|
- **`/teacher/announcements`** — post a notice to a course, a class, or
|
||||||
|
the whole institution (if your role allows).
|
||||||
|
|
||||||
|
### 3.12 Adaptive settings — `/teacher/adaptive/settings`
|
||||||
|
|
||||||
|
Configure how the adaptive engine behaves for your students:
|
||||||
|
|
||||||
|
- Difficulty ramp steepness.
|
||||||
|
- Mastery threshold to move on.
|
||||||
|
- How many wrong answers before a remediation branch triggers.
|
||||||
|
|
||||||
|
### 3.13 Profile & privacy
|
||||||
|
|
||||||
|
- **`/teacher/profile`** — personal details, signature, teaching bio.
|
||||||
|
- **`/teacher/privacy`** — export your data / delete account (see §5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Admin guide
|
||||||
|
|
||||||
|
The admin portal is the operational hub of the institution. Under the
|
||||||
|
hood it's a unified layout covering the original platform pages *plus*
|
||||||
|
the full LMS *plus* the AI control plane.
|
||||||
|
|
||||||
|
### 4.1 Access matrix
|
||||||
|
|
||||||
|
| Role | Sees | Typical use case |
|
||||||
|
| ----------------- | ---------------------------------------------------------------- | ---------------------------------------- |
|
||||||
|
| `admin` | Everything. | Institution owner / operations lead. |
|
||||||
|
| `corporate` | Their corporate entity only (users, reports, exams). | Corporate HR manager. |
|
||||||
|
| `mastercorporate` | Multiple corporate entities they own. | Corporate group head. |
|
||||||
|
| `agent` | Assigned cohorts of students + scoring queue. | Scoring / review agent. |
|
||||||
|
| `developer` | Full + dev-only tools (OpenAPI, feature flags). | Platform engineer. |
|
||||||
|
|
||||||
|
All admin URLs start with `/admin/*`.
|
||||||
|
|
||||||
|
### 4.2 Dashboards
|
||||||
|
|
||||||
|
- **`/admin/dashboard`** — LMS dashboard: enrolments, active courses,
|
||||||
|
attendance trends, open tickets, revenue.
|
||||||
|
- **`/admin/platform`** — legacy platform dashboard kept for
|
||||||
|
multi-tenant statistics (per-entity breakdown).
|
||||||
|
|
||||||
|
### 4.3 Academic setup
|
||||||
|
|
||||||
|
| Page | What you do there |
|
||||||
|
| ---------------------------- | ---------------------------------------------------------- |
|
||||||
|
| `/admin/academic-years` | Define terms / academic years. |
|
||||||
|
| `/admin/departments` | Departments within the institution. |
|
||||||
|
| `/admin/courses` | All courses, enabled / disabled, owners. |
|
||||||
|
| `/admin/batches` | Student cohorts — `/admin/batches/:id` for roster + schedule. |
|
||||||
|
| `/admin/timetable` | Institution-wide timetable editor. |
|
||||||
|
| `/admin/lessons` | Centralized lesson plan bank. |
|
||||||
|
| `/admin/library` | Institutional content library. |
|
||||||
|
| `/admin/facilities` | Rooms, labs, equipment bookings. |
|
||||||
|
| `/admin/activities` | Extra-curricular activities, clubs, events. |
|
||||||
|
|
||||||
|
### 4.4 People
|
||||||
|
|
||||||
|
| Page | What you do there |
|
||||||
|
| ----------------------------- | --------------------------------------------------------- |
|
||||||
|
| `/admin/students` | Student roster — search, filter, open a profile. |
|
||||||
|
| `/admin/teachers` | Teacher roster. |
|
||||||
|
| `/admin/admissions` | Applications pipeline — `/admin/admissions/:id` per file. |
|
||||||
|
| `/admin/admission-register` | Bulk register newly admitted students. |
|
||||||
|
| `/admin/entity/students/upload`| CSV bulk upload with validation + preview. |
|
||||||
|
| `/admin/entity/students/credentials` | Reset passwords / send invites in bulk. |
|
||||||
|
| `/admin/student-leave` | Approve / reject leave requests. |
|
||||||
|
|
||||||
|
### 4.5 Exams & assessments
|
||||||
|
|
||||||
|
This is where most of the recent work landed. Flow:
|
||||||
|
|
||||||
|
1. **Pick a template** — `/admin/exam/create` (template selection).
|
||||||
|
2. **IELTS path**:
|
||||||
|
- Create: `/admin/exam/ielts/create`
|
||||||
|
- Configure skills: `/admin/exam/ielts/:examId/skills`
|
||||||
|
- Build content pool: `/admin/exam/ielts/:examId/content`
|
||||||
|
- Validate against rubric: `/admin/exam/ielts/:examId/validate`
|
||||||
|
3. **Custom exam path**: `/admin/exam/custom/create` — free-form exam
|
||||||
|
builder with section / weighting / time budget controls.
|
||||||
|
4. **Human-in-the-loop review** (NEW)
|
||||||
|
- **`/admin/exam/review-queue`** — every exam whose items are in
|
||||||
|
`pending_review`. Filter by course, subject, or date.
|
||||||
|
- **`/admin/exam/review/:examId`** — side-by-side view: each item
|
||||||
|
with its AI-generated content, source passage, and buttons to
|
||||||
|
**Approve**, **Edit & approve**, or **Reject** (with required
|
||||||
|
reason). Approved exams become publishable.
|
||||||
|
5. **Grading queue**: `/admin/exam/:examId/grading` — human grader
|
||||||
|
pick-list for writing / speaking items.
|
||||||
|
6. **Score approval queue**: `/admin/scores/pending` — final sign-off
|
||||||
|
before scores are released to students.
|
||||||
|
7. **Institutional exam sessions**: `/admin/exam-sessions` — scheduled
|
||||||
|
sittings for on-site administered exams.
|
||||||
|
8. **Marksheets**: `/admin/marksheets` — bulk marksheet generation and
|
||||||
|
export.
|
||||||
|
9. **Exams list**: `/admin/examsList` — master list, filter by state
|
||||||
|
(draft / pending_review / published / archived).
|
||||||
|
10. **Exam structures**: `/admin/exam-structures` — reusable blueprints
|
||||||
|
(section counts, weighting, time budgets).
|
||||||
|
11. **Rubrics**: `/admin/rubrics` — rubric library used by both graders
|
||||||
|
and the AI validator.
|
||||||
|
12. **Assignments**: `/admin/assignments` — assignment oversight across
|
||||||
|
all courses.
|
||||||
|
13. **AI English quality** (`/admin/ai-course/english/:courseId/quality`)
|
||||||
|
and **IELTS validation** (`/admin/ai-course/ielts/:courseId/validation`)
|
||||||
|
— deep dive into the quality signals on a single AI-generated course.
|
||||||
|
|
||||||
|
### 4.6 AI control plane (NEW)
|
||||||
|
|
||||||
|
Three pages to operate the AI itself:
|
||||||
|
|
||||||
|
- **`/admin/generation`** — legacy generation page (bulk item creation
|
||||||
|
from a prompt).
|
||||||
|
- **`/admin/ai/prompts`** — **AI Prompt Editor**. Every prompt template
|
||||||
|
lives here with versions. Features:
|
||||||
|
- List all prompt keys (latest version per key).
|
||||||
|
- Click a key to see all historical versions.
|
||||||
|
- **Activate** any version (only one active per key at a time).
|
||||||
|
- **Create new version** — title, description, template body, optional
|
||||||
|
"activate immediately" flag.
|
||||||
|
- **Render preview** — plug in sample variables and dry-run the
|
||||||
|
template to see the exact text the LLM will receive, without
|
||||||
|
actually calling the LLM.
|
||||||
|
- **`/admin/ai/feedback`** — **AI Feedback Triage**. Every thumbs
|
||||||
|
up/down a student leaves on AI-generated content lands here. Features:
|
||||||
|
- Filter by status (open / acknowledged / fixed / dismissed), rating,
|
||||||
|
subject type.
|
||||||
|
- Click a feedback row to see the full comment, the AI output the
|
||||||
|
student rated, and the prompt + version that produced it.
|
||||||
|
- Resolve with status + optional note. Resolution is audited.
|
||||||
|
|
||||||
|
### 4.7 Taxonomy & resources
|
||||||
|
|
||||||
|
- **`/admin/taxonomy`** — hierarchical taxonomy editor (subject →
|
||||||
|
topic → sub-topic → skill). Used for tagging questions, embeddings,
|
||||||
|
and learning plans.
|
||||||
|
- **`/admin/resources`** — content resource manager (files, videos,
|
||||||
|
external links) with access control.
|
||||||
|
|
||||||
|
### 4.8 Reports
|
||||||
|
|
||||||
|
- **`/admin/reports`** — LMS reports dashboard (enrolment, attendance,
|
||||||
|
performance, revenue).
|
||||||
|
- **`/admin/student-performance`** — institution-wide performance
|
||||||
|
heatmap.
|
||||||
|
- **`/admin/stats-corporate`** — corporate-scoped KPIs (for
|
||||||
|
corporate / mastercorporate roles).
|
||||||
|
- **`/admin/record`** — audit trail / activity log browser.
|
||||||
|
- **`/admin/gradebook`** — master gradebook across all courses.
|
||||||
|
- **`/admin/student-progress`** — progress tracker for adaptive students.
|
||||||
|
|
||||||
|
All reports now use SQL aggregation under the hood, so pages load in
|
||||||
|
~hundreds of milliseconds even at institution scale.
|
||||||
|
|
||||||
|
### 4.9 Finance
|
||||||
|
|
||||||
|
- **`/admin/fees`** — fee structure, invoicing, per-student balance.
|
||||||
|
- **`/admin/payment-record`** — payment history (including Paymob
|
||||||
|
transactions with webhook-verified status).
|
||||||
|
- Paymob checkout is available to students via the subscription flow;
|
||||||
|
webhook events update order status automatically.
|
||||||
|
|
||||||
|
### 4.10 Governance & access control
|
||||||
|
|
||||||
|
- **`/admin/roles-permissions`** — role editor (what each role can do).
|
||||||
|
- **`/admin/authority-matrix`** — visual matrix: role × resource → CRUD
|
||||||
|
permissions.
|
||||||
|
- **`/admin/user-roles`** — assign roles to users.
|
||||||
|
- **`/admin/approval-workflows`** — define approval chains (e.g. exam
|
||||||
|
publish needs 2 approvers).
|
||||||
|
- **`/admin/approval-config`** — per-workflow configuration.
|
||||||
|
- **`/admin/notification-rules`** — which events trigger emails /
|
||||||
|
in-app notifications, to whom.
|
||||||
|
|
||||||
|
### 4.11 Content & UX management
|
||||||
|
|
||||||
|
- **`/admin/faq`** — manage the public FAQ (served at `/faq`).
|
||||||
|
- **`/admin/entity/:entityId/branding`** — white-label per corporate
|
||||||
|
entity: logo, colors, email sender.
|
||||||
|
- **`/admin/entity/:entityId/level-mapping`** — map your institution's
|
||||||
|
internal levels to CEFR.
|
||||||
|
|
||||||
|
### 4.12 Adaptive operations
|
||||||
|
|
||||||
|
- **`/admin/adaptive/dashboard`** — adaptive-engine health across all
|
||||||
|
students (average mastery, struggling-student alerts, remediation
|
||||||
|
success rate).
|
||||||
|
- **`/admin/adaptive/student/:studentId`** — deep dive into one
|
||||||
|
student's signals and auto-adjustments.
|
||||||
|
|
||||||
|
### 4.13 Classrooms, training modules, and legacy pages
|
||||||
|
|
||||||
|
- **`/admin/classrooms`** — physical classroom setup (rooms, capacity).
|
||||||
|
- **`/admin/training/vocabulary`** and **`/admin/training/grammar`** —
|
||||||
|
legacy training module editors kept for continuity with earlier
|
||||||
|
platform content.
|
||||||
|
- **`/admin/users`** — low-level user CRUD (prefer `/admin/students` /
|
||||||
|
`/admin/teachers` for day-to-day work).
|
||||||
|
- **`/admin/entities`** — multi-tenant entity manager
|
||||||
|
(institutions / corporates).
|
||||||
|
|
||||||
|
### 4.14 Tickets & support
|
||||||
|
|
||||||
|
- **`/admin/tickets`** — helpdesk inbox. Tickets fire notifications on
|
||||||
|
status and assignee change, so nobody is left hanging.
|
||||||
|
|
||||||
|
### 4.15 Settings
|
||||||
|
|
||||||
|
- **`/admin/settings`** — LMS-wide settings (term dates, grading scale,
|
||||||
|
email templates).
|
||||||
|
- **`/admin/settings-platform`** — platform-level settings (rate
|
||||||
|
limits, feature flags, integrations).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Privacy & language controls (all roles)
|
||||||
|
|
||||||
|
### 5.1 Privacy Center — `/<role>/privacy`
|
||||||
|
|
||||||
|
Available to students, teachers, and admins. Two actions:
|
||||||
|
|
||||||
|
1. **Export my data** — downloads a JSON file containing everything the
|
||||||
|
platform stores about you (profile, courses, attempts, messages,
|
||||||
|
logs). GDPR Article 20 compliant.
|
||||||
|
2. **Delete my account** — type `DELETE` to confirm. Effect:
|
||||||
|
- PII is anonymized.
|
||||||
|
- Personal records are removed.
|
||||||
|
- User ID is nulled on audit logs (logs themselves are kept for
|
||||||
|
legal retention).
|
||||||
|
- Account is deactivated and cannot sign in again.
|
||||||
|
- A tombstone record (`encoach.gdpr.erasure.request`) is written so
|
||||||
|
the institution has proof of compliance.
|
||||||
|
- Admins **cannot** self-erase via this flow (so there's always at
|
||||||
|
least one admin left). Admins must request erasure via another
|
||||||
|
admin.
|
||||||
|
|
||||||
|
### 5.2 Language toggle
|
||||||
|
|
||||||
|
Next to the theme toggle in every portal header. Choose **English** or
|
||||||
|
**العربية**. Arabic flips the UI to RTL automatically and the choice
|
||||||
|
is remembered across sessions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Troubleshooting & support
|
||||||
|
|
||||||
|
### 6.1 "I can't sign in"
|
||||||
|
|
||||||
|
- Double-check email (no leading / trailing space).
|
||||||
|
- Try **Forgot password** → reset.
|
||||||
|
- Check your email for a verification link if your account is brand new.
|
||||||
|
- If still stuck, contact your admin (students / teachers) or open a
|
||||||
|
support ticket via `/faq` (public) or `/admin/tickets` (admins).
|
||||||
|
|
||||||
|
### 6.2 "The AI coach feels stuck / slow"
|
||||||
|
|
||||||
|
- OpenAI calls time out at 30 s; if the provider is having a bad
|
||||||
|
minute, the page surfaces an error instead of hanging. Retry.
|
||||||
|
- If it happens repeatedly, report it from the thumbs-down button on
|
||||||
|
the coach — it lands in the admin AI Feedback Triage queue with
|
||||||
|
full context (prompt + version + output).
|
||||||
|
|
||||||
|
### 6.3 "An exam question looks wrong / biased / broken"
|
||||||
|
|
||||||
|
- Click **thumbs-down** under the item and leave a short comment. Admins
|
||||||
|
see it in `/admin/ai/feedback` and can acknowledge / fix / dismiss.
|
||||||
|
|
||||||
|
### 6.4 "My score is wrong"
|
||||||
|
|
||||||
|
- Open the exam results page (`/student/exam/:examId/results`) and use
|
||||||
|
**Request review**. It opens a ticket visible to your teacher and to
|
||||||
|
admin; they can override via `/admin/scores/pending`.
|
||||||
|
|
||||||
|
### 6.5 "Payment failed / hasn't reflected"
|
||||||
|
|
||||||
|
- Paymob updates arrive via a signed webhook, usually within seconds.
|
||||||
|
If your payment still shows as unpaid after 5 minutes:
|
||||||
|
- Students: open a support ticket from your dashboard.
|
||||||
|
- Admins: check `/admin/payment-record` and the `encoach.paymob.order`
|
||||||
|
logs — the last event payload and HMAC are stored for
|
||||||
|
investigation.
|
||||||
|
|
||||||
|
### 6.6 "The page is blank / shows a spinner forever"
|
||||||
|
|
||||||
|
- The app uses lazy-loaded routes; if the network chunk fails to load,
|
||||||
|
a refresh usually fixes it.
|
||||||
|
- Clear site data for the domain if the auth token is stuck.
|
||||||
|
- Open the browser console and share the error with your admin /
|
||||||
|
support.
|
||||||
|
|
||||||
|
### 6.7 Reporting issues to the platform team
|
||||||
|
|
||||||
|
- Admins: `/admin/tickets` → new ticket.
|
||||||
|
- Students & teachers: ask your institution admin, or use `/faq` for
|
||||||
|
self-service.
|
||||||
|
- Engineering escalation: open an issue on
|
||||||
|
`https://git.albousalh.com/yamen/full_encoach_platform` with:
|
||||||
|
1. What you were trying to do.
|
||||||
|
2. What happened (exact text of any error).
|
||||||
|
3. Your role (student / teacher / admin).
|
||||||
|
4. Approx. time of occurrence (so logs can be correlated via the
|
||||||
|
`X-Request-ID` that the backend emits).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Appendix A — Feature map at a glance
|
||||||
|
|
||||||
|
```
|
||||||
|
Student Teacher Admin
|
||||||
|
───────────── ─────────────────── ─────────────────────────────────────────
|
||||||
|
Dashboard Dashboard LMS Dashboard + Platform Dashboard
|
||||||
|
Courses Courses Courses / Batches / Lessons / Library
|
||||||
|
AI English Course Builder AI Prompt Editor
|
||||||
|
AI IELTS Chapters AI Feedback Triage
|
||||||
|
Adaptive path AI Workbench Exam review queue + detail
|
||||||
|
Placement Assignments Exam template builder (IELTS + custom)
|
||||||
|
Assignments Attendance Rubrics / Exam structures / Taxonomy
|
||||||
|
Grades Students Grading queue + Score approval queue
|
||||||
|
Attendance Library Reports (performance, corporate, gradebook)
|
||||||
|
Timetable Timetable Admissions / Departments / Academic years
|
||||||
|
Discussions Course Progress Roles / Authority matrix / Approval workflows
|
||||||
|
Announcements Discussions Paymob + fees + payment record
|
||||||
|
Messages Announcements Adaptive dashboard + per-student detail
|
||||||
|
Journey Adaptive Settings White-label branding + Level mapping
|
||||||
|
Profile Profile FAQ / Notification rules / Tickets
|
||||||
|
Privacy Privacy Settings (LMS + platform) / Privacy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Appendix B — Keyboard shortcuts
|
||||||
|
|
||||||
|
- **Sidebar collapse / expand** — `Ctrl/Cmd + B`
|
||||||
|
- **Global search** — `Ctrl/Cmd + K` (where present)
|
||||||
|
- **Submit form** — `Ctrl/Cmd + Enter` inside a form
|
||||||
|
- **Close dialog / modal** — `Esc`
|
||||||
|
|
||||||
|
### Appendix C — Supported browsers
|
||||||
|
|
||||||
|
- Chrome ≥ 115, Edge ≥ 115, Firefox ≥ 115, Safari ≥ 16.
|
||||||
|
- Mobile Safari (iOS 16+) and Chrome Android are supported for the
|
||||||
|
student portal. The admin portal is desktop-first.
|
||||||
329
docs/WORK_REPORT.md
Normal file
329
docs/WORK_REPORT.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# EnCoach — Work Report
|
||||||
|
|
||||||
|
**Period covered:** Recent hardening & delivery sprint (Phases 0 → 3 of the
|
||||||
|
improvement roadmap).
|
||||||
|
**Repository of record:** `https://git.albousalh.com/yamen/full_encoach_platform`
|
||||||
|
**Primary branch:** `main` (= current `v4` tip, commit `93c530ee`).
|
||||||
|
|
||||||
|
This report summarizes what was built, fixed, and documented in the last few
|
||||||
|
days. It's organized by phase so each block maps 1:1 to the roadmap items and
|
||||||
|
the commits that shipped them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of contents
|
||||||
|
|
||||||
|
- [Executive summary](#executive-summary)
|
||||||
|
- [What shipped — by phase](#what-shipped--by-phase)
|
||||||
|
- [Phase 0 — Platform safety & operations](#phase-0--platform-safety--operations)
|
||||||
|
- [Phase 1 — Exam correctness & data provenance](#phase-1--exam-correctness--data-provenance)
|
||||||
|
- [Phase 2 — Performance & observability](#phase-2--performance--observability)
|
||||||
|
- [Phase 3 — Human-in-the-loop, compliance, i18n, a11y, CI](#phase-3--human-in-the-loop-compliance-i18n-a11y-ci)
|
||||||
|
- [Branding update](#branding-update)
|
||||||
|
- [Repository migration](#repository-migration)
|
||||||
|
- [Commits landed in this sprint](#commits-landed-in-this-sprint)
|
||||||
|
- [Verification results](#verification-results)
|
||||||
|
- [Known gaps & deferred items](#known-gaps--deferred-items)
|
||||||
|
- [Operator checklist for production](#operator-checklist-for-production)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
| Area | Before | After |
|
||||||
|
| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------- |
|
||||||
|
| Duplicate exam models | Two `student.attempt` models in two modules | Single canonical `encoach.student.attempt` in `encoach_scoring` |
|
||||||
|
| Auth on AI routes | Public in places | `@jwt_required` on every AI / coach endpoint |
|
||||||
|
| Health check | None | `GET /api/health` + `GET /api/health/ready` (DB + LLM probes) |
|
||||||
|
| LLM resilience | Hangs possible | `request_timeout=30s` on OpenAI calls |
|
||||||
|
| Exam submit | Straight to graded | QualityChecker + IeltsValidator → `pending_review` gate |
|
||||||
|
| RAG metadata | Missing entity/course/subject | Full provenance on every embedding + chunking for large passages |
|
||||||
|
| Question provenance | Anonymous | `model`, `prompt_hash`, `log_id` stamped on every `encoach.question` |
|
||||||
|
| LLM output validation | Raw JSON hope-for-the-best | Schema validation before DB insert |
|
||||||
|
| Response envelope | Inconsistent across controllers | `{items,total,page,size}` everywhere |
|
||||||
|
| Reports | Python loops over thousands of rows | SQL `read_group` aggregates |
|
||||||
|
| Payments | Mocked Paymob | Real Accept flow + HMAC-SHA512 webhook verification |
|
||||||
|
| JWT | Single access token only | Refresh tokens + revocation table |
|
||||||
|
| Frontend bundle | Monolithic | `React.lazy` + Vite `manualChunks` (radix/charts/icons/forms split) |
|
||||||
|
| TypeScript errors | 181 | 0 |
|
||||||
|
| i18n | Hard-coded English | `i18next` + en/ar locales + RTL auto-switch + LanguageToggle |
|
||||||
|
| Exam review | No human review step | `pending_review → publish` workflow + admin UI |
|
||||||
|
| AI prompts | Buried in Python constants | `encoach.ai.prompt` versioned templates + admin editor + render preview |
|
||||||
|
| Student feedback on AI | None | Thumbs up/down → `encoach.ai.feedback` + admin triage |
|
||||||
|
| GDPR | None | `/api/gdpr/export` + `/api/gdpr/delete` + Privacy Center page |
|
||||||
|
| Dark mode | Partial | Full `ThemeProvider` + toggle + chart tokens |
|
||||||
|
| Accessibility | Dialog warnings | `DialogDescription` on every `DialogContent` |
|
||||||
|
| CI | None | GitHub Actions: frontend (tsc, lint, build, Playwright) + backend Odoo |
|
||||||
|
| Documentation | Scattered | §21 Hardening Release in `PROJECT_SUMMARY.md` + 4 ADRs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What shipped — by phase
|
||||||
|
|
||||||
|
### Phase 0 — Platform safety & operations
|
||||||
|
|
||||||
|
Goal: make the platform *safe to run* before adding features.
|
||||||
|
|
||||||
|
1. **Merged duplicate exam models.** `encoach_scoring` is now the single home
|
||||||
|
for `encoach.student.attempt` and `encoach.student.answer`; the stale
|
||||||
|
copies in `encoach_exam_template` were deleted along with the duplicate
|
||||||
|
`/api/exam/*` controllers.
|
||||||
|
2. **Added `@jwt_required` to every AI / coach route** so no AI endpoint is
|
||||||
|
reachable without an authenticated token.
|
||||||
|
3. **Added health endpoints**
|
||||||
|
- `GET /api/health` — liveness.
|
||||||
|
- `GET /api/health/ready` — readiness (DB ping + LLM reachability).
|
||||||
|
4. **Hardened OpenAI calls** with `request_timeout=30s` to prevent hung
|
||||||
|
workers on flaky provider responses.
|
||||||
|
5. **Gated `seed_demo_data.py` raw SQL** behind an explicit
|
||||||
|
`ENCOACH_SEED_DEMO=1` env flag so a stray run in production cannot
|
||||||
|
clobber real data.
|
||||||
|
6. **Fixed `docker-compose.yml`** and shipped `odoo-docker.conf` so the
|
||||||
|
container-local run works out of the box.
|
||||||
|
7. **Published `/logo.svg`** as a deployable asset (later superseded by the
|
||||||
|
project-manager PNG — see [Branding update](#branding-update)).
|
||||||
|
8. **Promoted a canonical `cefr_mapper`** to `encoach_ai.services.cefr_mapper`
|
||||||
|
(no more two copies drifting).
|
||||||
|
9. **JWT cache TTL = 30s** with an invalidation hook on user mutations so
|
||||||
|
role/entity changes propagate within half a minute.
|
||||||
|
|
||||||
|
### Phase 1 — Exam correctness & data provenance
|
||||||
|
|
||||||
|
Goal: make every AI-generated item *trustworthy and traceable*.
|
||||||
|
|
||||||
|
1. **QualityChecker + IeltsValidator wired into exam submit.** Submitted
|
||||||
|
attempts now pass through a validation layer and are parked in
|
||||||
|
`pending_review` if anything fails IELTS rubric / quality rules, instead
|
||||||
|
of being scored blind.
|
||||||
|
2. **Populated RAG metadata** on every `encoach.vector.embedding` record:
|
||||||
|
`course_id`, `subject_id`, `entity_id`, and taxonomy tags. Queries can
|
||||||
|
now be scoped per tenant / per skill.
|
||||||
|
3. **Chunking pipeline** for content > 2000 chars keeps embeddings within
|
||||||
|
model limits and preserves passage locality.
|
||||||
|
4. **Provenance fields on `encoach.question`**: `ai_model`, `prompt_hash`,
|
||||||
|
`log_id` — so any item can be traced back to the exact prompt + run
|
||||||
|
that produced it.
|
||||||
|
5. **Schema validation of LLM output** before DB insert (jsonschema-style).
|
||||||
|
Malformed JSON no longer reaches storage.
|
||||||
|
6. **Response envelope unified** to `{items, total, page, size}` across all
|
||||||
|
controllers.
|
||||||
|
7. **Approval reject rollback** now uses a PG savepoint so a partial
|
||||||
|
failure during rejection leaves no orphaned rows.
|
||||||
|
8. **Ticket notifications** fire on status and assignee change.
|
||||||
|
9. **`new_project/` retired.** `backend/` is declared canonical;
|
||||||
|
`new_project/DEPRECATED.md` carries the redirect notice.
|
||||||
|
|
||||||
|
### Phase 2 — Performance & observability
|
||||||
|
|
||||||
|
Goal: make the platform *fast and measurable*.
|
||||||
|
|
||||||
|
1. **Reports use SQL `read_group`** instead of Python loops. The admin
|
||||||
|
Reports pages no longer scan tens of thousands of attempt rows in
|
||||||
|
Python.
|
||||||
|
2. **`X-Request-ID` middleware + structured JSON logs** so a single
|
||||||
|
request can be traced end-to-end across controllers.
|
||||||
|
3. **Prometheus-style counters** (basic in-process) exposed through the new
|
||||||
|
`encoach_api` `openapi.py` controller, which also exports a live
|
||||||
|
OpenAPI spec by scanning `@http.route` decorators at runtime.
|
||||||
|
4. **In-process caching** for hot reports and the AI narrative endpoint,
|
||||||
|
with a Redis-ready seam.
|
||||||
|
5. **Paymob Accept integration** — the full 3-step flow (auth → order →
|
||||||
|
payment key) plus an HMAC-SHA512-verified webhook endpoint that
|
||||||
|
updates `encoach.paymob.order` atomically. Credentials come from
|
||||||
|
`ir.config_parameter`.
|
||||||
|
6. **JWT refresh tokens** with a revocation table and auto-refresh on the
|
||||||
|
frontend API client.
|
||||||
|
7. **Composite DB indexes** on the hottest report / ticket / attempt paths.
|
||||||
|
8. **React.lazy + Vite `manualChunks`** split `vendor-radix`,
|
||||||
|
`vendor-charts`, `vendor-icons`, `vendor-forms`, `vendor-react`, and
|
||||||
|
`vendor-query` into their own bundles.
|
||||||
|
9. **Fixed all 181 TypeScript errors.** `npx tsc --noEmit -p
|
||||||
|
tsconfig.app.json` now passes cleanly.
|
||||||
|
|
||||||
|
### Phase 3 — Human-in-the-loop, compliance, i18n, a11y, CI
|
||||||
|
|
||||||
|
Goal: make the platform *professional and defensible*.
|
||||||
|
|
||||||
|
1. **i18n.** `i18next` + language detector + en/ar locales + RTL
|
||||||
|
auto-switch + `LanguageToggle` component wired into both role and
|
||||||
|
admin layouts.
|
||||||
|
2. **GDPR compliance.** `/api/gdpr/export` returns a JSON snapshot of the
|
||||||
|
caller's personal data. `/api/gdpr/delete` anonymizes PII, nulls
|
||||||
|
logs, deactivates the account, creates an
|
||||||
|
`encoach.gdpr.erasure.request` tombstone, and refuses admin
|
||||||
|
self-erasure. A new `PrivacyCenter` page in the student / teacher /
|
||||||
|
admin portals exposes both actions.
|
||||||
|
3. **Human-in-the-loop exam review.** `pending_review → publish`
|
||||||
|
workflow, backed by a new `review_workflow.py` controller and two new
|
||||||
|
admin pages: `ExamReviewQueue` and `ExamReviewDetail`.
|
||||||
|
4. **AI prompt management.** New `encoach.ai.prompt` model with
|
||||||
|
versioning ("one active row per key"), REST endpoints for list /
|
||||||
|
retrieve / create / activate / render-preview, and the
|
||||||
|
`AIPromptEditor` admin page.
|
||||||
|
5. **Student AI feedback loop.** `encoach.ai.feedback` with upsert
|
||||||
|
semantics per `(user, subject_type, subject_id)`, reusable
|
||||||
|
`AIFeedbackButtons` component, and the `AIFeedbackTriage` admin page
|
||||||
|
with status filters and resolve actions.
|
||||||
|
6. **Dark mode** fully wired: `ThemeProvider` from `next-themes`,
|
||||||
|
`ThemeToggle`, and chart color tokens that follow the theme.
|
||||||
|
7. **Accessibility.** `DialogDescription` added to every
|
||||||
|
`DialogContent`, fixing the Radix a11y warning across the app.
|
||||||
|
8. **CI scaffolding.** `.github/workflows/ci.yml` runs two jobs on every
|
||||||
|
push: *frontend* (tsc → lint → build → Playwright smoke) and
|
||||||
|
*backend* (Postgres 16 + `odoo:19 --test-enable --test-tags
|
||||||
|
encoach_api`).
|
||||||
|
9. **Onboarding consolidated.** Single root `README.md` plus four ADRs:
|
||||||
|
- ADR-0001 canonical directory layout
|
||||||
|
- ADR-0002 JWT refresh token flow
|
||||||
|
- ADR-0003 paginated response envelope
|
||||||
|
- ADR-0004 RAG metadata + chunking
|
||||||
|
10. **Dead code removed.** `ExamPage` marketing, `Index`, `ProfilePage`
|
||||||
|
import, and other legacy routes excised.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding update
|
||||||
|
|
||||||
|
The project-manager-supplied `EnCoach` logo (icon + wordmark + tagline
|
||||||
|
"Unlock your potential with AI powered platform") now appears across:
|
||||||
|
|
||||||
|
- **Login page** — shown at `h-36 w-auto` so the wordmark and tagline are
|
||||||
|
legible.
|
||||||
|
- **Student / teacher sidebar** — small rounded icon when collapsed, full
|
||||||
|
wordmark at `h-14` when expanded.
|
||||||
|
- **Admin sidebar** — same collapsed / expanded behavior.
|
||||||
|
- **`og:image` meta** (social cards) — already pointed at
|
||||||
|
`/logo-icon.png`, so it picks up the new asset automatically.
|
||||||
|
|
||||||
|
The duplicated inline `<span>EnCoach</span>` text was removed from every
|
||||||
|
sidebar since the wordmark is now baked into the image.
|
||||||
|
|
||||||
|
Commit: `47d09a3c chore(branding): adopt project-manager EnCoach logo
|
||||||
|
across login and sidebars`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository migration
|
||||||
|
|
||||||
|
To consolidate the codebase into a single canonical repo for future work,
|
||||||
|
the entire monorepo was migrated to:
|
||||||
|
|
||||||
|
- **`https://git.albousalh.com/yamen/full_encoach_platform`**
|
||||||
|
|
||||||
|
Migration steps performed:
|
||||||
|
|
||||||
|
1. Added the new repo as the `origin` remote.
|
||||||
|
2. Pushed the current `v4` tip as `main` (default branch).
|
||||||
|
3. Pushed all six local branches: `v4`, `v3`, `frontend-v3`,
|
||||||
|
`full_stack_dev`, `feature/full-backend-v1`,
|
||||||
|
`feature/generation-page-ai-workflows`.
|
||||||
|
4. Pushed all tags (none existed locally).
|
||||||
|
5. Rebound `v4` upstream to `origin/v4`.
|
||||||
|
6. **Removed all seven prior remotes** from `.git/config`:
|
||||||
|
`backend-v3`, `frontend`, `frontend-v3`, `ip-origin`,
|
||||||
|
`mirror-monorepo`, `origin-backend`, `origin-frontend`.
|
||||||
|
|
||||||
|
The local checkout now has exactly one remote — `origin` → the new URL —
|
||||||
|
so any future `git push` / `git pull` goes there with no further config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commits landed in this sprint
|
||||||
|
|
||||||
|
```
|
||||||
|
93c530ee chore(ci,docs): GitHub Actions, ADRs, README overhaul, §21 Hardening Release
|
||||||
|
e70a2854 feat(frontend): Phase 2/3 hardening release
|
||||||
|
dcf5ea69 feat(backend): Phase 2/3 hardening release
|
||||||
|
47d09a3c chore(branding): adopt project-manager EnCoach logo across login and sidebars
|
||||||
|
c016a522 feat(reports): replace mock Reports pages with real backend aggregates
|
||||||
|
d940db07 fix(config): align Configuration pages with platform logic
|
||||||
|
7f23127e chore(remotes,docs): rename remotes to match source-of-truth doctrine
|
||||||
|
7737f6de docs(summary): declare encoach_backend_v4 + encoach_frontend_v4 as repos of record
|
||||||
|
```
|
||||||
|
|
||||||
|
File-churn totals for the four hardening commits:
|
||||||
|
|
||||||
|
| Commit | Files changed | Lines + | Lines − |
|
||||||
|
| ----------------------- | ------------- | ------- | ------- |
|
||||||
|
| branding (logo) | 4 | +42 | −18 |
|
||||||
|
| backend hardening | 70 | +4 221 | −716 |
|
||||||
|
| frontend hardening | 75 | +3 722 | −546 |
|
||||||
|
| CI + docs + ADRs | 10 | +740 | −96 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification results
|
||||||
|
|
||||||
|
Ran before each commit and again after the final commit:
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
| ------------------------------------------------------ | ----------------------------------------- |
|
||||||
|
| `python -m compileall backend/custom_addons/encoach_*` | Pass, no syntax errors |
|
||||||
|
| `npx tsc --noEmit -p tsconfig.app.json` | Pass (0 errors, down from 181) |
|
||||||
|
| `npm run build` | Pass — 3.63 s, bundles split as expected |
|
||||||
|
| `python -m compileall seed_demo_data.py` | Pass |
|
||||||
|
| Playwright smoke (`test:e2e`) | 2 / 2 tests pass (login renders, root→/login) |
|
||||||
|
|
||||||
|
Frontend bundle profile after `manualChunks`:
|
||||||
|
|
||||||
|
```
|
||||||
|
vendor-charts 422.80 kB │ gzip: 112.73 kB
|
||||||
|
vendor-radix 318.60 kB │ gzip: 99.50 kB
|
||||||
|
index (app shell) 219.02 kB │ gzip: 65.36 kB
|
||||||
|
vendor-forms 88.31 kB │ gzip: 24.40 kB
|
||||||
|
vendor-icons 53.77 kB │ gzip: 9.83 kB
|
||||||
|
vendor-query 41.17 kB │ gzip: 12.23 kB
|
||||||
|
vendor-react 23.33 kB │ gzip: 8.60 kB
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known gaps & deferred items
|
||||||
|
|
||||||
|
These were intentionally **not** addressed in this sprint to keep scope
|
||||||
|
bounded. Each has a clear next owner / next step.
|
||||||
|
|
||||||
|
- **P1.2 — `ir.rule` entity isolation + drop blanket `sudo()`.** Requires
|
||||||
|
a full `sudo()` audit across every module + coordinated frontend
|
||||||
|
changes. Deferred to a follow-up ticket.
|
||||||
|
- **AIFeedbackButtons inside `AiStudyCoach`.** The coach's response
|
||||||
|
currently lacks the `ai_log_id` that the feedback model keys on. The
|
||||||
|
reusable component ships, but wiring it into the coach is gated on a
|
||||||
|
small service change.
|
||||||
|
- **Redis cache backend.** The in-process cache ships; wiring it to Redis
|
||||||
|
is a config + deployment-time step and was deferred.
|
||||||
|
- **Paymob credentials.** `payment.paymob.api_key`,
|
||||||
|
`payment.paymob.integration_id`, `payment.paymob.hmac_secret`, and
|
||||||
|
`payment.paymob.iframe_id` must be set in `ir.config_parameter`
|
||||||
|
before checkout works. Left unset on purpose — no secrets in git.
|
||||||
|
- **Arabic translation coverage.** Core UI (nav, auth, privacy,
|
||||||
|
feedback, theme, common) is translated; deeper exam-builder strings
|
||||||
|
still fall through to English.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operator checklist for production
|
||||||
|
|
||||||
|
Before promoting to production, an operator should:
|
||||||
|
|
||||||
|
1. **Set Paymob credentials** in `ir.config_parameter`:
|
||||||
|
- `payment.paymob.api_key`
|
||||||
|
- `payment.paymob.integration_id`
|
||||||
|
- `payment.paymob.hmac_secret`
|
||||||
|
- `payment.paymob.iframe_id`
|
||||||
|
2. **Set the OpenAI key** in `ir.config_parameter` as `openai.api_key`
|
||||||
|
(or the matching env var on the container).
|
||||||
|
3. **Run migrations** for the new modules / models:
|
||||||
|
`encoach.ai.prompt`, `encoach.ai.feedback`,
|
||||||
|
`encoach.gdpr.erasure.request`, `encoach.paymob.order`,
|
||||||
|
`encoach.jwt.token` (refresh revocation).
|
||||||
|
4. **Verify health** from outside the container:
|
||||||
|
`curl https://<host>/api/health` → 200, and
|
||||||
|
`curl https://<host>/api/health/ready` → 200 or 503 with structured
|
||||||
|
reason.
|
||||||
|
5. **Pin the default-branch protection** on
|
||||||
|
`git.albousalh.com/yamen/full_encoach_platform` (`main`).
|
||||||
|
6. **Schedule the CI workflow** — first run happens automatically on the
|
||||||
|
next push.
|
||||||
|
7. **Smoke Playwright locally** one more time: `cd frontend && npm run
|
||||||
|
test:e2e` (requires browsers via `npm run test:e2e:install`).
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;500;600;700;800&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
@@ -80,6 +80,7 @@
|
|||||||
"lovable-tagger": "^1.1.13",
|
"lovable-tagger": "^1.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
@@ -7300,6 +7301,13 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders"
|
"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": {
|
"node_modules/tailwindcss/node_modules/postcss-selector-parser": {
|
||||||
"version": "6.1.2",
|
"version": "6.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
"lovable-tagger": "^1.1.13",
|
"lovable-tagger": "^1.1.13",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-rtl": "^0.9.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0",
|
||||||
"vite": "^5.4.19",
|
"vite": "^5.4.19",
|
||||||
|
|||||||
@@ -34,115 +34,123 @@ import {
|
|||||||
Library, Activity, Warehouse, UserCog, Sparkles,
|
Library, Activity, Warehouse, UserCog, Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// ============= Navigation Config =============
|
// ============= 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[] = [
|
const overviewItems: NavItem[] = [
|
||||||
{ title: "Admin Dashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "Platform Dashboard", url: "/admin/platform", icon: BarChart3 },
|
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const lmsItems: NavItem[] = [
|
const lmsItems: NavItem[] = [
|
||||||
{ title: "Courses", url: "/admin/courses", icon: BookOpen },
|
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
|
||||||
{ title: "Students", url: "/admin/students", icon: Users },
|
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
|
||||||
{ title: "Teachers", url: "/admin/teachers", icon: GraduationCap },
|
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
|
||||||
{ title: "Batches", url: "/admin/batches", icon: Layers },
|
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
|
||||||
{ title: "Timetable", url: "/admin/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
|
||||||
{ title: "Reports", url: "/admin/reports", icon: BarChart3 },
|
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const academicItems: NavItem[] = [
|
const academicItems: NavItem[] = [
|
||||||
{ title: "Assignments", url: "/admin/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
|
||||||
{ title: "Exams List", url: "/admin/examsList", icon: FileText },
|
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
|
||||||
{ title: "Exam Structures", url: "/admin/exam-structures", icon: Layers },
|
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
|
||||||
{ title: "Rubrics", url: "/admin/rubrics", icon: BookOpen },
|
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
|
||||||
{ title: "Generation", url: "/admin/generation", icon: Wand2 },
|
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
|
||||||
{ title: "Review Queue", url: "/admin/exam/review-queue", icon: Sparkles },
|
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
|
||||||
{ title: "AI Prompts", url: "/admin/ai/prompts", icon: Wand2 },
|
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
|
||||||
{ title: "AI Feedback", url: "/admin/ai/feedback", icon: Sparkles },
|
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
|
||||||
{ title: "Approval Workflows", url: "/admin/approval-workflows", icon: GitBranch },
|
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adaptiveItems: NavItem[] = [
|
const adaptiveItems: NavItem[] = [
|
||||||
{ title: "Taxonomy", url: "/admin/taxonomy", icon: Target },
|
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
|
||||||
{ title: "Resources", url: "/admin/resources", icon: FolderOpen },
|
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
|
||||||
];
|
];
|
||||||
|
|
||||||
const institutionalItems: NavItem[] = [
|
const institutionalItems: NavItem[] = [
|
||||||
{ title: "Academic Years", url: "/admin/academic-years", icon: CalendarDays },
|
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
|
||||||
{ title: "Departments", url: "/admin/departments", icon: Landmark },
|
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
|
||||||
{ title: "Admissions", url: "/admin/admissions", icon: UserPlus },
|
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
|
||||||
{ title: "Admission Register", url: "/admin/admission-register", icon: ScrollText },
|
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
|
||||||
{ title: "Exam Sessions", url: "/admin/exam-sessions", icon: FileText },
|
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
|
||||||
{ title: "Marksheets", url: "/admin/marksheets", icon: Award },
|
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
|
||||||
{ title: "Student Leave", url: "/admin/student-leave", icon: CalendarOff },
|
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
|
||||||
{ title: "Fees & Payments", url: "/admin/fees", icon: DollarSign },
|
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
|
||||||
{ title: "Lessons", url: "/admin/lessons", icon: BookMarked },
|
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
|
||||||
{ title: "Gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
|
||||||
{ title: "Student Progress", url: "/admin/student-progress", icon: TrendingUp },
|
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
|
||||||
{ title: "Library", url: "/admin/library", icon: Library },
|
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
|
||||||
{ title: "Activities", url: "/admin/activities", icon: Activity },
|
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
|
||||||
{ title: "Facilities", url: "/admin/facilities", icon: Warehouse },
|
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
|
||||||
];
|
];
|
||||||
|
|
||||||
const managementItems: NavItem[] = [
|
const managementItems: NavItem[] = [
|
||||||
{ title: "Users", url: "/admin/users", icon: Users },
|
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
|
||||||
{ title: "Entities", url: "/admin/entities", icon: Building2 },
|
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
|
||||||
{ title: "Classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
|
||||||
{ title: "User Roles", url: "/admin/user-roles", icon: UserCog },
|
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
|
||||||
{ title: "Roles & Permissions", url: "/admin/roles-permissions", icon: Settings },
|
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
|
||||||
{ title: "Authority Matrix", url: "/admin/authority-matrix", icon: Workflow },
|
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
|
||||||
];
|
];
|
||||||
|
|
||||||
const reportItems: NavItem[] = [
|
const reportItems: NavItem[] = [
|
||||||
{ title: "Student Performance", url: "/admin/student-performance", icon: BarChart3 },
|
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
|
||||||
{ title: "Stats Corporate", url: "/admin/stats-corporate", icon: Building2 },
|
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
|
||||||
{ title: "Record", url: "/admin/record", icon: History },
|
{ titleKey: "nav.record", url: "/admin/record", icon: History },
|
||||||
];
|
];
|
||||||
|
|
||||||
const trainingItems: NavItem[] = [
|
const trainingItems: NavItem[] = [
|
||||||
{ title: "Vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
|
||||||
{ title: "Grammar", url: "/admin/training/grammar", icon: PenTool },
|
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
|
||||||
];
|
];
|
||||||
|
|
||||||
const configItems: NavItem[] = [
|
const configItems: NavItem[] = [
|
||||||
{ title: "FAQ Manager", url: "/admin/faq", icon: FaqIcon },
|
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
|
||||||
{ title: "Notification Rules", url: "/admin/notification-rules", icon: Bell },
|
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
|
||||||
{ title: "Approval Config", url: "/admin/approval-config", icon: Workflow },
|
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
|
||||||
];
|
];
|
||||||
|
|
||||||
const supportItems: NavItem[] = [
|
const supportItems: NavItem[] = [
|
||||||
{ title: "Payment Record", url: "/admin/payment-record", icon: CreditCard },
|
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
|
||||||
{ title: "Tickets", url: "/admin/tickets", icon: Ticket },
|
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
|
||||||
{ title: "Settings", url: "/admin/settings-platform", icon: Settings },
|
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ============= Reusable sidebar group =============
|
// ============= Reusable sidebar group =============
|
||||||
function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] }) {
|
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { t } = useTranslation();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => (
|
{items.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
<NavLink
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
to={item.url}
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
|
||||||
>
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -150,42 +158,52 @@ function SidebarNavGroup({ label, items }: { label: string; items: NavItem[] })
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============= Breadcrumbs =============
|
// ============= Breadcrumbs =============
|
||||||
const routeLabels: Record<string, string> = {
|
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
|
||||||
admin: "Admin", dashboard: "Dashboard", platform: "Platform", courses: "Courses",
|
// Segments without an entry fall back to a Title-cased version of the slug.
|
||||||
students: "Students", teachers: "Teachers", batches: "Batches", timetable: "Timetable",
|
const routeLabelKeys: Record<string, string> = {
|
||||||
reports: "Reports", assignments: "Assignments", examsList: "Exams List",
|
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
|
||||||
"exam-structures": "Exam Structures", rubrics: "Rubrics", generation: "Generation",
|
platform: "breadcrumb.platform", courses: "nav.courses",
|
||||||
"review-queue": "Review Queue", review: "Review", prompts: "AI Prompts", ai: "AI",
|
students: "nav.students", teachers: "nav.teachers", batches: "nav.batches",
|
||||||
feedback: "Feedback",
|
timetable: "nav.timetable", reports: "nav.reports",
|
||||||
"approval-workflows": "Approval Workflows", users: "Users", entities: "Entities",
|
assignments: "nav.assignments", examsList: "nav.examsList",
|
||||||
classrooms: "Classrooms", "student-performance": "Student Performance",
|
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
|
||||||
"stats-corporate": "Stats Corporate", record: "Record", training: "Training",
|
generation: "nav.generation", "review-queue": "nav.reviewQueue",
|
||||||
vocabulary: "Vocabulary", grammar: "Grammar", "payment-record": "Payment Record",
|
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
|
||||||
tickets: "Tickets", "settings-platform": "Settings", settings: "Settings",
|
feedback: "breadcrumb.feedback",
|
||||||
profile: "Profile", exam: "Exam",
|
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
|
||||||
"academic-years": "Academic Years", departments: "Departments",
|
entities: "nav.entities", classrooms: "nav.classrooms",
|
||||||
admissions: "Admissions", "admission-register": "Admission Register",
|
"student-performance": "nav.studentPerformance",
|
||||||
"exam-sessions": "Exam Sessions", marksheets: "Marksheets",
|
"stats-corporate": "nav.statsCorporate", record: "nav.record",
|
||||||
faq: "FAQ Manager", "notification-rules": "Notification Rules",
|
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
|
||||||
"approval-config": "Approval Config",
|
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
|
||||||
"student-leave": "Student Leave", fees: "Fees & Payments", lessons: "Lessons",
|
tickets: "nav.tickets", "settings-platform": "nav.settings",
|
||||||
gradebook: "Gradebook", "student-progress": "Student Progress",
|
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
|
||||||
library: "Library", activities: "Activities", facilities: "Facilities",
|
"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() {
|
function AppBreadcrumbs() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
const segments = location.pathname.split("/").filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
<BreadcrumbItem>
|
<BreadcrumbItem>
|
||||||
<BreadcrumbLink asChild><Link to="/admin/dashboard">Home</Link></BreadcrumbLink>
|
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
|
||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
{segments.map((seg, i) => {
|
{segments.map((seg, i) => {
|
||||||
const path = "/" + segments.slice(0, i + 1).join("/");
|
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;
|
const isLast = i === segments.length - 1;
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={path}>
|
<React.Fragment key={path}>
|
||||||
@@ -205,6 +223,7 @@ function AppBreadcrumbs() {
|
|||||||
export default function AdminLmsLayout() {
|
export default function AdminLmsLayout() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
|
||||||
|
|
||||||
@@ -220,7 +239,7 @@ export default function AdminLmsLayout() {
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||||
<AppBreadcrumbs />
|
<AppBreadcrumbs />
|
||||||
</div>
|
</div>
|
||||||
<AiSearchBar />
|
<AiSearchBar />
|
||||||
@@ -228,7 +247,7 @@ export default function AdminLmsLayout() {
|
|||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
|
||||||
<Ticket className="h-4 w-4" />
|
<Ticket className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -241,19 +260,19 @@ export default function AdminLmsLayout() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-sm font-medium">{user?.name ?? "User"}</p>
|
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
|
||||||
<User className="mr-2 h-4 w-4" /> Profile
|
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
|
||||||
<Settings className="mr-2 h-4 w-4" /> Settings
|
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
@@ -267,10 +286,10 @@ export default function AdminLmsLayout() {
|
|||||||
<AiAssistantDrawer />
|
<AiAssistantDrawer />
|
||||||
<Link
|
<Link
|
||||||
to="/admin/tickets"
|
to="/admin/tickets"
|
||||||
className="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
className="fixed bottom-6 end-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
>
|
>
|
||||||
<HelpCircle className="h-4 w-4" />
|
<HelpCircle className="h-4 w-4" />
|
||||||
<span className="text-sm font-medium">Need help?</span>
|
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
);
|
);
|
||||||
@@ -282,25 +301,27 @@ function AdminSidebar() {
|
|||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { hasAnyPermission, isAdmin } = usePermissions();
|
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 showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
|
||||||
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
|
||||||
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="EnCoach"
|
alt={t("chrome.adminAlt")}
|
||||||
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
className="h-9 w-9 shrink-0 rounded-lg object-contain"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="EnCoach — Unlock your potential with AI powered platform"
|
alt={t("chrome.adminAltLong")}
|
||||||
className="h-14 w-auto shrink-0 object-contain"
|
className="h-14 w-auto shrink-0 object-contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -308,47 +329,50 @@ function AdminSidebar() {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<SidebarNavGroup label="Overview" items={overviewItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
|
||||||
<SidebarNavGroup label="LMS" items={lmsItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
|
||||||
<SidebarNavGroup label="Adaptive Learning" items={adaptiveItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
|
||||||
<SidebarNavGroup label="Institutional" items={institutionalItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
|
||||||
<SidebarNavGroup label="Academic" items={academicItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
|
||||||
{showManagement && <SidebarNavGroup label="Management" items={managementItems} />}
|
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
|
||||||
{showReports && <SidebarNavGroup label="Reports" items={reportItems} />}
|
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
|
||||||
<SidebarNavGroup label="Configuration" items={configItems} />
|
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
|
||||||
|
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<Collapsible defaultOpen className="group/collapsible">
|
<Collapsible defaultOpen className="group/collapsible">
|
||||||
<SidebarGroupLabel asChild>
|
<SidebarGroupLabel asChild>
|
||||||
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
<CollapsibleTrigger className="flex w-full items-center justify-between">
|
||||||
Training
|
{t("sidebarGroup.training")}
|
||||||
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
</SidebarGroupLabel>
|
</SidebarGroupLabel>
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{trainingItems.map((item) => (
|
{trainingItems.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
<NavLink
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
to={item.url}
|
||||||
>
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</CollapsibleContent>
|
</CollapsibleContent>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
|
|
||||||
<SidebarNavGroup label="Support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarSeparator />
|
<SidebarSeparator />
|
||||||
<SidebarFooter className="p-3">
|
<SidebarFooter className="p-3">
|
||||||
@@ -358,7 +382,7 @@ function AdminSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? "User"}</span>
|
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
|
||||||
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { NavLink } from "@/components/NavLink";
|
import { NavLink } from "@/components/NavLink";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
|
||||||
@@ -81,9 +82,11 @@ export function AppSidebar() {
|
|||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center shrink-0">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import { withTranslation, type WithTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface Props {
|
interface Props extends WithTranslation {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fallback?: ReactNode;
|
fallback?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -10,7 +11,12 @@ interface State {
|
|||||||
error: Error | null;
|
error: Error | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
/**
|
||||||
|
* Error boundary is a classical React class component, so we bridge it to
|
||||||
|
* i18next via the `withTranslation` HOC. That keeps the tree-shaking of
|
||||||
|
* `react-i18next`'s hook path intact while giving us a `t` prop here.
|
||||||
|
*/
|
||||||
|
class ErrorBoundaryInner extends Component<Props, State> {
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props);
|
super(props);
|
||||||
this.state = { hasError: false, error: null };
|
this.state = { hasError: false, error: null };
|
||||||
@@ -27,6 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
render() {
|
render() {
|
||||||
if (this.state.hasError) {
|
if (this.state.hasError) {
|
||||||
if (this.props.fallback) return this.props.fallback;
|
if (this.props.fallback) return this.props.fallback;
|
||||||
|
const { t } = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||||
@@ -36,13 +43,13 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
<h2 className="text-xl font-semibold">{t("errors.somethingWrong")}</h2>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
An unexpected error occurred. Please try refreshing the page.
|
{t("errors.unexpectedDescription")}
|
||||||
</p>
|
</p>
|
||||||
{this.state.error && (
|
{this.state.error && (
|
||||||
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
<details className="text-start text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||||
<summary className="cursor-pointer font-medium">Error details</summary>
|
<summary className="cursor-pointer font-medium">{t("errors.errorDetails")}</summary>
|
||||||
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +57,7 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
onClick={() => window.location.reload()}
|
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"
|
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||||
>
|
>
|
||||||
Refresh Page
|
{t("errors.refreshPage")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,3 +67,5 @@ export class ErrorBoundary extends Component<Props, State> {
|
|||||||
return this.props.children;
|
return this.props.children;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ErrorBoundary = withTranslation()(ErrorBoundaryInner);
|
||||||
|
|||||||
@@ -12,18 +12,23 @@ import {
|
|||||||
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
|
import { SUPPORTED_LANGS, type SupportedLang } from "@/i18n";
|
||||||
|
|
||||||
export function LanguageToggle() {
|
export function LanguageToggle() {
|
||||||
const { i18n } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
|
const current = (i18n.language?.split("-")[0] || "en") as SupportedLang;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label="Change language">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("language.change")}
|
||||||
|
title={t("language.change")}
|
||||||
|
>
|
||||||
<Languages className="h-5 w-5" />
|
<Languages className="h-5 w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuLabel>Language</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("language.label")}</DropdownMenuLabel>
|
||||||
{SUPPORTED_LANGS.map((lang) => (
|
{SUPPORTED_LANGS.map((lang) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
|
|||||||
@@ -1,35 +1,50 @@
|
|||||||
import { Bell } from "lucide-react";
|
import { Bell } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
|
||||||
export default function NotificationDropdown() {
|
export default function NotificationDropdown() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
const notifications: { id: string; title: string; message: string; time: string; read: boolean; type: string }[] = [];
|
||||||
const unread = notifications.filter((n) => !n.read).length;
|
const unread = notifications.filter((n) => !n.read).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="relative text-muted-foreground hover:text-foreground">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label={t("notifications.ariaLabel")}
|
||||||
|
title={t("notifications.ariaLabel")}
|
||||||
|
className="relative text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
{unread > 0 && (
|
{unread > 0 && (
|
||||||
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
<span className="absolute -top-0.5 -end-0.5 h-4 w-4 rounded-full bg-destructive text-[10px] font-bold text-destructive-foreground flex items-center justify-center">
|
||||||
{unread}
|
{unread}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
<div className="px-3 py-2 font-semibold text-sm">Notifications</div>
|
<div className="px-3 py-2 font-semibold text-sm">{t("notifications.title")}</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{notifications.slice(0, 4).map((n) => (
|
{notifications.length === 0 ? (
|
||||||
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
{t("notifications.empty")}
|
||||||
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
) : (
|
||||||
</DropdownMenuItem>
|
notifications.slice(0, 4).map((n) => (
|
||||||
))}
|
<DropdownMenuItem key={n.id} className="flex flex-col items-start gap-0.5 py-2.5 cursor-pointer">
|
||||||
|
<span className={`text-sm font-medium ${!n.read ? "text-foreground" : "text-muted-foreground"}`}>{n.title}</span>
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-1">{n.message}</span>
|
||||||
|
<span className="text-[10px] text-muted-foreground/70">{n.time}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import {
|
import {
|
||||||
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
|
||||||
@@ -15,17 +16,21 @@ import {
|
|||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
|
||||||
DropdownMenuSeparator, DropdownMenuTrigger,
|
DropdownMenuSeparator, DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { LogOut, User, Settings, LucideIcon } from "lucide-react";
|
import { LogOut, User, LucideIcon } from "lucide-react";
|
||||||
import React from "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 {
|
export interface NavItem {
|
||||||
title: string;
|
titleKey: string;
|
||||||
url: string;
|
url: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NavGroup {
|
export interface NavGroup {
|
||||||
label: string;
|
labelKey: string;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,30 +41,34 @@ interface RoleLayoutProps {
|
|||||||
|
|
||||||
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
||||||
const { state } = useSidebar();
|
const { state } = useSidebar();
|
||||||
|
const { t } = useTranslation();
|
||||||
const collapsed = state === "collapsed";
|
const collapsed = state === "collapsed";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{navGroups.map((group) => (
|
{navGroups.map((group) => (
|
||||||
<SidebarGroup key={group.label}>
|
<SidebarGroup key={group.labelKey}>
|
||||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{t(group.labelKey)}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => {
|
||||||
<SidebarMenuItem key={item.url}>
|
const label = t(item.titleKey);
|
||||||
<SidebarMenuButton asChild tooltip={item.title}>
|
return (
|
||||||
<NavLink
|
<SidebarMenuItem key={item.url}>
|
||||||
to={item.url}
|
<SidebarMenuButton asChild tooltip={label}>
|
||||||
end={item.url.endsWith("/dashboard")}
|
<NavLink
|
||||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
to={item.url}
|
||||||
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
end={item.url.endsWith("/dashboard")}
|
||||||
>
|
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
|
||||||
<item.icon className="h-4 w-4 shrink-0" />
|
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||||
{!collapsed && <span>{item.title}</span>}
|
>
|
||||||
</NavLink>
|
<item.icon className="h-4 w-4 shrink-0" />
|
||||||
</SidebarMenuButton>
|
{!collapsed && <span>{label}</span>}
|
||||||
</SidebarMenuItem>
|
</NavLink>
|
||||||
))}
|
</SidebarMenuButton>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarGroupContent>
|
</SidebarGroupContent>
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
@@ -71,6 +80,8 @@ function SidebarNav({ navGroups }: { navGroups: NavGroup[] }) {
|
|||||||
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
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() ?? "??";
|
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");
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const roleLabel = t(`roles.${role}`, { defaultValue: role });
|
||||||
|
const portalTitle = `${roleLabel} ${t("chrome.portalSuffix")}`.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<div className="min-h-screen flex w-full">
|
<div className="min-h-screen flex w-full">
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar side={sidebarSide} collapsible="icon">
|
||||||
<SidebarHeader className="p-4">
|
<SidebarHeader className="p-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="EnCoach"
|
alt={t("chrome.adminAlt")}
|
||||||
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
className="h-9 w-9 shrink-0 rounded-lg object-contain group-data-[collapsible=icon]:block hidden"
|
||||||
/>
|
/>
|
||||||
<img
|
<img
|
||||||
src="/logo.png"
|
src="/logo.png"
|
||||||
alt="EnCoach — Unlock your potential with AI powered platform"
|
alt={t("chrome.adminAltLong")}
|
||||||
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
className="h-14 w-auto shrink-0 object-contain group-data-[collapsible=icon]:hidden"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -108,7 +122,9 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
<span className="text-xs font-semibold text-primary">{initials}</span>
|
<span className="text-xs font-semibold text-primary">{initials}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
<div className="flex flex-col min-w-0 group-data-[collapsible=icon]:hidden">
|
||||||
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name}</span>
|
<span className="text-sm font-medium truncate text-sidebar-foreground">
|
||||||
|
{user?.name ?? t("userMenu.userFallback")}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
<span className="text-xs text-muted-foreground truncate">{user?.email}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,8 +134,8 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
<div className="flex-1 flex flex-col min-w-0">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
|
||||||
<span className="text-sm font-medium text-muted-foreground capitalize">{role} Portal</span>
|
<span className="text-sm font-medium text-muted-foreground">{portalTitle}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<LanguageToggle />
|
<LanguageToggle />
|
||||||
@@ -135,16 +151,16 @@ export default function RoleLayout({ navGroups, role }: RoleLayoutProps) {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="text-sm font-medium">{user?.name}</p>
|
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
|
||||||
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
<DropdownMenuItem onClick={() => navigate(`/${role}/profile`)}>
|
||||||
<User className="mr-2 h-4 w-4" /> Profile
|
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" /> Logout
|
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -2,47 +2,52 @@ import RoleLayout, { NavGroup } from "./RoleLayout";
|
|||||||
import ExamPopup from "./student/ExamPopup";
|
import ExamPopup from "./student/ExamPopup";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
LayoutDashboard, BookOpen, ClipboardList, BarChart3,
|
||||||
CalendarCheck, Calendar, User, Target, GraduationCap, ListChecks,
|
CalendarCheck, Calendar, User, Target, ListChecks,
|
||||||
MessageSquare, Megaphone, Mail, TrendingUp,
|
MessageSquare, Megaphone, Mail, TrendingUp,
|
||||||
} from "lucide-react";
|
} 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[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Learning",
|
labelKey: "sidebarGroup.learning",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/student/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "My Courses", url: "/student/courses", icon: BookOpen },
|
{ titleKey: "nav.myCourses", url: "/student/courses", icon: BookOpen },
|
||||||
{ title: "Subject Registration", url: "/student/subject-registration", icon: ListChecks },
|
{ titleKey: "nav.subjectRegistration", url: "/student/subject-registration", icon: ListChecks },
|
||||||
{ title: "Assignments", url: "/student/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/student/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Adaptive Learning",
|
labelKey: "sidebarGroup.adaptiveLearning",
|
||||||
items: [
|
items: [
|
||||||
{ title: "My Subjects", url: "/student/subjects", icon: Target },
|
{ titleKey: "nav.mySubjects", url: "/student/subjects", icon: Target },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Progress",
|
labelKey: "sidebarGroup.progress",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Grades", url: "/student/grades", icon: BarChart3 },
|
{ titleKey: "nav.grades", url: "/student/grades", icon: BarChart3 },
|
||||||
{ title: "Attendance", url: "/student/attendance", icon: CalendarCheck },
|
{ titleKey: "nav.attendance", url: "/student/attendance", icon: CalendarCheck },
|
||||||
{ title: "Timetable", url: "/student/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/student/timetable", icon: Calendar },
|
||||||
{ title: "My Journey", url: "/student/journey", icon: TrendingUp },
|
{ titleKey: "nav.myJourney", url: "/student/journey", icon: TrendingUp },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Communication",
|
labelKey: "sidebarGroup.communication",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Discussions", url: "/student/discussions", icon: MessageSquare },
|
{ titleKey: "nav.discussions", url: "/student/discussions", icon: MessageSquare },
|
||||||
{ title: "Messages", url: "/student/messages", icon: Mail },
|
{ titleKey: "nav.messages", url: "/student/messages", icon: Mail },
|
||||||
{ title: "Announcements", url: "/student/announcements", icon: Megaphone },
|
{ titleKey: "nav.announcements", url: "/student/announcements", icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Account",
|
labelKey: "sidebarGroup.account",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Profile", url: "/student/profile", icon: User },
|
{ titleKey: "nav.profile", url: "/student/profile", icon: User },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
import RoleLayout, { NavGroup } from "./RoleLayout";
|
import RoleLayout, { NavGroup } from "./RoleLayout";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, BookOpen, ClipboardList, FileText,
|
LayoutDashboard, BookOpen, ClipboardList,
|
||||||
CalendarCheck, Users, Calendar, User, MessageSquare,
|
CalendarCheck, Users, Calendar, User, MessageSquare,
|
||||||
Megaphone, Wand2, Library,
|
Megaphone, Library,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
/** Teacher portal shell. See `StudentLayout` for the nav-config rationale. */
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Teaching",
|
labelKey: "sidebarGroup.teaching",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
{ titleKey: "nav.dashboard", url: "/teacher/dashboard", icon: LayoutDashboard },
|
||||||
{ title: "Courses", url: "/teacher/courses", icon: BookOpen },
|
{ titleKey: "nav.courses", url: "/teacher/courses", icon: BookOpen },
|
||||||
{ title: "Resource Library", url: "/teacher/library", icon: Library },
|
{ titleKey: "nav.resourceLibrary", url: "/teacher/library", icon: Library },
|
||||||
{ title: "Assignments", url: "/teacher/assignments", icon: ClipboardList },
|
{ titleKey: "nav.assignments", url: "/teacher/assignments", icon: ClipboardList },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Management",
|
labelKey: "sidebarGroup.management",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
{ titleKey: "nav.attendance", url: "/teacher/attendance", icon: CalendarCheck },
|
||||||
{ title: "Students", url: "/teacher/students", icon: Users },
|
{ titleKey: "nav.students", url: "/teacher/students", icon: Users },
|
||||||
{ title: "Timetable", url: "/teacher/timetable", icon: Calendar },
|
{ titleKey: "nav.timetable", url: "/teacher/timetable", icon: Calendar },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Communication",
|
labelKey: "sidebarGroup.communication",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Discussions", url: "/teacher/discussions", icon: MessageSquare },
|
{ titleKey: "nav.discussions", url: "/teacher/discussions", icon: MessageSquare },
|
||||||
{ title: "Announcements", url: "/teacher/announcements", icon: Megaphone },
|
{ titleKey: "nav.announcements", url: "/teacher/announcements", icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Account",
|
labelKey: "sidebarGroup.account",
|
||||||
items: [
|
items: [
|
||||||
{ title: "Profile", url: "/teacher/profile", icon: User },
|
{ titleKey: "nav.profile", url: "/teacher/profile", icon: User },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Moon, Sun, Monitor } from "lucide-react";
|
import { Moon, Sun, Monitor } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
export function ThemeToggle() {
|
export function ThemeToggle() {
|
||||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -33,7 +35,8 @@ export function ThemeToggle() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Toggle theme"
|
aria-label={t("theme.toggleLabel")}
|
||||||
|
title={t("theme.toggleLabel")}
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{mounted ? (
|
{mounted ? (
|
||||||
@@ -49,16 +52,16 @@ export function ThemeToggle() {
|
|||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-36">
|
<DropdownMenuContent align="end" className="w-36">
|
||||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||||
<Sun className="mr-2 h-4 w-4" /> Light
|
<Sun className="me-2 h-4 w-4" /> {t("theme.light")}
|
||||||
{theme === "light" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
{theme === "light" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||||
<Moon className="mr-2 h-4 w-4" /> Dark
|
<Moon className="me-2 h-4 w-4" /> {t("theme.dark")}
|
||||||
{theme === "dark" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
{theme === "dark" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||||
<Monitor className="mr-2 h-4 w-4" /> System
|
<Monitor className="me-2 h-4 w-4" /> {t("theme.system")}
|
||||||
{theme === "system" && <span className="ml-auto text-xs text-muted-foreground">✓</span>}
|
{theme === "system" && <span className="ms-auto text-xs text-muted-foreground">✓</span>}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
import { AlertTriangle, X, Sparkles, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { analyticsService } from "@/services/analytics.service";
|
import { analyticsService } from "@/services/analytics.service";
|
||||||
@@ -7,6 +8,7 @@ import { analyticsService } from "@/services/analytics.service";
|
|||||||
export default function AiAlertBanner() {
|
export default function AiAlertBanner() {
|
||||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(() => new Set());
|
||||||
const [errorDismissed, setErrorDismissed] = useState(false);
|
const [errorDismissed, setErrorDismissed] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: resp, isLoading, isError, error } = useQuery({
|
const { data: resp, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "alerts"],
|
queryKey: ["ai", "alerts"],
|
||||||
@@ -20,20 +22,20 @@ export default function AiAlertBanner() {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-center gap-3">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
<Loader2 className="h-5 w-5 animate-spin text-warning shrink-0" />
|
||||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
<p className="text-sm text-muted-foreground">{t("ai.loadingAlerts")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError && !errorDismissed) {
|
if (isError && !errorDismissed) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
<div className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium flex items-center gap-1">
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3 text-primary" /> Alerts unavailable
|
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {t("ai.alertsUnavailable")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : "Could not load alerts."}</p>
|
<p className="text-sm text-muted-foreground mt-1">{error instanceof Error ? error.message : t("ai.couldNotLoadAlerts")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0" onClick={() => setErrorDismissed(true)}>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
@@ -48,7 +50,7 @@ export default function AiAlertBanner() {
|
|||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
<div className="rounded-lg border border-muted bg-muted/20 p-4 flex items-start gap-3">
|
||||||
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
<Sparkles className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
<p className="text-sm text-muted-foreground">No AI alerts right now.</p>
|
<p className="text-sm text-muted-foreground">{t("ai.noAlertsRightNow")}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -58,11 +60,11 @@ export default function AiAlertBanner() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{visible.map((alert, idx) => (
|
{visible.map((alert, idx) => (
|
||||||
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3">
|
<div key={idx} className="rounded-lg border border-warning/30 bg-warning/10 p-4 flex items-start gap-3" dir="auto">
|
||||||
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
<AlertTriangle className="h-5 w-5 text-warning shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium flex items-center gap-1">
|
<p className="text-sm font-medium flex items-center gap-1">
|
||||||
<Sparkles className="h-3 w-3 text-primary" /> {alert.title}
|
<Sparkles className="h-3 w-3 text-primary shrink-0" /> {alert.title}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
<p className="text-sm text-muted-foreground mt-1">{alert.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -8,19 +9,27 @@ import { Sparkles, Send, Loader2 } from "lucide-react";
|
|||||||
import { coachingService } from "@/services/coaching.service";
|
import { coachingService } from "@/services/coaching.service";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const quickActions = [
|
|
||||||
"Platform health summary",
|
|
||||||
"Show dropout risks",
|
|
||||||
"Compare course performance",
|
|
||||||
"Suggest batch improvements",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AiAssistantDrawer() {
|
export default function AiAssistantDrawer() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
const [messages, setMessages] = useState<{ role: "user" | "ai"; text: string }[]>([]);
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Re-compute when language switches so quick-action chips show translated
|
||||||
|
// labels immediately. Chips are stored by label (what we send to the
|
||||||
|
// backend), so localizing the label is safe here — the backend treats
|
||||||
|
// them as free-form prompts.
|
||||||
|
const quickActions = useMemo(
|
||||||
|
() => [
|
||||||
|
t("ai.quickHealth"),
|
||||||
|
t("ai.quickDropouts"),
|
||||||
|
t("ai.quickCompare"),
|
||||||
|
t("ai.quickBatch"),
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
|
||||||
const chatMutation = useMutation({
|
const chatMutation = useMutation({
|
||||||
mutationFn: (message: string) =>
|
mutationFn: (message: string) =>
|
||||||
@@ -31,15 +40,12 @@ export default function AiAssistantDrawer() {
|
|||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Could not get a reply",
|
title: t("ai.replyErrorTitle"),
|
||||||
description: err.message || "Something went wrong. Try again.",
|
description: err.message || t("ai.replyErrorDesc"),
|
||||||
});
|
});
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...prev,
|
||||||
{
|
{ role: "ai", text: t("ai.fallbackReply") },
|
||||||
role: "ai",
|
|
||||||
text: "Sorry, I could not reach the assistant. Please try again in a moment.",
|
|
||||||
},
|
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -56,8 +62,9 @@ export default function AiAssistantDrawer() {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
className="fixed bottom-20 right-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
className="fixed bottom-20 end-6 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||||
aria-label="AI Assistant"
|
aria-label={t("ai.assistantLabel")}
|
||||||
|
title={t("ai.assistantLabel")}
|
||||||
>
|
>
|
||||||
<Sparkles className="h-5 w-5" />
|
<Sparkles className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -67,7 +74,7 @@ export default function AiAssistantDrawer() {
|
|||||||
<SheetHeader>
|
<SheetHeader>
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-5 w-5 text-primary" />
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
EnCoach AI Assistant
|
{t("ai.assistantTitle")}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
@@ -90,8 +97,8 @@ export default function AiAssistantDrawer() {
|
|||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="text-center text-muted-foreground text-sm py-8">
|
<div className="text-center text-muted-foreground text-sm py-8">
|
||||||
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
<Sparkles className="h-8 w-8 mx-auto mb-3 text-primary/40" />
|
||||||
<p>Ask me anything about the platform,</p>
|
<p>{t("ai.emptyLine1")}</p>
|
||||||
<p>or click a quick action above.</p>
|
<p>{t("ai.emptyLine2")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.map((msg, i) => (
|
{messages.map((msg, i) => (
|
||||||
@@ -99,27 +106,27 @@ export default function AiAssistantDrawer() {
|
|||||||
key={i}
|
key={i}
|
||||||
className={`rounded-lg p-3 text-sm ${
|
className={`rounded-lg p-3 text-sm ${
|
||||||
msg.role === "user"
|
msg.role === "user"
|
||||||
? "bg-primary text-primary-foreground ml-8"
|
? "bg-primary text-primary-foreground ms-8"
|
||||||
: "bg-muted mr-8"
|
: "bg-muted me-8"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{msg.role === "ai" && (
|
{msg.role === "ai" && (
|
||||||
<Sparkles className="h-3 w-3 text-primary inline mr-1.5 -mt-0.5" />
|
<Sparkles className="h-3 w-3 text-primary inline me-1.5 -mt-0.5" />
|
||||||
)}
|
)}
|
||||||
{msg.text}
|
{msg.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{chatMutation.isPending && (
|
{chatMutation.isPending && (
|
||||||
<div className="bg-muted rounded-lg p-3 text-sm mr-8 flex items-center gap-2">
|
<div className="bg-muted rounded-lg p-3 text-sm me-8 flex items-center gap-2">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
<span className="text-muted-foreground">Thinking...</span>
|
<span className="text-muted-foreground">{t("ai.thinking")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-3 border-t mt-auto">
|
<div className="flex gap-2 pt-3 border-t mt-auto">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ask anything..."
|
placeholder={t("ai.placeholder")}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
onKeyDown={(e) => e.key === "Enter" && handleSend(input)}
|
||||||
@@ -128,6 +135,8 @@ export default function AiAssistantDrawer() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleSend(input)}
|
onClick={() => handleSend(input)}
|
||||||
disabled={!input.trim() || chatMutation.isPending}
|
disabled={!input.trim() || chatMutation.isPending}
|
||||||
|
aria-label={t("ai.send")}
|
||||||
|
title={t("ai.send")}
|
||||||
>
|
>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
import { Sparkles, TrendingUp, AlertTriangle, Info, Loader2 } from "lucide-react";
|
||||||
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
import { analyticsService, type AiInsightItem } from "@/services/analytics.service";
|
||||||
@@ -35,14 +36,15 @@ interface Props {
|
|||||||
|
|
||||||
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
const payloadKey = useMemo(() => JSON.stringify(data), [data]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
mutationFn: (payload: Record<string, unknown>) => analyticsService.getInsights(payload),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Insights unavailable",
|
title: t("ai.insightsUnavailable"),
|
||||||
description: err.message || "Could not load AI insights.",
|
description: err.message || t("ai.couldNotLoadInsights"),
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -60,21 +62,21 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary" />
|
<Sparkles className="h-4 w-4 text-primary" />
|
||||||
AI Platform Insights
|
{t("ai.platformInsightsTitle")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{mutation.isPending && (
|
{mutation.isPending && (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8 justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||||
Loading insights…
|
{t("ai.loadingInsights")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{mutation.isError && !mutation.isPending && (
|
{mutation.isError && !mutation.isPending && (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">Could not load insights.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.couldNotLoadInsights")}</p>
|
||||||
)}
|
)}
|
||||||
{mutation.isSuccess && items.length === 0 && (
|
{mutation.isSuccess && items.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground py-4 text-center">No insights available for this view.</p>
|
<p className="text-sm text-muted-foreground py-4 text-center">{t("ai.noInsightsAvailable")}</p>
|
||||||
)}
|
)}
|
||||||
{!mutation.isPending && items.length > 0 && (
|
{!mutation.isPending && items.length > 0 && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@@ -82,10 +84,10 @@ export default function AiInsightsPanel({ data = EMPTY_PAYLOAD }: Props) {
|
|||||||
const Icon = insightIcon(item.severity);
|
const Icon = insightIcon(item.severity);
|
||||||
const color = insightColor(item.severity);
|
const color = insightColor(item.severity);
|
||||||
return (
|
return (
|
||||||
<div key={idx} className="rounded-lg border bg-muted/30 p-4">
|
<div key={idx} className="rounded-lg border bg-muted/30 p-4" dir="auto">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Icon className={`h-4 w-4 ${color}`} />
|
<Icon className={`h-4 w-4 shrink-0 ${color}`} />
|
||||||
<span className="text-sm font-semibold">{item.title}</span>
|
<span className="text-sm font-semibold min-w-0">{item.title}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{item.description}</p>
|
<p className="text-sm text-muted-foreground">{item.description}</p>
|
||||||
{item.recommendation && (
|
{item.recommendation && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
import { Sparkles, Search, Loader2, X } from "lucide-react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
@@ -10,14 +11,15 @@ export default function AiSearchBar() {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const searchMutation = useMutation({
|
const searchMutation = useMutation({
|
||||||
mutationFn: (q: string) => analyticsService.search(q),
|
mutationFn: (q: string) => analyticsService.search(q),
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: "Search failed",
|
title: t("chrome.aiSearchFailedTitle"),
|
||||||
description: err.message || "Could not complete AI search.",
|
description: err.message || t("chrome.aiSearchFailedDesc"),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -32,11 +34,11 @@ export default function AiSearchBar() {
|
|||||||
return (
|
return (
|
||||||
<div className="relative max-w-md w-full">
|
<div className="relative max-w-md w-full">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Sparkles className="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
<Sparkles className="absolute start-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-primary" />
|
||||||
<Search className="absolute left-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute start-7 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Ask anything... e.g. 'Show students with low attendance'"
|
placeholder={t("chrome.aiSearchPlaceholder")}
|
||||||
className="pl-12 pr-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
className="ps-12 pe-8 h-9 text-sm bg-muted/50 border-0 focus-visible:ring-1"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
@@ -50,7 +52,7 @@ export default function AiSearchBar() {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
searchMutation.reset();
|
searchMutation.reset();
|
||||||
}}
|
}}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -58,11 +60,11 @@ export default function AiSearchBar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(searchMutation.isPending || result !== undefined) && (
|
{(searchMutation.isPending || result !== undefined) && (
|
||||||
<div className="absolute top-full mt-1 left-0 right-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
<div className="absolute top-full mt-1 start-0 end-0 z-50 rounded-lg border bg-popover p-3 shadow-md">
|
||||||
{searchMutation.isPending ? (
|
{searchMutation.isPending ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
AI is searching...
|
{t("chrome.aiSearching")}
|
||||||
</div>
|
</div>
|
||||||
) : result?.answer ? (
|
) : result?.answer ? (
|
||||||
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
<div className="text-sm space-y-2 max-h-64 overflow-y-auto">
|
||||||
@@ -72,7 +74,7 @@ export default function AiSearchBar() {
|
|||||||
</div>
|
</div>
|
||||||
{result.suggestions?.length > 0 && (
|
{result.suggestions?.length > 0 && (
|
||||||
<div className="border-t pt-2 space-y-1">
|
<div className="border-t pt-2 space-y-1">
|
||||||
<p className="text-xs font-semibold text-primary">Related queries</p>
|
<p className="text-xs font-semibold text-primary">{t("chrome.aiRelatedQueries")}</p>
|
||||||
{result.suggestions.map((s, i) => (
|
{result.suggestions.map((s, i) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
@@ -99,9 +101,7 @@ export default function AiSearchBar() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-sm flex items-start gap-2">
|
<div className="text-sm flex items-start gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<p>
|
<p>{t("chrome.aiNoResults", { q: query })}</p>
|
||||||
No results for "{query}". Try a different question or keyword.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Sparkles, X, Loader2 } from "lucide-react";
|
import { Sparkles, X, Loader2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { coachingService } from "@/services/coaching.service";
|
import { coachingService } from "@/services/coaching.service";
|
||||||
@@ -12,6 +13,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
export default function AiTipBanner({ context = "dashboard", variant = "tip", dismissible = true }: Props) {
|
||||||
const [dismissed, setDismissed] = useState(false);
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data, isLoading, isError, error } = useQuery({
|
const { data, isLoading, isError, error } = useQuery({
|
||||||
queryKey: ["ai", "tip", context],
|
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 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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-center gap-3`}>
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
<Loader2 className="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||||
<span className="text-sm text-muted-foreground">Loading AI tip…</span>
|
<span className="text-sm text-muted-foreground">{t("ai.loadingTip")}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !data) {
|
if (isError || !data) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">AI Tip</span>
|
<span className="block text-xs font-semibold text-primary">{t("ai.tipLabel")}</span>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">
|
<p className="text-sm text-muted-foreground mt-0.5">
|
||||||
{isError ? (error instanceof Error ? error.message : "Could not load tip.") : "No tip available."}
|
{isError ? (error instanceof Error ? error.message : t("ai.couldNotLoadTip")) : t("ai.noTipAvailable")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
@@ -52,25 +61,25 @@ export default function AiTipBanner({ context = "dashboard", variant = "tip", di
|
|||||||
|
|
||||||
if (!data.tip?.trim()) {
|
if (!data.tip?.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`}>
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3`} dir="auto">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">AI {variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}</span>
|
<span className="block text-xs font-semibold text-primary">{variantLabel}</span>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">No tip for this context yet.</p>
|
<p className="text-sm text-muted-foreground mt-0.5">{t("ai.noTipForContext")}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const label = data.category && data.category !== "general"
|
const label = data.category && data.category !== "general"
|
||||||
? `AI ${data.category.charAt(0).toUpperCase() + data.category.slice(1)} Tip`
|
? t("ai.categoryTipLabel", { category: data.category.charAt(0).toUpperCase() + data.category.slice(1) })
|
||||||
: `AI ${variant === "tip" ? "Tip" : variant === "insight" ? "Insight" : "Recommendation"}`;
|
: variantLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`}>
|
<div className={`rounded-lg border ${bgClass} p-3 flex items-start gap-3 animate-in fade-in slide-in-from-top-2 duration-300`} dir="auto">
|
||||||
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
<Sparkles className="h-4 w-4 text-primary shrink-0 mt-0.5" />
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-xs font-semibold text-primary">{label}</span>
|
<span className="block text-xs font-semibold text-primary">{label}</span>
|
||||||
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
<p className="text-sm text-muted-foreground mt-0.5">{data.tip}</p>
|
||||||
</div>
|
</div>
|
||||||
{dismissible && (
|
{dismissible && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -10,6 +11,7 @@ import type { StudentExamAssignment } from "@/types";
|
|||||||
|
|
||||||
export default function ExamPopup() {
|
export default function ExamPopup() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
const [dismissed, setDismissed] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
@@ -30,11 +32,14 @@ export default function ExamPopup() {
|
|||||||
|
|
||||||
if (pendingExams.length === 0) return null;
|
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) => {
|
const formatDate = (iso: string | null) => {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +
|
return d.toLocaleDateString(dateLocale, { month: "short", day: "numeric", year: "numeric" }) +
|
||||||
" " + d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
" " + d.toLocaleTimeString(dateLocale, { hour: "2-digit", minute: "2-digit" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStart = (exam: StudentExamAssignment) => {
|
const handleStart = (exam: StudentExamAssignment) => {
|
||||||
@@ -54,10 +59,10 @@ export default function ExamPopup() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-primary" />
|
<AlertCircle className="h-5 w-5 text-primary" />
|
||||||
Upcoming Exams ({pendingExams.length})
|
{t("examPopup.title", { n: pendingExams.length })}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="space-y-3 max-h-[60vh] overflow-y-auto pr-1">
|
<div className="space-y-3 max-h-[60vh] overflow-y-auto pe-1">
|
||||||
{pendingExams.map((exam) => (
|
{pendingExams.map((exam) => (
|
||||||
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
<div key={exam.id} className="border rounded-lg p-4 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -66,7 +71,7 @@ export default function ExamPopup() {
|
|||||||
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
variant={exam.schedule_state === "active" ? "default" : "secondary"}
|
||||||
className="capitalize text-xs"
|
className="capitalize text-xs"
|
||||||
>
|
>
|
||||||
{exam.schedule_state === "active" ? "Active Now" : exam.schedule_state}
|
{exam.schedule_state === "active" ? t("examPopup.activeNow") : exam.schedule_state}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -77,11 +82,11 @@ export default function ExamPopup() {
|
|||||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Calendar className="h-3 w-3" />
|
<Calendar className="h-3 w-3" />
|
||||||
From: {formatDate(exam.start_date)}
|
{t("examPopup.from")} {formatDate(exam.start_date)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
To: {formatDate(exam.end_date)}
|
{t("examPopup.to")} {formatDate(exam.end_date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,20 +98,20 @@ export default function ExamPopup() {
|
|||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
>
|
>
|
||||||
<PlayCircle className="h-3.5 w-3.5" />
|
<PlayCircle className="h-3.5 w-3.5" />
|
||||||
{exam.can_start ? "Start Exam" : "Not Available Yet"}
|
{exam.can_start ? t("examPopup.startExam") : t("examPopup.notAvailableYet")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => handleDismiss(exam.id)}
|
onClick={() => handleDismiss(exam.id)}
|
||||||
>
|
>
|
||||||
Dismiss
|
{t("common.dismiss")}
|
||||||
</Button>
|
</Button>
|
||||||
{!exam.can_start && (
|
{!exam.can_start && (
|
||||||
<span className="text-[11px] text-muted-foreground italic ml-auto">
|
<span className="text-[11px] text-muted-foreground italic ms-auto">
|
||||||
{exam.schedule_state === "planned"
|
{exam.schedule_state === "planned"
|
||||||
? "Exam will be available once it becomes active"
|
? t("examPopup.willBeAvailable")
|
||||||
: "Exam is not currently active"}
|
: t("examPopup.notActive")}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,12 @@ const BreadcrumbPage = React.forwardRef<HTMLSpanElement, React.ComponentPropsWit
|
|||||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||||
|
|
||||||
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
const BreadcrumbSeparator = ({ children, className, ...props }: React.ComponentProps<"li">) => (
|
||||||
<li role="presentation" aria-hidden="true" className={cn("[&>svg]:size-3.5", className)} {...props}>
|
<li
|
||||||
|
role="presentation"
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("[&>svg]:size-3.5 rtl:[&>svg]:rotate-180", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children ?? <ChevronRight />}
|
{children ?? <ChevronRight />}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ function Calendar({ className, classNames, showOutsideDays = true, ...props }: C
|
|||||||
...classNames,
|
...classNames,
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4" />,
|
IconLeft: ({ ..._props }) => <ChevronLeft className="h-4 w-4 rtl:rotate-180" />,
|
||||||
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4" />,
|
IconRight: ({ ..._props }) => <ChevronRight className="h-4 w-4 rtl:rotate-180" />,
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProp
|
|||||||
onClick={scrollPrev}
|
onClick={scrollPrev}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span className="sr-only">Previous slide</span>
|
<span className="sr-only">Previous slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -213,7 +213,7 @@ const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<ty
|
|||||||
onClick={scrollNext}
|
onClick={scrollNext}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span className="sr-only">Next slide</span>
|
<span className="sr-only">Next slide</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
</ContextMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const MenubarSubTrigger = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<ChevronRight className="ml-auto h-4 w-4" />
|
<ChevronRight className="ms-auto h-4 w-4 rtl:rotate-180" />
|
||||||
</MenubarPrimitive.SubTrigger>
|
</MenubarPrimitive.SubTrigger>
|
||||||
));
|
));
|
||||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||||
|
|||||||
@@ -47,17 +47,17 @@ const PaginationLink = ({ className, isActive, size = "icon", ...props }: Pagina
|
|||||||
PaginationLink.displayName = "PaginationLink";
|
PaginationLink.displayName = "PaginationLink";
|
||||||
|
|
||||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 pl-2.5", className)} {...props}>
|
<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 ps-2.5", className)} {...props}>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
<span>Previous</span>
|
<span>Previous</span>
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
PaginationPrevious.displayName = "PaginationPrevious";
|
PaginationPrevious.displayName = "PaginationPrevious";
|
||||||
|
|
||||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||||
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pr-2.5", className)} {...props}>
|
<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 pe-2.5", className)} {...props}>
|
||||||
<span>Next</span>
|
<span>Next</span>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4 rtl:rotate-180" />
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
);
|
);
|
||||||
PaginationNext.displayName = "PaginationNext";
|
PaginationNext.displayName = "PaginationNext";
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ const Sidebar = React.forwardRef<
|
|||||||
Sidebar.displayName = "Sidebar";
|
Sidebar.displayName = "Sidebar";
|
||||||
|
|
||||||
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.ComponentProps<typeof Button>>(
|
||||||
({ className, onClick, ...props }, ref) => {
|
({ className, onClick, "aria-label": ariaLabel, ...props }, ref) => {
|
||||||
const { toggleSidebar } = useSidebar();
|
const { toggleSidebar } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -226,6 +226,7 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
|||||||
data-sidebar="trigger"
|
data-sidebar="trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
aria-label={ariaLabel ?? "Toggle sidebar"}
|
||||||
className={cn("h-7 w-7", className)}
|
className={cn("h-7 w-7", className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event);
|
onClick?.(event);
|
||||||
@@ -233,8 +234,8 @@ const SidebarTrigger = React.forwardRef<React.ElementRef<typeof Button>, React.C
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeft />
|
<PanelLeft className="rtl:rotate-180" />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">{ariaLabel ?? "Toggle sidebar"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Translations } from "./en";
|
import type { Translations } from "./en";
|
||||||
|
|
||||||
/** Arabic (RTL) translations. */
|
/** Arabic (RTL) translations. Keep key parity with `en.ts`. */
|
||||||
const ar: Translations = {
|
const ar: Translations = {
|
||||||
common: {
|
common: {
|
||||||
cancel: "إلغاء",
|
cancel: "إلغاء",
|
||||||
@@ -20,30 +20,293 @@ const ar: Translations = {
|
|||||||
status: "الحالة",
|
status: "الحالة",
|
||||||
settings: "الإعدادات",
|
settings: "الإعدادات",
|
||||||
signOut: "تسجيل الخروج",
|
signOut: "تسجيل الخروج",
|
||||||
|
viewAll: "عرض الكل",
|
||||||
|
dismiss: "تجاهل",
|
||||||
|
pending: "قيد الانتظار",
|
||||||
|
active: "نشط",
|
||||||
|
inactive: "غير نشط",
|
||||||
|
available: "متاح",
|
||||||
|
unavailable: "غير متاح",
|
||||||
|
name: "الاسم",
|
||||||
|
email: "البريد الإلكتروني",
|
||||||
|
user: "المستخدم",
|
||||||
|
home: "الرئيسية",
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
signIn: "تسجيل الدخول",
|
signIn: "تسجيل الدخول",
|
||||||
signInTitle: "تسجيل الدخول إلى EnCoach",
|
signInTitle: "تسجيل الدخول إلى EnCoach",
|
||||||
|
welcomeBack: "مرحباً بعودتك",
|
||||||
|
signInDescription: "سجّل الدخول إلى حسابك للمتابعة",
|
||||||
email: "البريد الإلكتروني",
|
email: "البريد الإلكتروني",
|
||||||
|
emailPlaceholder: "you@example.com",
|
||||||
password: "كلمة المرور",
|
password: "كلمة المرور",
|
||||||
|
rememberMe: "تذكرني",
|
||||||
forgotPassword: "هل نسيت كلمة المرور؟",
|
forgotPassword: "هل نسيت كلمة المرور؟",
|
||||||
needAccount: "ليس لديك حساب؟",
|
needAccount: "ليس لديك حساب؟",
|
||||||
signUp: "إنشاء حساب",
|
signUp: "إنشاء حساب",
|
||||||
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة",
|
||||||
|
missingCredentials: "يرجى إدخال البريد الإلكتروني وكلمة المرور",
|
||||||
|
loginFailedTitle: "فشل تسجيل الدخول",
|
||||||
|
errorTitle: "خطأ",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
adminDashboard: "لوحة الإدارة",
|
||||||
|
platformDashboard: "لوحة المنصّة",
|
||||||
dashboard: "لوحة التحكم",
|
dashboard: "لوحة التحكم",
|
||||||
courses: "الدورات",
|
courses: "الدورات",
|
||||||
|
myCourses: "دوراتي",
|
||||||
students: "الطلاب",
|
students: "الطلاب",
|
||||||
teachers: "المعلمون",
|
teachers: "المعلمون",
|
||||||
exams: "الاختبارات",
|
batches: "الدفعات",
|
||||||
|
timetable: "الجدول الدراسي",
|
||||||
reports: "التقارير",
|
reports: "التقارير",
|
||||||
settings: "الإعدادات",
|
assignments: "الواجبات",
|
||||||
profile: "الملف الشخصي",
|
examsList: "قائمة الاختبارات",
|
||||||
privacy: "مركز الخصوصية",
|
examStructures: "هياكل الاختبارات",
|
||||||
|
rubrics: "معايير التقييم",
|
||||||
|
generation: "التوليد",
|
||||||
reviewQueue: "قائمة المراجعة",
|
reviewQueue: "قائمة المراجعة",
|
||||||
aiPrompts: "تعليمات الذكاء الاصطناعي",
|
aiPrompts: "تعليمات الذكاء الاصطناعي",
|
||||||
aiFeedback: "ملاحظات الذكاء الاصطناعي",
|
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: {
|
privacy: {
|
||||||
title: "مركز الخصوصية",
|
title: "مركز الخصوصية",
|
||||||
@@ -74,12 +337,6 @@ const ar: Translations = {
|
|||||||
commentRequired: "من فضلك أخبرنا بالخطأ.",
|
commentRequired: "من فضلك أخبرنا بالخطأ.",
|
||||||
submit: "إرسال الملاحظات",
|
submit: "إرسال الملاحظات",
|
||||||
},
|
},
|
||||||
theme: {
|
|
||||||
title: "المظهر",
|
|
||||||
light: "فاتح",
|
|
||||||
dark: "داكن",
|
|
||||||
system: "النظام",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ar;
|
export default ar;
|
||||||
|
|||||||
@@ -3,14 +3,33 @@
|
|||||||
* We deliberately annotate the literal with `Translations` (not `as const`)
|
* We deliberately annotate the literal with `Translations` (not `as const`)
|
||||||
* so sibling locale files can widen their string values without fighting
|
* so sibling locale files can widen their string values without fighting
|
||||||
* literal-string type mismatches.
|
* literal-string type mismatches.
|
||||||
|
*
|
||||||
|
* Adding a new key: add it here FIRST (English is the source of truth), then
|
||||||
|
* mirror it in `ar.ts`. The `Translations` interface is intentionally
|
||||||
|
* permissive (`Record<string, string>` per group) so we don't have to update
|
||||||
|
* a giant type every time we add a string.
|
||||||
*/
|
*/
|
||||||
export interface Translations {
|
export interface Translations {
|
||||||
common: Record<string, string>;
|
common: Record<string, string>;
|
||||||
auth: Record<string, string>;
|
auth: Record<string, string>;
|
||||||
nav: Record<string, string>;
|
nav: Record<string, string>;
|
||||||
|
sidebarGroup: Record<string, string>;
|
||||||
|
breadcrumb: Record<string, string>;
|
||||||
|
userMenu: Record<string, string>;
|
||||||
|
chrome: Record<string, string>;
|
||||||
|
notifications: Record<string, string>;
|
||||||
|
theme: Record<string, string>;
|
||||||
|
language: Record<string, string>;
|
||||||
|
roles: Record<string, string>;
|
||||||
|
dashboard: Record<string, string>;
|
||||||
|
studentDash: Record<string, string>;
|
||||||
|
teacherDash: Record<string, string>;
|
||||||
|
adminDash: Record<string, string>;
|
||||||
|
examPopup: Record<string, string>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
ai: Record<string, string>;
|
||||||
privacy: Record<string, string>;
|
privacy: Record<string, string>;
|
||||||
feedback: Record<string, string>;
|
feedback: Record<string, string>;
|
||||||
theme: Record<string, string>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const en: Translations = {
|
const en: Translations = {
|
||||||
@@ -32,30 +51,293 @@ const en: Translations = {
|
|||||||
status: "Status",
|
status: "Status",
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
signOut: "Sign out",
|
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: {
|
auth: {
|
||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signInTitle: "Sign in to EnCoach",
|
signInTitle: "Sign in to EnCoach",
|
||||||
|
welcomeBack: "Welcome back",
|
||||||
|
signInDescription: "Sign in to your account to continue",
|
||||||
email: "Email",
|
email: "Email",
|
||||||
|
emailPlaceholder: "you@example.com",
|
||||||
password: "Password",
|
password: "Password",
|
||||||
|
rememberMe: "Remember me",
|
||||||
forgotPassword: "Forgot password?",
|
forgotPassword: "Forgot password?",
|
||||||
needAccount: "Don't have an account?",
|
needAccount: "Don't have an account?",
|
||||||
signUp: "Sign up",
|
signUp: "Sign up",
|
||||||
invalidCredentials: "Invalid email or password",
|
invalidCredentials: "Invalid email or password",
|
||||||
|
missingCredentials: "Please enter email and password",
|
||||||
|
loginFailedTitle: "Login Failed",
|
||||||
|
errorTitle: "Error",
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
|
adminDashboard: "Admin Dashboard",
|
||||||
|
platformDashboard: "Platform Dashboard",
|
||||||
dashboard: "Dashboard",
|
dashboard: "Dashboard",
|
||||||
courses: "Courses",
|
courses: "Courses",
|
||||||
|
myCourses: "My Courses",
|
||||||
students: "Students",
|
students: "Students",
|
||||||
teachers: "Teachers",
|
teachers: "Teachers",
|
||||||
exams: "Exams",
|
batches: "Batches",
|
||||||
|
timetable: "Timetable",
|
||||||
reports: "Reports",
|
reports: "Reports",
|
||||||
settings: "Settings",
|
assignments: "Assignments",
|
||||||
profile: "Profile",
|
examsList: "Exams List",
|
||||||
privacy: "Privacy Center",
|
examStructures: "Exam Structures",
|
||||||
|
rubrics: "Rubrics",
|
||||||
|
generation: "Generation",
|
||||||
reviewQueue: "Review Queue",
|
reviewQueue: "Review Queue",
|
||||||
aiPrompts: "AI Prompts",
|
aiPrompts: "AI Prompts",
|
||||||
aiFeedback: "AI Feedback",
|
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: {
|
privacy: {
|
||||||
title: "Privacy Center",
|
title: "Privacy Center",
|
||||||
@@ -86,12 +368,6 @@ const en: Translations = {
|
|||||||
commentRequired: "Please tell us what was wrong.",
|
commentRequired: "Please tell us what was wrong.",
|
||||||
submit: "Submit feedback",
|
submit: "Submit feedback",
|
||||||
},
|
},
|
||||||
theme: {
|
|
||||||
title: "Theme",
|
|
||||||
light: "Light",
|
|
||||||
dark: "Dark",
|
|
||||||
system: "System",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default en;
|
export default en;
|
||||||
|
|||||||
@@ -134,3 +134,103 @@
|
|||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
* RTL / Arabic support
|
||||||
|
*
|
||||||
|
* `tailwindcss-rtl` handles the bulk of the work: physical margin / padding /
|
||||||
|
* position / border / rounded / text-align / space-x utilities are mirrored
|
||||||
|
* automatically when `html[dir="rtl"]` is active.
|
||||||
|
*
|
||||||
|
* Below we cover the things Tailwind can't: the webfont (Inter has weak
|
||||||
|
* Arabic glyphs, so swap in Cairo), recharts tooltips/axes which have their
|
||||||
|
* own DOM, and a few icons that should NOT mirror (arrows used as pure
|
||||||
|
* visual affordance e.g. ChevronRight in a dropdown).
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
html[dir="rtl"] body,
|
||||||
|
html[dir="rtl"] h1,
|
||||||
|
html[dir="rtl"] h2,
|
||||||
|
html[dir="rtl"] h3,
|
||||||
|
html[dir="rtl"] h4,
|
||||||
|
html[dir="rtl"] h5,
|
||||||
|
html[dir="rtl"] h6,
|
||||||
|
html[dir="rtl"] input,
|
||||||
|
html[dir="rtl"] textarea,
|
||||||
|
html[dir="rtl"] button,
|
||||||
|
html[dir="rtl"] select {
|
||||||
|
font-family: 'Cairo', 'Inter', system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] code,
|
||||||
|
html[dir="rtl"] pre {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[dir="rtl"] .recharts-wrapper,
|
||||||
|
html[dir="rtl"] .recharts-legend-wrapper {
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb and submenu chevrons: always pointing "forward" in reading
|
||||||
|
direction. Lucide's ChevronRight is ">" which is forward in LTR, but in
|
||||||
|
RTL the same arrow must become "<". We flip via CSS so no component has
|
||||||
|
to know about direction. */
|
||||||
|
html[dir="rtl"] nav[aria-label="breadcrumb"] li[role="presentation"] > svg,
|
||||||
|
html[dir="rtl"] [data-radix-menu-content] [role="menuitem"] > svg:last-child,
|
||||||
|
html[dir="rtl"] [data-radix-popper-content-wrapper] [role="menuitem"] > svg:last-child {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Numbers, percentages, emails, URLs, dates should stay LTR-isolated even
|
||||||
|
when embedded in Arabic paragraphs, otherwise slashes / percent signs /
|
||||||
|
dashes drift to the wrong side and the value becomes unreadable.
|
||||||
|
<bdi> already gives us this per-element; the helper classes below are
|
||||||
|
for places where adding a wrapper element is awkward. */
|
||||||
|
.ltr-nums,
|
||||||
|
.dir-ltr {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pure-number cells & badges align to the end (right in LTR, left in RTL)
|
||||||
|
but the digits themselves still read left-to-right. */
|
||||||
|
html[dir="rtl"] .numeric {
|
||||||
|
text-align: end;
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Some lucide icons are pure affordances (play, external-link, send) and
|
||||||
|
should NOT be mirrored even if tailwindcss-rtl would otherwise flip the
|
||||||
|
button that contains them. Authors opt-in with data-no-flip. */
|
||||||
|
html[dir="rtl"] [data-no-flip] {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep code snippets, identifiers, and file paths readable in RTL. */
|
||||||
|
html[dir="rtl"] code,
|
||||||
|
html[dir="rtl"] pre,
|
||||||
|
html[dir="rtl"] kbd,
|
||||||
|
html[dir="rtl"] samp,
|
||||||
|
html[dir="rtl"] [data-monospace] {
|
||||||
|
direction: ltr;
|
||||||
|
unicode-bidi: isolate;
|
||||||
|
text-align: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbars inside horizontally-scrollable tables already flip naturally
|
||||||
|
in RTL, but the shadow Radix portals (dropdowns, tooltips, dialogs) use
|
||||||
|
`right-0` / `left-0` positioning computed in JS. For safety, make sure
|
||||||
|
they inherit the document direction. */
|
||||||
|
html[dir="rtl"] [data-radix-popper-content-wrapper] {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inputs with dir="ltr" (email, password, URL) override but keep the
|
||||||
|
placeholder aligned to the visual start (right in RTL, left in LTR). */
|
||||||
|
html[dir="rtl"] input[dir="ltr"]::placeholder,
|
||||||
|
html[dir="rtl"] textarea[dir="ltr"]::placeholder {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -130,12 +130,32 @@ function refreshOnce(): Promise<boolean> {
|
|||||||
return refreshPromise;
|
return refreshPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentLanguage(): string {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("encoach-lang");
|
||||||
|
if (stored) return stored;
|
||||||
|
} catch {
|
||||||
|
// localStorage unavailable (SSR, sandboxing, etc.)
|
||||||
|
}
|
||||||
|
if (typeof navigator !== "undefined" && navigator.language) {
|
||||||
|
return navigator.language.split("-")[0];
|
||||||
|
}
|
||||||
|
return "en";
|
||||||
|
}
|
||||||
|
|
||||||
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
function buildHeaders(extra?: Record<string, string>, withJson = true): Record<string, string> {
|
||||||
const headers: Record<string, string> = { ...extra };
|
const headers: Record<string, string> = { ...extra };
|
||||||
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
if (withJson) headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||||
|
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
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;
|
return headers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1355,11 +1355,11 @@ export default function ExamStructuresPage() {
|
|||||||
const examType = getExamTypeBadge(s);
|
const examType = getExamTypeBadge(s);
|
||||||
return (
|
return (
|
||||||
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
|
<Card key={s.id} className="border-0 shadow-sm hover:shadow-md transition-shadow cursor-pointer" onClick={() => setEditTarget(s)}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
<CardTitle className="text-base font-semibold flex items-center gap-2">
|
||||||
<Layers className="h-4 w-4 text-primary" />{s.name}
|
<Layers className="h-4 w-4 text-primary" />{s.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-primary"
|
||||||
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
|
onClick={(e) => { e.stopPropagation(); setEditTarget(s); }}>
|
||||||
@@ -1370,9 +1370,9 @@ export default function ExamStructuresPage() {
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground mb-3 flex-wrap">
|
||||||
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
|
{s.entity_name && <span>Entity: <span className="text-foreground font-medium">{s.entity_name}</span></span>}
|
||||||
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
|
{s.industry && <span>Industry: <span className="text-foreground font-medium">{s.industry}</span></span>}
|
||||||
@@ -1382,9 +1382,9 @@ export default function ExamStructuresPage() {
|
|||||||
{(Array.isArray(s.modules) ? s.modules : []).map((m) => (
|
{(Array.isArray(s.modules) ? s.modules : []).map((m) => (
|
||||||
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
|
<Badge key={m} variant="secondary" className="capitalize text-xs">{m}</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ForgotPassword() {
|
|||||||
) : null}
|
) : null}
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
<Link to="/login" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
|
||||||
<ArrowLeft className="h-3 w-3" /> Back to sign in
|
<ArrowLeft className="h-3 w-3 rtl:rotate-180" /> Back to sign in
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -10,6 +11,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { ApiError } from "@/lib/api-client";
|
import { ApiError } from "@/lib/api-client";
|
||||||
import type { UserRole } from "@/types/auth";
|
import type { UserRole } from "@/types/auth";
|
||||||
|
import { LanguageToggle } from "@/components/LanguageToggle";
|
||||||
|
|
||||||
/** Keep in sync with `ProtectedRoute` post-login targets */
|
/** Keep in sync with `ProtectedRoute` post-login targets */
|
||||||
function getRoleDashboard(role: string): string {
|
function getRoleDashboard(role: string): string {
|
||||||
@@ -48,11 +50,16 @@ export default function Login() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!email || !password) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +68,11 @@ export default function Login() {
|
|||||||
const user = await login(email, password);
|
const user = await login(email, password);
|
||||||
navigate(getRoleDashboard(user.user_type));
|
navigate(getRoleDashboard(user.user_type));
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast({ title: "Login Failed", description: loginErrorMessage(err), variant: "destructive" });
|
toast({
|
||||||
|
title: t("auth.loginFailedTitle"),
|
||||||
|
description: loginErrorMessage(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -69,6 +80,9 @@ export default function Login() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<LanguageToggle />
|
||||||
|
</div>
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<div className="flex items-center justify-center mb-8">
|
<div className="flex items-center justify-center mb-8">
|
||||||
<img
|
<img
|
||||||
@@ -80,24 +94,25 @@ export default function Login() {
|
|||||||
|
|
||||||
<Card className="shadow-lg border-0 bg-card">
|
<Card className="shadow-lg border-0 bg-card">
|
||||||
<CardHeader className="text-center pb-4">
|
<CardHeader className="text-center pb-4">
|
||||||
<CardTitle className="text-xl">Welcome back</CardTitle>
|
<CardTitle className="text-xl">{t("auth.welcomeBack")}</CardTitle>
|
||||||
<CardDescription>Sign in to your account to continue</CardDescription>
|
<CardDescription>{t("auth.signInDescription")}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">{t("auth.email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="you@example.com"
|
placeholder={t("auth.emailPlaceholder")}
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
@@ -106,11 +121,13 @@ export default function Login() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
dir="ltr"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
aria-label={showPassword ? t("auth.password") : t("auth.password")}
|
||||||
|
className="absolute end-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -119,19 +136,25 @@ export default function Login() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox id="remember" />
|
<Checkbox id="remember" />
|
||||||
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">Remember me</Label>
|
<Label htmlFor="remember" className="text-sm font-normal cursor-pointer">
|
||||||
|
{t("auth.rememberMe")}
|
||||||
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/forgot-password" className="text-sm text-primary hover:underline">Forgot password?</Link>
|
<Link to="/forgot-password" className="text-sm text-primary hover:underline">
|
||||||
|
{t("auth.forgotPassword")}
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" className="w-full" disabled={loading}>
|
<Button type="submit" className="w-full" disabled={loading}>
|
||||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{loading && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
|
||||||
Sign in
|
{t("auth.signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
{t("auth.needAccount")}{" "}
|
||||||
<Link to="/register" className="text-primary font-medium hover:underline">Sign up</Link>
|
<Link to="/register" className="text-primary font-medium hover:underline">
|
||||||
|
{t("auth.signUp")}
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
const NotFound = () => {
|
const NotFound = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
console.error("404 Error: User attempted to access non-existent route:", location.pathname);
|
||||||
@@ -11,10 +13,10 @@ const NotFound = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-muted">
|
<div className="flex min-h-screen items-center justify-center bg-muted">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="mb-4 text-4xl font-bold">404</h1>
|
<h1 className="mb-4 text-4xl font-bold">{t("errors.notFoundCode")}</h1>
|
||||||
<p className="mb-4 text-xl text-muted-foreground">Oops! Page not found</p>
|
<p className="mb-4 text-xl text-muted-foreground">{t("errors.notFoundMessage")}</p>
|
||||||
<a href="/" className="text-primary underline hover:text-primary/90">
|
<a href="/" className="text-primary underline hover:text-primary/90">
|
||||||
Return to Home
|
{t("errors.returnHome")}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function AdminBatchDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
<Button variant="ghost" size="icon" asChild><Link to="/admin/batches"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{batch.name}</h1>
|
<h1 className="text-2xl font-bold">{batch.name}</h1>
|
||||||
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>
|
<p className="text-muted-foreground">{batch.course_name} · {batch.teacher_name}</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
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 { Users, BookOpen, GraduationCap, Layers, Ticket, DollarSign, Plus, BarChart3, FolderOpen } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
|
import { useCourses, useBatches, useStudents, useTeachers } from "@/hooks/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { api } from "@/lib/api-client";
|
import { api } from "@/lib/api-client";
|
||||||
@@ -32,6 +33,7 @@ interface DashboardStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminLmsDashboard() {
|
export default function AdminLmsDashboard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: coursesData, isLoading: lc } = useCourses();
|
const { data: coursesData, isLoading: lc } = useCourses();
|
||||||
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
|
const { data: studentsData, isLoading: ls } = useStudents({ size: 500 });
|
||||||
const { data: teachersData, isLoading: lt } = useTeachers({ 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 openTickets = dbStats?.open_tickets ?? 0;
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: "Total Students", value: String(students.length), icon: Users, color: "text-primary" },
|
{ label: t("adminDash.totalStudents"), 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: t("adminDash.activeCourses"), 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: t("adminDash.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: t("adminDash.activeBatches"), 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: t("adminDash.openTickets"), value: String(openTickets), icon: Ticket, color: "text-destructive" },
|
||||||
{ label: "Revenue", value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
|
{ label: t("adminDash.revenue"), value: `$${revenue.toLocaleString()}`, icon: DollarSign, color: "text-success" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const courseChartData = courses.map(c => {
|
const courseChartData = courses.map(c => {
|
||||||
@@ -82,12 +84,16 @@ export default function AdminLmsDashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Admin Dashboard</h1>
|
<h1 className="text-2xl font-bold">{t("adminDash.title")}</h1>
|
||||||
<p className="text-muted-foreground">Platform overview and key metrics.</p>
|
<p className="text-muted-foreground">{t("adminDash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" asChild><Link to="/admin/students"><Plus className="mr-1 h-3 w-3" />Add Student</Link></Button>
|
<Button variant="outline" asChild>
|
||||||
<Button asChild><Link to="/admin/courses"><Plus className="mr-1 h-3 w-3" />New Course</Link></Button>
|
<Link to="/admin/students"><Plus className="me-1 h-3 w-3" />{t("adminDash.addStudent")}</Link>
|
||||||
|
</Button>
|
||||||
|
<Button asChild>
|
||||||
|
<Link to="/admin/courses"><Plus className="me-1 h-3 w-3" />{t("adminDash.newCourse")}</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,7 +105,7 @@ export default function AdminLmsDashboard() {
|
|||||||
<Card key={s.label}>
|
<Card key={s.label}>
|
||||||
<CardContent className="pt-4 pb-4">
|
<CardContent className="pt-4 pb-4">
|
||||||
<s.icon className={`h-5 w-5 ${s.color} mb-1`} />
|
<s.icon className={`h-5 w-5 ${s.color} mb-1`} />
|
||||||
<p className="text-lg font-bold">{s.value}</p>
|
<p className="text-lg font-bold"><bdi>{s.value}</bdi></p>
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -108,16 +114,16 @@ export default function AdminLmsDashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: "Departments", value: dbStats?.total_departments ?? 0, icon: FolderOpen },
|
{ label: t("adminDash.departments"), value: dbStats?.total_departments ?? 0, icon: FolderOpen },
|
||||||
{ label: "Classrooms", value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
|
{ label: t("adminDash.classrooms"), value: dbStats?.total_classrooms ?? 0, icon: BarChart3 },
|
||||||
{ label: "Subjects", value: dbStats?.total_subjects ?? 0, icon: BookOpen },
|
{ label: t("adminDash.subjects"), value: dbStats?.total_subjects ?? 0, icon: BookOpen },
|
||||||
{ label: "Resources", value: dbStats?.total_resources ?? 0, icon: Layers },
|
{ label: t("adminDash.resources"), value: dbStats?.total_resources ?? 0, icon: Layers },
|
||||||
].map(s => (
|
].map(s => (
|
||||||
<Card key={s.label}>
|
<Card key={s.label}>
|
||||||
<CardContent className="pt-3 pb-3 flex items-center gap-3">
|
<CardContent className="pt-3 pb-3 flex items-center gap-3">
|
||||||
<s.icon className="h-4 w-4 text-muted-foreground" />
|
<s.icon className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold">{s.value}</p>
|
<p className="text-sm font-semibold"><bdi>{s.value}</bdi></p>
|
||||||
<p className="text-xs text-muted-foreground">{s.label}</p>
|
<p className="text-xs text-muted-foreground">{s.label}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -127,7 +133,7 @@ export default function AdminLmsDashboard() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-lg">Courses Overview</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">{t("adminDash.coursesOverview")}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{courseChartData.length > 0 ? (
|
{courseChartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
@@ -136,18 +142,18 @@ export default function AdminLmsDashboard() {
|
|||||||
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
<XAxis dataKey="course" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
||||||
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="enrolled" name="Enrolled" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="enrolled" name={t("adminDash.chartEnrolled")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground py-8 text-center">No course data available.</p>
|
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noCourseData")}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-lg">Batch Capacity</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">{t("adminDash.batchCapacity")}</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{batchChartData.length > 0 ? (
|
{batchChartData.length > 0 ? (
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
@@ -156,11 +162,11 @@ export default function AdminLmsDashboard() {
|
|||||||
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
<XAxis dataKey="batch" className="fill-muted-foreground" tick={{ fontSize: 10 }} />
|
||||||
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
<YAxis className="fill-muted-foreground" tick={{ fontSize: 12 }} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="capacity" name="Capacity" fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="capacity" name={t("adminDash.chartCapacity")} fill="hsl(var(--info))" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground py-8 text-center">No batch data available.</p>
|
<p className="text-sm text-muted-foreground py-8 text-center">{t("adminDash.noBatchData")}</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -168,31 +174,43 @@ export default function AdminLmsDashboard() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">Quick Summary</CardTitle>
|
<CardTitle className="text-lg">{t("adminDash.quickSummary")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Module</TableHead><TableHead className="text-right">Count</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("adminDash.colModule")}</TableHead>
|
||||||
|
<TableHead className="text-end">{t("adminDash.colCount")}</TableHead>
|
||||||
|
<TableHead>{t("adminDash.colStatus")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Exams</TableCell>
|
<TableCell className="font-medium">{t("adminDash.exams")}</TableCell>
|
||||||
<TableCell className="text-right">{dbStats?.total_exams ?? 0}</TableCell>
|
<TableCell className="text-end"><bdi>{dbStats?.total_exams ?? 0}</bdi></TableCell>
|
||||||
<TableCell className="text-muted-foreground">{dbStats?.total_exam_sessions ?? 0} sessions</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
|
{t("adminDash.examSessionsCount", { n: dbStats?.total_exam_sessions ?? 0 })}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Assignments</TableCell>
|
<TableCell className="font-medium">{t("adminDash.assignments")}</TableCell>
|
||||||
<TableCell className="text-right">{dbStats?.total_assignments ?? 0}</TableCell>
|
<TableCell className="text-end"><bdi>{dbStats?.total_assignments ?? 0}</bdi></TableCell>
|
||||||
<TableCell className="text-muted-foreground">across courses</TableCell>
|
<TableCell className="text-muted-foreground">{t("adminDash.acrossCourses")}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Support Tickets</TableCell>
|
<TableCell className="font-medium">{t("adminDash.supportTickets")}</TableCell>
|
||||||
<TableCell className="text-right">{dbStats?.total_tickets ?? 0}</TableCell>
|
<TableCell className="text-end"><bdi>{dbStats?.total_tickets ?? 0}</bdi></TableCell>
|
||||||
<TableCell className="text-muted-foreground">{openTickets} open</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
|
{t("adminDash.ticketsOpenCount", { n: openTickets })}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="font-medium">Payments</TableCell>
|
<TableCell className="font-medium">{t("adminDash.payments")}</TableCell>
|
||||||
<TableCell className="text-right">{dbStats?.total_payments ?? 0}</TableCell>
|
<TableCell className="text-end"><bdi>{dbStats?.total_payments ?? 0}</bdi></TableCell>
|
||||||
<TableCell className="text-muted-foreground">${revenue.toLocaleString()} total</TableCell>
|
<TableCell className="text-muted-foreground">
|
||||||
|
{t("adminDash.revenueTotal", { amount: `$${revenue.toLocaleString()}` })}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function AdmissionDetail() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
|
<Button variant="ghost" size="icon" onClick={() => navigate("/admin/admissions")}>
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4 rtl:rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
|
<h1 className="text-2xl font-bold">{admission.first_name} {admission.last_name}</h1>
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export default function ApprovalWorkflowConfig() {
|
|||||||
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
|
<span className="h-5 w-5 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-medium">{step.order}</span>
|
||||||
<span>{step.approver_name}</span>
|
<span>{step.approver_name}</span>
|
||||||
</div>
|
</div>
|
||||||
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground" />}
|
{i < wf.steps.length - 1 && <ArrowRight className="h-4 w-4 text-muted-foreground rtl:rotate-180" />}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function ExamReviewDetail() {
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
<Button asChild variant="ghost" size="sm" className="-ml-2">
|
||||||
<Link to="/admin/exam/review-queue">
|
<Link to="/admin/exam/review-queue">
|
||||||
<ArrowLeft className="h-4 w-4 mr-1" /> Back to queue
|
<ArrowLeft className="h-4 w-4 me-1 rtl:rotate-180" /> Back to queue
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
|
<h1 className="text-2xl font-bold tracking-tight">{exam.title}</h1>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export default function DiagnosticTest() {
|
|||||||
)}
|
)}
|
||||||
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
|
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
|
||||||
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
|
Submit Answer <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export default function LearningPlanPage() {
|
|||||||
</div>
|
</div>
|
||||||
{(item.status === "available" || item.status === "in_progress") && (
|
{(item.status === "available" || item.status === "in_progress") && (
|
||||||
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
|
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
|
||||||
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
|
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{item.status === "completed" && (
|
{item.status === "completed" && (
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export default function ProficiencyProfile() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
|
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
|
||||||
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
|
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" /></Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function StudentCourseDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{course.title}</h1>
|
<h1 className="text-2xl font-bold">{course.title}</h1>
|
||||||
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>
|
<p className="text-muted-foreground">{course.code} · {chapters.length} chapters</p>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight, Play } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
import { useMyEnrolledCourses, useGrades } from "@/hooks/queries";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
import AiStudyCoach from "@/components/ai/AiStudyCoach";
|
||||||
@@ -11,6 +12,7 @@ import AiTipBanner from "@/components/ai/AiTipBanner";
|
|||||||
|
|
||||||
export default function StudentDashboard() {
|
export default function StudentDashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
const { data: enrolledData, isLoading: lc } = useMyEnrolledCourses();
|
||||||
const { data: gradesData, isLoading: lg } = useGrades();
|
const { data: gradesData, isLoading: lg } = useGrades();
|
||||||
const myCourses = enrolledData?.items ?? [];
|
const myCourses = enrolledData?.items ?? [];
|
||||||
@@ -22,20 +24,20 @@ export default function StudentDashboard() {
|
|||||||
const avgGrade = gradeRecords.length > 0
|
const avgGrade = gradeRecords.length > 0
|
||||||
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
|
? Math.round(gradeRecords.reduce((s, g) => s + (g.grade / g.max_grade) * 100, 0) / gradeRecords.length)
|
||||||
: 0;
|
: 0;
|
||||||
const firstName = user?.name?.split(" ")[0] || "Student";
|
const firstName = user?.name?.split(" ")[0] || t("dashboard.greetingFallback");
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
{ label: t("studentDash.enrolledCourses"), value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
|
||||||
{ label: "Overall Progress", value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
{ label: t("studentDash.overallProgress"), value: `${avgProgress}%`, icon: ClipboardList, color: "text-warning" },
|
||||||
{ label: "Average Grade", value: gradeRecords.length > 0 ? `${avgGrade}%` : "N/A", icon: BarChart3, color: "text-success" },
|
{ label: t("studentDash.averageGrade"), 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.totalChapters"), value: String(myCourses.reduce((s, c) => s + c.chapter_count, 0)), icon: Calendar, color: "text-info" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Welcome back, {firstName}!</h1>
|
<h1 className="text-2xl font-bold">{t("studentDash.welcome", { name: firstName })}</h1>
|
||||||
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
|
<p className="text-muted-foreground">{t("studentDash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="student-dashboard" variant="tip" />
|
<AiTipBanner context="student-dashboard" variant="tip" />
|
||||||
@@ -46,12 +48,12 @@ export default function StudentDashboard() {
|
|||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<Card key={s.label}>
|
<Card key={s.label}>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-muted-foreground">{s.label}</p>
|
<p className="text-sm text-muted-foreground">{s.label}</p>
|
||||||
<p className="text-2xl font-bold">{s.value}</p>
|
<p className="text-2xl font-bold"><bdi>{s.value}</bdi></p>
|
||||||
</div>
|
</div>
|
||||||
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
|
<s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -61,22 +63,28 @@ export default function StudentDashboard() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">My Courses</CardTitle>
|
<CardTitle className="text-lg">{t("studentDash.myCourses")}</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/student/courses">
|
||||||
|
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{myCourses.length === 0 ? (
|
{myCourses.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">No enrolled courses yet.</p>
|
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noEnrolledCourses")}</p>
|
||||||
) : myCourses.map((c) => (
|
) : myCourses.map((c) => (
|
||||||
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
|
||||||
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
|
<p className="font-medium text-sm truncate">{c.title || c.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{c.chapter_count} chapters · {c.total_materials} materials</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("studentDash.chaptersMaterials", { chapters: c.chapter_count, materials: c.total_materials })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
|
||||||
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span>
|
<span className="text-xs font-medium w-8 text-end"><bdi>{c.progress}%</bdi></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -87,35 +95,45 @@ export default function StudentDashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">Quick Actions</CardTitle>
|
<CardTitle className="text-lg">{t("studentDash.quickActions")}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{myCourses.slice(0, 3).map(c => (
|
{myCourses.slice(0, 3).map(c => (
|
||||||
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
<Link key={c.id} to={`/student/courses/${c.id}`} className="flex items-center gap-3 p-3 rounded-lg border hover:bg-muted/50 transition-colors">
|
||||||
<Play className="h-4 w-4 text-primary shrink-0" />
|
<Play className="h-4 w-4 text-primary shrink-0" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{c.progress > 0 ? "Continue" : "Start"} {c.title || c.name}</p>
|
<p className="text-sm font-medium truncate">
|
||||||
<p className="text-xs text-muted-foreground">{c.completed_chapters}/{c.chapter_count} chapters done</p>
|
{c.progress > 0 ? t("studentDash.continue") : t("studentDash.start")} {c.title || c.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("studentDash.chaptersDone", { done: c.completed_chapters, total: c.chapter_count })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline">{c.progress}%</Badge>
|
<Badge variant="outline"><bdi>{c.progress}%</bdi></Badge>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
{myCourses.length === 0 && <p className="text-sm text-muted-foreground text-center py-4">Enroll in a course to get started.</p>}
|
{myCourses.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.enrollToStart")}</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">Recent Grades</CardTitle>
|
<CardTitle className="text-lg">{t("studentDash.recentGrades")}</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/student/grades">
|
||||||
|
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{recentGrades.length === 0 ? (
|
{recentGrades.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">No grades yet.</p>
|
<p className="text-sm text-muted-foreground text-center py-4">{t("studentDash.noGradesYet")}</p>
|
||||||
) : recentGrades.map((g) => (
|
) : recentGrades.map((g) => (
|
||||||
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
|
<div key={g.id} className="flex items-center justify-between p-2 rounded border gap-3">
|
||||||
<div><p className="text-sm font-medium">{g.assignment_title}</p><p className="text-xs text-muted-foreground">{g.course_name}</p></div>
|
<div className="min-w-0"><p className="text-sm font-medium truncate" dir="auto">{g.assignment_title}</p><p className="text-xs text-muted-foreground truncate" dir="auto">{g.course_name}</p></div>
|
||||||
<span className="text-sm font-bold text-primary">{g.grade}/{g.max_grade}</span>
|
<span className="text-sm font-bold text-primary shrink-0"><bdi>{g.grade}/{g.max_grade}</bdi></span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ export default function StudentDiscussionBoard() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
|
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
|
<ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
|
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
|
||||||
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
|
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function SubjectSelection() {
|
|||||||
View Profile
|
View Profile
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
|
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
|
||||||
Learning Plan <ArrowRight className="ml-1 h-3 w-3" />
|
Learning Plan <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -66,7 +66,7 @@ export default function SubjectSelection() {
|
|||||||
<>
|
<>
|
||||||
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
|
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
|
||||||
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
|
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
|
||||||
Start Diagnostic <ArrowRight className="ml-1 h-4 w-4" />
|
Start Diagnostic <ArrowRight className="ms-1 h-4 w-4 rtl:rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function TeacherAssignmentDetail() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4" /></Link></Button>
|
<Button variant="ghost" size="icon" asChild><Link to="/teacher/assignments"><ArrowLeft className="h-4 w-4 rtl:rotate-180" /></Link></Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">{assignment.title}</h1>
|
<h1 className="text-2xl font-bold">{assignment.title}</h1>
|
||||||
<p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p>
|
<p className="text-muted-foreground">{assignment.entity_name} · Due: {assignment.end_date}</p>
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react";
|
import { BookOpen, Users, ClipboardList, TrendingUp, ArrowRight } from "lucide-react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useCourses, useAssignments } from "@/hooks/queries";
|
import { useCourses, useAssignments } from "@/hooks/queries";
|
||||||
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
|
import AiInsightsPanel from "@/components/ai/AiInsightsPanel";
|
||||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||||
|
|
||||||
export default function TeacherDashboard() {
|
export default function TeacherDashboard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { data: coursesData, isLoading: lc } = useCourses();
|
const { data: coursesData, isLoading: lc } = useCourses();
|
||||||
const { data: assignmentsData, isLoading: la } = useAssignments();
|
const { data: assignmentsData, isLoading: la } = useAssignments();
|
||||||
const courses = coursesData?.items ?? [];
|
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 pendingGrading = (submissions as { id: string; studentName: string; submittedAt: string; status: string }[]).filter(s => s.status === "pending");
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ label: "Active Courses", value: String(teacherCourses.filter(c => c.status === "active").length), icon: BookOpen, color: "text-primary" },
|
{ label: t("teacherDash.activeCourses"), 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: t("teacherDash.totalStudents"), value: "55", icon: Users, color: "text-info" },
|
||||||
{ label: "Pending Grading", value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
|
{ label: t("teacherDash.pendingGrading"), value: String(pendingGrading.length), icon: ClipboardList, color: "text-warning" },
|
||||||
{ label: "Avg. Pass Rate", value: "82%", icon: TrendingUp, color: "text-success" },
|
{ label: t("teacherDash.avgPassRate"), value: "82%", icon: TrendingUp, color: "text-success" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold">Teacher Dashboard</h1>
|
<h1 className="text-2xl font-bold">{t("teacherDash.title")}</h1>
|
||||||
<p className="text-muted-foreground">Overview of your teaching activities.</p>
|
<p className="text-muted-foreground">{t("teacherDash.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AiTipBanner context="teacher-dashboard" variant="recommendation" />
|
<AiTipBanner context="teacher-dashboard" variant="recommendation" />
|
||||||
@@ -42,9 +44,9 @@ export default function TeacherDashboard() {
|
|||||||
{stats.map(s => (
|
{stats.map(s => (
|
||||||
<Card key={s.label}>
|
<Card key={s.label}>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold">{s.value}</p></div>
|
<div className="min-w-0"><p className="text-sm text-muted-foreground">{s.label}</p><p className="text-2xl font-bold"><bdi>{s.value}</bdi></p></div>
|
||||||
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
|
<s.icon className={`h-8 w-8 ${s.color} opacity-80 shrink-0`} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -54,17 +56,27 @@ export default function TeacherDashboard() {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">My Courses</CardTitle>
|
<CardTitle className="text-lg">{t("teacherDash.myCourses")}</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild><Link to="/teacher/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/teacher/courses">
|
||||||
|
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader><TableRow><TableHead>Course</TableHead><TableHead>Students</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("teacherDash.colCourse")}</TableHead>
|
||||||
|
<TableHead>{t("teacherDash.colStudents")}</TableHead>
|
||||||
|
<TableHead>{t("teacherDash.colStatus")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{teacherCourses.map(c => (
|
{teacherCourses.map(c => (
|
||||||
<TableRow key={c.id}>
|
<TableRow key={c.id}>
|
||||||
<TableCell className="font-medium">{c.title}</TableCell>
|
<TableCell className="font-medium" dir="auto">{c.title}</TableCell>
|
||||||
<TableCell>{c.enrolled}/{c.max_capacity}</TableCell>
|
<TableCell><bdi>{c.enrolled}/{c.max_capacity}</bdi></TableCell>
|
||||||
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
|
<TableCell><Badge variant={c.status === "active" ? "default" : "secondary"} className="capitalize">{c.status}</Badge></TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -76,26 +88,35 @@ export default function TeacherDashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
<CardTitle className="text-lg">Pending Grading</CardTitle>
|
<CardTitle className="text-lg">{t("teacherDash.pendingGrading")}</CardTitle>
|
||||||
<Button variant="ghost" size="sm" asChild><Link to="/teacher/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
|
<Button variant="ghost" size="sm" asChild>
|
||||||
|
<Link to="/teacher/assignments">
|
||||||
|
{t("common.viewAll")} <ArrowRight className="ms-1 h-3 w-3 rtl:rotate-180" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{pendingGrading.slice(0, 4).map(s => (
|
{pendingGrading.slice(0, 4).map(s => (
|
||||||
<div key={s.id} className="flex items-center justify-between p-2 rounded border">
|
<div key={s.id} className="flex items-center justify-between p-2 rounded border">
|
||||||
<div><p className="text-sm font-medium">{s.studentName}</p><p className="text-xs text-muted-foreground">Submitted {s.submittedAt}</p></div>
|
<div>
|
||||||
<Badge variant="secondary">Pending</Badge>
|
<p className="text-sm font-medium">{s.studentName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("teacherDash.submitted", { when: s.submittedAt })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">{t("teacherDash.pending")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-lg">Recent Activity</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">{t("teacherDash.recentActivity")}</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{activityFeed.slice(0, 4).map(a => (
|
{activityFeed.slice(0, 4).map(a => (
|
||||||
<div key={a.id} className="text-sm">
|
<div key={a.id} className="text-sm">
|
||||||
<span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span>
|
<span className="font-medium">{a.user}</span> <span className="text-muted-foreground">{a.action}</span> <span>{a.target}</span>
|
||||||
<span className="text-xs text-muted-foreground ml-2">· {a.time}</span>
|
<span className="text-xs text-muted-foreground ms-2">· {a.time}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ export default function TeacherDiscussionBoard() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
|
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
|
<ArrowLeft className="me-2 h-4 w-4 rtl:rotate-180" /> Back to Boards
|
||||||
</Button>
|
</Button>
|
||||||
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
|
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
|
||||||
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
|
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
|
||||||
|
|||||||
@@ -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;
|
} satisfies Config;
|
||||||
|
|||||||
Reference in New Issue
Block a user