Files
encoach_frontend_new_v2/src/components/AdminLmsLayout.tsx
Yamen Ahmad a0cee77628 feat(frontend): student dashboard course-plans, interactive workbook, media uploads, branches
Ported from monorepo v4 commit 3b62075d (frontend/* portion).

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

Made-with: Cursor
2026-04-28 00:22:29 +04:00

462 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Outlet, Link, useNavigate, useLocation, useMatch } from "react-router-dom";
import { Suspense } from "react";
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
import {
Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem,
SidebarHeader, SidebarFooter, SidebarSeparator, useSidebar,
} from "@/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { NavLink } from "@/components/NavLink";
import { useAuth } from "@/contexts/AuthContext";
import NotificationDropdown from "@/components/NotificationDropdown";
import { ThemeToggle } from "@/components/ThemeToggle";
import { LanguageToggle } from "@/components/LanguageToggle";
import AiAssistantDrawer from "@/components/ai/AiAssistantDrawer";
import AiSearchBar from "@/components/ai/AiSearchBar";
import { usePermissions } from "@/hooks/usePermissions";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem,
DropdownMenuSeparator, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList,
BreadcrumbPage, BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import {
GraduationCap, LogOut, User, Settings, LayoutDashboard, ClipboardList,
FileText, Layers, BookOpen, Wand2, GitBranch, Users, BarChart3,
Building2, History, CreditCard, Ticket, BookA, PenTool,
Calendar, ChevronDown, HelpCircle, LucideIcon, Target, FolderOpen,
CalendarDays, Landmark, UserPlus, ScrollText, Award,
HelpCircle as FaqIcon, Bell, Workflow,
CalendarOff, DollarSign, BookMarked, BarChartHorizontal, TrendingUp,
Library, Activity, Warehouse, UserCog, Sparkles, Compass,
} from "lucide-react";
import React from "react";
import { useTranslation } from "react-i18next";
// ============= Navigation Config =============
// 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[] = [
{ titleKey: "nav.smartWizard", url: "/admin/smart-wizard", icon: Sparkles },
{ titleKey: "nav.adminDashboard", url: "/admin/dashboard", icon: LayoutDashboard },
{ titleKey: "nav.platformDashboard", url: "/admin/platform", icon: BarChart3 },
];
const lmsItems: NavItem[] = [
{ titleKey: "nav.courses", url: "/admin/courses", icon: BookOpen },
{ titleKey: "nav.students", url: "/admin/students", icon: Users },
{ titleKey: "nav.teachers", url: "/admin/teachers", icon: GraduationCap },
{ titleKey: "nav.branches", url: "/admin/branches", icon: GitBranch },
{ titleKey: "nav.classrooms", url: "/admin/classrooms", icon: GraduationCap },
{ titleKey: "nav.batches", url: "/admin/batches", icon: Layers },
{ titleKey: "nav.timetable", url: "/admin/timetable", icon: Calendar },
{ titleKey: "nav.reports", url: "/admin/reports", icon: BarChart3 },
];
const academicItems: NavItem[] = [
{ titleKey: "nav.assignments", url: "/admin/assignments", icon: ClipboardList },
{ titleKey: "nav.examsList", url: "/admin/examsList", icon: FileText },
{ titleKey: "nav.examStructures", url: "/admin/exam-structures", icon: Layers },
{ titleKey: "nav.rubrics", url: "/admin/rubrics", icon: BookOpen },
{ titleKey: "nav.generation", url: "/admin/generation", icon: Wand2 },
{ titleKey: "nav.reviewQueue", url: "/admin/exam/review-queue", icon: Sparkles },
{ titleKey: "nav.aiPrompts", url: "/admin/ai/prompts", icon: Wand2 },
{ titleKey: "nav.aiFeedback", url: "/admin/ai/feedback", icon: Sparkles },
{ titleKey: "nav.coursePlans", url: "/admin/course-plans", icon: Compass },
{ titleKey: "nav.approvalWorkflows", url: "/admin/approval-workflows", icon: GitBranch },
];
const adaptiveItems: NavItem[] = [
{ titleKey: "nav.taxonomy", url: "/admin/taxonomy", icon: Target },
{ titleKey: "nav.resources", url: "/admin/resources", icon: FolderOpen },
];
const institutionalItems: NavItem[] = [
{ titleKey: "nav.academicYears", url: "/admin/academic-years", icon: CalendarDays },
{ titleKey: "nav.departments", url: "/admin/departments", icon: Landmark },
{ titleKey: "nav.admissions", url: "/admin/admissions", icon: UserPlus },
{ titleKey: "nav.admissionRegister", url: "/admin/admission-register", icon: ScrollText },
{ titleKey: "nav.examSessions", url: "/admin/exam-sessions", icon: FileText },
{ titleKey: "nav.marksheets", url: "/admin/marksheets", icon: Award },
{ titleKey: "nav.studentLeave", url: "/admin/student-leave", icon: CalendarOff },
{ titleKey: "nav.fees", url: "/admin/fees", icon: DollarSign },
{ titleKey: "nav.lessons", url: "/admin/lessons", icon: BookMarked },
{ titleKey: "nav.gradebook", url: "/admin/gradebook", icon: BarChartHorizontal },
{ titleKey: "nav.studentProgress", url: "/admin/student-progress", icon: TrendingUp },
{ titleKey: "nav.library", url: "/admin/library", icon: Library },
{ titleKey: "nav.activities", url: "/admin/activities", icon: Activity },
{ titleKey: "nav.facilities", url: "/admin/facilities", icon: Warehouse },
];
const managementItems: NavItem[] = [
{ titleKey: "nav.users", url: "/admin/users", icon: Users },
{ titleKey: "nav.entities", url: "/admin/entities", icon: Building2 },
{ titleKey: "nav.userRoles", url: "/admin/user-roles", icon: UserCog },
{ titleKey: "nav.rolesPermissions", url: "/admin/roles-permissions", icon: Settings },
{ titleKey: "nav.authorityMatrix", url: "/admin/authority-matrix", icon: Workflow },
];
const reportItems: NavItem[] = [
{ titleKey: "nav.studentPerformance", url: "/admin/student-performance", icon: BarChart3 },
{ titleKey: "nav.statsCorporate", url: "/admin/stats-corporate", icon: Building2 },
{ titleKey: "nav.record", url: "/admin/record", icon: History },
];
const trainingItems: NavItem[] = [
{ titleKey: "nav.vocabulary", url: "/admin/training/vocabulary", icon: BookA },
{ titleKey: "nav.grammar", url: "/admin/training/grammar", icon: PenTool },
];
const configItems: NavItem[] = [
{ titleKey: "nav.faqManager", url: "/admin/faq", icon: FaqIcon },
{ titleKey: "nav.notificationRules", url: "/admin/notification-rules", icon: Bell },
{ titleKey: "nav.approvalConfig", url: "/admin/approval-config", icon: Workflow },
];
const supportItems: NavItem[] = [
{ titleKey: "nav.paymentRecord", url: "/admin/payment-record", icon: CreditCard },
{ titleKey: "nav.tickets", url: "/admin/tickets", icon: Ticket },
{ titleKey: "nav.settings", url: "/admin/settings-platform", icon: Settings },
];
// ============= Reusable sidebar group =============
function SidebarNavGroup({ labelKey, items }: { labelKey: string; items: NavItem[] }) {
const { state } = useSidebar();
const { t } = useTranslation();
const collapsed = state === "collapsed";
return (
<SidebarGroup>
<SidebarGroupLabel>{t(labelKey)}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
end={item.url.endsWith("/dashboard") || item.url.endsWith("/platform")}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
// ============= Breadcrumbs =============
// Map URL segments to i18n keys so breadcrumbs follow the active locale.
// Segments without an entry fall back to a Title-cased version of the slug.
const routeLabelKeys: Record<string, string> = {
admin: "breadcrumb.admin", dashboard: "breadcrumb.dashboard",
platform: "breadcrumb.platform", courses: "nav.courses",
students: "nav.students", teachers: "nav.teachers", branches: "nav.branches", batches: "nav.batches",
timetable: "nav.timetable", reports: "nav.reports",
assignments: "nav.assignments", examsList: "nav.examsList",
"exam-structures": "nav.examStructures", rubrics: "nav.rubrics",
generation: "nav.generation", "review-queue": "nav.reviewQueue",
review: "breadcrumb.review", prompts: "nav.aiPrompts", ai: "breadcrumb.ai",
feedback: "breadcrumb.feedback",
"approval-workflows": "nav.approvalWorkflows", users: "nav.users",
entities: "nav.entities", classrooms: "nav.classrooms",
"student-performance": "nav.studentPerformance",
"stats-corporate": "nav.statsCorporate", record: "nav.record",
training: "sidebarGroup.training", vocabulary: "nav.vocabulary",
grammar: "nav.grammar", "payment-record": "nav.paymentRecord",
tickets: "nav.tickets", "settings-platform": "nav.settings",
settings: "nav.settings", profile: "nav.profile", exam: "breadcrumb.exam",
"academic-years": "nav.academicYears", departments: "nav.departments",
admissions: "nav.admissions", "admission-register": "nav.admissionRegister",
"exam-sessions": "nav.examSessions", marksheets: "nav.marksheets",
faq: "nav.faqManager", "notification-rules": "nav.notificationRules",
"approval-config": "nav.approvalConfig",
"student-leave": "nav.studentLeave", fees: "nav.fees",
lessons: "nav.lessons", gradebook: "nav.gradebook",
"student-progress": "nav.studentProgress", library: "nav.library",
activities: "nav.activities", facilities: "nav.facilities",
};
function AppBreadcrumbs() {
const location = useLocation();
const { t } = useTranslation();
const segments = location.pathname.split("/").filter(Boolean);
return (
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild><Link to="/admin/dashboard">{t("common.home")}</Link></BreadcrumbLink>
</BreadcrumbItem>
{segments.map((seg, i) => {
const path = "/" + segments.slice(0, i + 1).join("/");
const key = routeLabelKeys[seg];
const label = key ? t(key) : seg.charAt(0).toUpperCase() + seg.slice(1);
const isLast = i === segments.length - 1;
return (
<React.Fragment key={path}>
<BreadcrumbSeparator />
<BreadcrumbItem>
{isLast ? <BreadcrumbPage>{label}</BreadcrumbPage> : <BreadcrumbLink asChild><Link to={path}>{label}</Link></BreadcrumbLink>}
</BreadcrumbItem>
</React.Fragment>
);
})}
</BreadcrumbList>
</Breadcrumb>
);
}
// ============= Route content fallback =============
// Shown only inside the main content area while a lazy-loaded route chunk
// is fetching. Keeping the fallback local means the sidebar and header
// stay mounted during navigation — previously the page felt like a full
// browser reload because the outer App-level Suspense replaced everything
// with a full-viewport spinner.
function RouteContentFallback() {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
</div>
);
}
// ============= Main Layout =============
export default function AdminLmsLayout() {
const { user, logout, selectedEntity, setSelectedEntityId } = useAuth();
const navigate = useNavigate();
const { t } = useTranslation();
// Hide the floating "Need help?" pill on multi-step wizard routes.
// It sits at `fixed bottom-6 end-6` z-50 and was intercepting clicks
// on the wizard's own Next/Finish footer (real bug repro: trying to
// click "Next" in the Course-plan wizard at the standard 1024×768
// viewport hits the pill instead). The AI Assistant orb also at the
// bottom-right is preserved — it's smaller and useful in-wizard.
const isWizardRoute = !!useMatch("/admin/smart-wizard/*");
const initials = user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??";
const handleLogout = async () => {
await logout();
navigate("/login");
};
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<AdminSidebar />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 border-b bg-card flex items-center justify-between px-4 shrink-0">
<div className="flex items-center gap-3">
<SidebarTrigger aria-label={t("chrome.toggleSidebar")} />
<AppBreadcrumbs />
</div>
<AiSearchBar />
<div className="flex items-center gap-2">
{user?.entities?.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1">
<Building2 className="h-4 w-4" />
<span className="max-w-40 truncate">
{selectedEntity?.name ?? user.entities[0]?.name ?? t("nav.entities")}
</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<div className="px-2 py-1.5 text-xs text-muted-foreground">
{t("nav.entities")}
</div>
<DropdownMenuSeparator />
{user.entities.map((entity) => (
<DropdownMenuItem
key={entity.id}
onClick={() => {
setSelectedEntityId(entity.id);
// Force page data to refresh against the newly
// selected entity scope.
window.location.reload();
}}
className="flex items-center justify-between gap-2"
>
<span className="truncate">{entity.name}</span>
{selectedEntity?.id === entity.id && (
<Badge variant="secondary" className="text-[10px]">
{t("common.active", "Active")}
</Badge>
)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<LanguageToggle />
<ThemeToggle />
<NotificationDropdown />
<Button variant="ghost" size="icon" aria-label={t("chrome.ticketsTooltip")} onClick={() => navigate("/admin/tickets")} className="text-muted-foreground hover:text-foreground">
<Ticket className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<div className="h-8 w-8 rounded-full bg-primary flex items-center justify-center">
<span className="text-xs font-semibold text-primary-foreground">{initials}</span>
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<div className="px-3 py-2">
<p className="text-sm font-medium">{user?.name ?? t("userMenu.userFallback")}</p>
<p className="text-xs text-muted-foreground">{user?.email ?? ""}</p>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => navigate("/admin/profile")}>
<User className="me-2 h-4 w-4" /> {t("userMenu.profile")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate("/admin/settings-platform")}>
<Settings className="me-2 h-4 w-4" /> {t("userMenu.settings")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="me-2 h-4 w-4" /> {t("userMenu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<main className="flex-1 overflow-auto p-6">
<Suspense fallback={<RouteContentFallback />}>
<Outlet />
</Suspense>
</main>
</div>
</div>
<AiAssistantDrawer />
{!isWizardRoute && (
<Link
to="/admin/tickets"
className="fixed bottom-6 end-6 z-40 flex items-center gap-2 rounded-full bg-primary px-4 py-2.5 text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
>
<HelpCircle className="h-4 w-4" />
<span className="text-sm font-medium">{t("chrome.needHelp")}</span>
</Link>
)}
</SidebarProvider>
);
}
// ============= Admin Sidebar =============
function AdminSidebar() {
const { state } = useSidebar();
const collapsed = state === "collapsed";
const { user } = useAuth();
const { hasAnyPermission, isAdmin } = usePermissions();
const { t, i18n } = useTranslation();
const sidebarSide = i18n.dir() === "rtl" ? "right" : "left";
const showManagement = isAdmin || hasAnyPermission(["view_entities", "view_students", "view_teachers"]);
const showReports = isAdmin || hasAnyPermission(["view_statistics", "view_student_performance", "view_entity_statistics"]);
const showPayments = isAdmin || hasAnyPermission(["view_payment_record", "pay_entity"]);
return (
<Sidebar side={sidebarSide} collapsible="icon">
<SidebarHeader className="p-4">
<div className="flex items-center gap-2">
{collapsed ? (
<img
src="/logo.png"
alt={t("chrome.adminAlt")}
className="h-9 w-9 shrink-0 rounded-lg object-contain"
/>
) : (
<img
src="/logo.png"
alt={t("chrome.adminAltLong")}
className="h-14 w-auto shrink-0 object-contain"
/>
)}
</div>
</SidebarHeader>
<SidebarSeparator />
<SidebarContent>
<SidebarNavGroup labelKey="sidebarGroup.overview" items={overviewItems} />
<SidebarNavGroup labelKey="sidebarGroup.lms" items={lmsItems} />
<SidebarNavGroup labelKey="sidebarGroup.adaptiveLearning" items={adaptiveItems} />
<SidebarNavGroup labelKey="sidebarGroup.institutional" items={institutionalItems} />
<SidebarNavGroup labelKey="sidebarGroup.academic" items={academicItems} />
{showManagement && <SidebarNavGroup labelKey="sidebarGroup.management" items={managementItems} />}
{showReports && <SidebarNavGroup labelKey="sidebarGroup.reports" items={reportItems} />}
<SidebarNavGroup labelKey="sidebarGroup.configuration" items={configItems} />
<SidebarGroup>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroupLabel asChild>
<CollapsibleTrigger className="flex w-full items-center justify-between">
{t("sidebarGroup.training")}
{!collapsed && <ChevronDown className="h-4 w-4 transition-transform group-data-[state=open]/collapsible:rotate-180" />}
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{trainingItems.map((item) => {
const label = t(item.titleKey);
return (
<SidebarMenuItem key={item.url}>
<SidebarMenuButton asChild tooltip={label}>
<NavLink
to={item.url}
className="flex items-center gap-2 px-2 py-1.5 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent transition-colors"
activeClassName="bg-sidebar-accent text-sidebar-accent-foreground font-medium"
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</Collapsible>
</SidebarGroup>
<SidebarNavGroup labelKey="sidebarGroup.support" items={showPayments ? supportItems : supportItems.filter(i => i.url !== "/admin/payment-record")} />
</SidebarContent>
<SidebarSeparator />
<SidebarFooter className="p-3">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<span className="text-xs font-semibold text-primary">{user?.name?.split(" ").map(w => w[0]).join("").slice(0, 2).toUpperCase() ?? "??"}</span>
</div>
{!collapsed && (
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium truncate text-sidebar-foreground">{user?.name ?? t("userMenu.userFallback")}</span>
<span className="text-xs text-muted-foreground truncate">{user?.email ?? ""}</span>
</div>
)}
</div>
</SidebarFooter>
</Sidebar>
);
}