feat(i18n,rtl): full Arabic localization + RTL sweep across all layouts
Frontend - i18n: install tailwindcss-rtl, Cairo font, RTL-aware direction in index.css. - Language toggle: localize aria-label / menu label, persist choice, update document dir synchronously. - Sidebar: add `side` prop so the drawer pins to the right in RTL; wire up AdminLmsLayout, RoleLayout (student/teacher) and AppSidebar to pass side = i18n.dir() === 'rtl' ? 'right' : 'left'. - AdminLmsLayout: convert every nav item from hard-coded title to titleKey, translate group labels (incl. the collapsible Training), breadcrumbs, user menu (Profile / Settings / Logout), help button and toggle aria labels; replace physical mr-/right- utilities with logical me-/end-. - AI components (AiTipBanner, AiInsightsPanel, AiAlertBanner, AiSearchBar, AiAssistantDrawer): apply dir="auto" at the container level, localize titles, loading / error / empty states. - Dashboards (admin / student / teacher): wrap numeric values in <bdi>, localize dates via ar-EG, fix flex direction for KPI and assignment cards. - UI primitives (breadcrumb, calendar, carousel, dropdown-menu, menubar, context-menu, pagination, sidebar): flip chevrons in RTL via a scoped CSS rule, swap pl-/pr-/ml-/mr- for ps-/pe-/ms-/me-. - Add logical-direction helpers and bidirectional isolation classes. Locales - Expand en.ts and ar.ts with full `nav`, `sidebarGroup`, `breadcrumb`, `userMenu`, `chrome`, `ai`, and dashboard key sets; keep key parity. API client - `api-client.ts` reads the active language from localStorage/i18n and sends `Accept-Language` on every request so the backend can localize AI output. Backend (encoach_ai) - openai_service: add _LANGUAGE_NAMES, normalize_language, language-aware system prompt injection for every OpenAI call. - coach_service + controllers (coach_controller, ai_controller): thread the requested language from headers / user locale down to OpenAIService. - ai_feedback: fix latent registry error by pointing course_id at op.course instead of the non-existent encoach.course. Other - .gitignore: ignore runtime odoo logs and local caches. Made-with: Cursor
This commit is contained in:
@@ -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
package-lock.json
generated
8
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;
|
||||||
|
|||||||
100
src/index.css
100
src/index.css
@@ -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