39172-vm/frontend/src/pages/dashboard.tsx
Flatlogic Bot 9c59af97be 1
2026-03-13 13:29:01 +00:00

576 lines
27 KiB
TypeScript

import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config'
import Link from "next/link";
import { hasPermission } from "../helpers/userPermissions";
import { LMS_ROLE_GROUPS, userHasAnyRole } from '../helpers/roleVisibility';
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
type LearnerMetricsState = {
enrolledCourses: string | number | null;
completedCourses: string | number | null;
avgCourseProgress: string | number | null;
lessonsInProgress: string | number | null;
lessonsCompleted: string | number | null;
hoursSpent: string | number | null;
};
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const loadingMessage = 'Loading...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [courses, setCourses] = React.useState(loadingMessage);
const [lessons, setLessons] = React.useState(loadingMessage);
const [enrollments, setEnrollments] = React.useState(loadingMessage);
const [lesson_progress, setLesson_progress] = React.useState(loadingMessage);
const [announcements, setAnnouncements] = React.useState(loadingMessage);
const [course_resources, setCourse_resources] = React.useState(loadingMessage);
const [learnerMetrics, setLearnerMetrics] = React.useState<LearnerMetricsState>({
enrolledCourses: loadingMessage,
completedCourses: loadingMessage,
avgCourseProgress: loadingMessage,
lessonsInProgress: loadingMessage,
lessonsCompleted: loadingMessage,
hoursSpent: loadingMessage,
});
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
const isAdmin = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.admin);
const isInstructor = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.instructor);
const isLearner = userHasAnyRole(currentUser, LMS_ROLE_GROUPS.learner);
const overviewTitle = isLearner ? 'My Learning Overview' : isInstructor ? 'Instructor Overview' : 'Platform Overview';
async function loadData() {
const entities = ['users','roles','permissions','courses','lessons','enrollments','lesson_progress','announcements','course_resources',];
const fns = [setUsers,setRoles,setPermissions,setCourses,setLessons,setEnrollments,setLesson_progress,setAnnouncements,setCourse_resources,];
const requests = entities.map((entity, index) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
}
async function loadLearnerMetrics() {
if (!currentUser?.id || !isLearner) return;
try {
const canReadEnrollments = hasPermission(currentUser, 'READ_ENROLLMENTS');
const canReadLessonProgress = hasPermission(currentUser, 'READ_LESSON_PROGRESS');
const enrollmentsResponse = canReadEnrollments
? await axios.get('/enrollments', {
params: {
page: 0,
limit: 500,
student: currentUser.id,
},
})
: { data: { rows: [] } };
const enrollmentRows = Array.isArray(enrollmentsResponse?.data?.rows)
? enrollmentsResponse.data.rows
: [];
const enrolledCourses = enrollmentRows.length;
const completedCourses = enrollmentRows.filter((item) => item?.status === 'completed').length;
const avgCourseProgress = enrollmentRows.length
? Math.round(
enrollmentRows.reduce((sum, item) => sum + Number(item?.progress_percent || 0), 0) /
enrollmentRows.length,
)
: 0;
const enrollmentIds = enrollmentRows.map((item) => item?.id).filter(Boolean);
const lessonProgressResponse =
canReadLessonProgress && enrollmentIds.length
? await axios.get('/lesson_progress', {
params: {
page: 0,
limit: 1000,
enrollment: enrollmentIds.join('|'),
},
})
: { data: { rows: [] } };
const lessonProgressRows = Array.isArray(lessonProgressResponse?.data?.rows)
? lessonProgressResponse.data.rows
: [];
const lessonsInProgress = lessonProgressRows.filter((item) => item?.status === 'in_progress').length;
const lessonsCompleted = lessonProgressRows.filter((item) => item?.status === 'completed').length;
const hoursSpent =
Math.round(
(lessonProgressRows.reduce((sum, item) => sum + Number(item?.time_spent_seconds || 0), 0) / 3600) *
10,
) / 10;
setLearnerMetrics({
enrolledCourses,
completedCourses,
avgCourseProgress: `${avgCourseProgress}%`,
lessonsInProgress,
lessonsCompleted,
hoursSpent,
});
} catch (error) {
console.error('Failed to load learner metrics:', error);
setLearnerMetrics({
enrolledCourses: 'Error',
completedCourses: 'Error',
avgCourseProgress: 'Error',
lessonsInProgress: 'Error',
lessonsCompleted: 'Error',
hoursSpent: 'Error',
});
}
}
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
loadLearnerMetrics().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser, isLearner]);
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title={overviewTitle}
main>
{''}
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
{isLearner && (
<>
<div className='text-gray-500 dark:text-gray-400 mb-4'>My Courses & Progress</div>
<div className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
<Link href={'/enrollments/enrollments-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>My courses</div>
<div className='text-3xl leading-tight font-semibold'>{learnerMetrics.enrolledCourses}</div>
</div>
</Link>
<Link href={'/enrollments/enrollments-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Completed courses
</div>
<div className='text-3xl leading-tight font-semibold'>{learnerMetrics.completedCourses}</div>
</div>
</Link>
<Link href={'/enrollments/enrollments-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Avg course progress
</div>
<div className='text-3xl leading-tight font-semibold'>{learnerMetrics.avgCourseProgress}</div>
</div>
</Link>
<Link href={'/lesson_progress/lesson_progress-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Lessons in progress
</div>
<div className='text-3xl leading-tight font-semibold'>
{learnerMetrics.lessonsInProgress}
</div>
</div>
</Link>
<Link href={'/lesson_progress/lesson_progress-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>
Lessons completed
</div>
<div className='text-3xl leading-tight font-semibold'>
{learnerMetrics.lessonsCompleted}
</div>
</div>
</Link>
<Link href={'/lesson_progress/lesson_progress-list'}>
<div
className={`${corners !== 'rounded-full' ? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className='text-lg leading-tight text-gray-500 dark:text-gray-400'>Hours spent</div>
<div className='text-3xl leading-tight font-semibold'>{learnerMetrics.hoursSpent}</div>
</div>
</Link>
</div>
</>
)}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && !isLearner && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && isAdmin && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && isAdmin && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_COURSES') && <Link href={'/courses/courses-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Courses
</div>
<div className="text-3xl leading-tight font-semibold">
{courses}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiSchool' in icon ? icon['mdiSchool' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_LESSONS') && <Link href={'/lessons/lessons-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Lessons
</div>
<div className="text-3xl leading-tight font-semibold">
{lessons}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ENROLLMENTS') && <Link href={'/enrollments/enrollments-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Enrollments
</div>
<div className="text-3xl leading-tight font-semibold">
{enrollments}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiAccountSchool' in icon ? icon['mdiAccountSchool' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_LESSON_PROGRESS') && <Link href={'/lesson_progress/lesson_progress-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Lesson progress
</div>
<div className="text-3xl leading-tight font-semibold">
{lesson_progress}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiProgressCheck' in icon ? icon['mdiProgressCheck' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ANNOUNCEMENTS') && <Link href={'/announcements/announcements-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Announcements
</div>
<div className="text-3xl leading-tight font-semibold">
{announcements}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiBullhorn' in icon ? icon['mdiBullhorn' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_COURSE_RESOURCES') && <Link href={'/course_resources/course_resources-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Course resources
</div>
<div className="text-3xl leading-tight font-semibold">
{course_resources}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiFolderOpen' in icon ? icon['mdiFolderOpen' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div>
</SectionMain>
</>
)
}
Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard