feat: course analytics (#574)
* feat: added anaytics for the course page * feat: Added pagination * feat: cleanup * feat: added translations * feat: added fixes * my improvements --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
f3de6a063e
commit
ec3875bac2
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
export let title: string;
|
||||
export let value: string | number;
|
||||
export let subtitle: string = '';
|
||||
export let icon: any;
|
||||
export let iconColor: string = 'blue';
|
||||
export let className: string = '';
|
||||
|
||||
$: colorClasses = {
|
||||
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
|
||||
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
|
||||
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
|
||||
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
|
||||
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
|
||||
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
|
||||
<svelte:component this={icon} className={colorClasses[iconColor]} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{#if subtitle}
|
||||
<p class="text-xs capitalize text-gray-500 dark:text-gray-500">
|
||||
{subtitle}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import type { CourseAnalytics } from '$lib/utils/types/analytics';
|
||||
import { ScaleTypes } from '@carbon/charts-svelte';
|
||||
import '@carbon/charts-svelte/styles.css';
|
||||
|
||||
export let courseAnalytics: CourseAnalytics | null = null;
|
||||
|
||||
let BarChartSimple: any;
|
||||
|
||||
// Transform data for progress distribution chart
|
||||
$: progressChartData = courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '80%+ Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage >= 80).length
|
||||
},
|
||||
{
|
||||
group: '60-79% Progress',
|
||||
value: courseAnalytics.students.filter(
|
||||
(s) => s.progressPercentage >= 60 && s.progressPercentage < 80
|
||||
).length
|
||||
},
|
||||
{
|
||||
group: 'Below 60% Progress',
|
||||
value: courseAnalytics.students.filter((s) => s.progressPercentage < 60).length
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
// Transform data for grade distribution chart
|
||||
$: gradeChartData = courseAnalytics?.students
|
||||
? [
|
||||
{
|
||||
group: '90%+ Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 90).length
|
||||
},
|
||||
{
|
||||
group: '80-89% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 80 && s.averageGrade < 90)
|
||||
.length
|
||||
},
|
||||
{
|
||||
group: '70-79% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade >= 70 && s.averageGrade < 80)
|
||||
.length
|
||||
},
|
||||
{
|
||||
group: 'Below 70% Grade',
|
||||
value: courseAnalytics.students.filter((s) => s.averageGrade < 70).length
|
||||
}
|
||||
]
|
||||
: [];
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
const charts = await import('@carbon/charts-svelte');
|
||||
BarChartSimple = charts.BarChartSimple;
|
||||
}
|
||||
});
|
||||
|
||||
// Chart options for progress distribution
|
||||
$: progressChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value',
|
||||
title: 'Number of Students'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes.LABELS,
|
||||
title: 'Progress Range'
|
||||
}
|
||||
},
|
||||
height: '300px',
|
||||
data: {
|
||||
loading: !courseAnalytics
|
||||
},
|
||||
color: {
|
||||
scale: {
|
||||
'80%+ Progress': '#10b981', // green
|
||||
'60-79% Progress': '#f59e0b', // yellow
|
||||
'Below 60% Progress': '#ef4444' // red
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Chart options for grade distribution
|
||||
$: gradeChartOptions = {
|
||||
axes: {
|
||||
left: {
|
||||
mapsTo: 'value',
|
||||
title: 'Number of Students'
|
||||
},
|
||||
bottom: {
|
||||
mapsTo: 'group',
|
||||
scaleType: ScaleTypes.LABELS,
|
||||
title: 'Grade Range'
|
||||
}
|
||||
},
|
||||
height: '300px',
|
||||
data: {
|
||||
loading: !courseAnalytics
|
||||
},
|
||||
color: {
|
||||
scale: {
|
||||
'90%+ Grade': '#10b981', // green
|
||||
'80-89% Grade': '#3b82f6', // blue
|
||||
'70-79% Grade': '#f59e0b', // yellow
|
||||
'Below 70% Grade': '#ef4444' // red
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if browser && BarChartSimple}
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<!-- Progress Distribution Chart -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Student Progress Distribution
|
||||
</h3>
|
||||
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
|
||||
<svelte:component
|
||||
this={BarChartSimple}
|
||||
data={progressChartData}
|
||||
options={progressChartOptions}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No progress data available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Grade Distribution Chart -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
|
||||
{#if courseAnalytics?.students && courseAnalytics.students.length > 0}
|
||||
<svelte:component this={BarChartSimple} data={gradeChartData} options={gradeChartOptions} />
|
||||
{:else}
|
||||
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
No grade data available
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Loading state -->
|
||||
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Student Progress Distribution
|
||||
</h3>
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">Grade Distribution</h3>
|
||||
<div class="flex h-[300px] items-center justify-center">
|
||||
<div class="animate-pulse text-gray-500 dark:text-gray-400">Loading chart...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { SkeletonPlaceholder } from 'carbon-components-svelte';
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen">
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{#each Array(4) as _}
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="flex items-center space-x-4">
|
||||
<SkeletonPlaceholder style="width: 48px; height: 48px;" />
|
||||
<div class="flex-1">
|
||||
<SkeletonPlaceholder style="width: 80px; height: 16px; margin-bottom: 8px;" />
|
||||
<SkeletonPlaceholder style="width: 40px; height: 24px; margin-bottom: 4px;" />
|
||||
<SkeletonPlaceholder style="width: 60px; height: 12px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section Skeleton -->
|
||||
<div class="mb-12">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<SkeletonPlaceholder style="width: 200px; height: 24px; margin-bottom: 24px;" />
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
|
||||
<SkeletonPlaceholder style="width: 100%; height: 300px;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Students Table Skeleton -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
|
||||
<SkeletonPlaceholder style="width: 200px; height: 24px;" />
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
{#each Array(5) as _}
|
||||
<div class="flex items-center space-x-4">
|
||||
<SkeletonPlaceholder style="width: 32px; height: 32px;" />
|
||||
<div class="flex-1">
|
||||
<SkeletonPlaceholder style="width: 150px; height: 16px; margin-bottom: 4px;" />
|
||||
<SkeletonPlaceholder style="width: 200px; height: 12px;" />
|
||||
</div>
|
||||
<SkeletonPlaceholder style="width: 80px; height: 16px;" />
|
||||
<SkeletonPlaceholder style="width: 60px; height: 16px;" />
|
||||
<SkeletonPlaceholder style="width: 40px; height: 20px;" />
|
||||
<SkeletonPlaceholder style="width: 80px; height: 12px;" />
|
||||
<SkeletonPlaceholder style="width: 100px; height: 32px;" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { ChartBar } from 'carbon-icons-svelte';
|
||||
|
||||
export let title: string = 'No data available';
|
||||
export let description: string = 'There is no data to display at this time.';
|
||||
export let icon: any = ChartBar;
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center justify-center px-4 py-12 {className}">
|
||||
<div
|
||||
class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
>
|
||||
<svelte:component this={icon} size="24" class="text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h3 class="mb-2 text-lg font-medium text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p class="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { Tag, Pagination } from 'carbon-components-svelte';
|
||||
import type { StudentOverview } from '$lib/utils/types/analytics';
|
||||
import EmptyState from './EmptyState.svelte';
|
||||
import { User } from 'carbon-icons-svelte';
|
||||
import { course } from '../../store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export let students: StudentOverview[] = [];
|
||||
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
const pageSize = 15;
|
||||
|
||||
// Computed values
|
||||
$: totalPages = Math.ceil(students.length / pageSize);
|
||||
$: startIndex = (currentPage - 1) * pageSize;
|
||||
$: endIndex = startIndex + pageSize;
|
||||
$: paginatedStudents = students.slice(startIndex, endIndex);
|
||||
$: startItem = startIndex + 1;
|
||||
$: endItem = Math.min(endIndex, students.length);
|
||||
|
||||
const GotoFullProfile = (student: StudentOverview) => {
|
||||
if (!student) return;
|
||||
goto(`/courses/${$course.id}/people/${student.id}?back=/courses/${$course.id}/analytics`);
|
||||
};
|
||||
|
||||
function handlePageChange(event: CustomEvent) {
|
||||
currentPage = event.detail.page;
|
||||
}
|
||||
|
||||
// Reset to first page when students array changes
|
||||
$: if (students.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if students.length === 0}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_students_found')}
|
||||
description={$t('analytics.no_students_description')}
|
||||
icon={User}
|
||||
/>
|
||||
{:else}
|
||||
<!-- Responsive Table -->
|
||||
<div class="relative h-[300px] overflow-hidden">
|
||||
<div class="h-full overflow-auto">
|
||||
<table class="w-full min-w-[800px]">
|
||||
<!-- Fixed Header -->
|
||||
<thead class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
|
||||
<tr class="border-b border-gray-200 dark:border-neutral-600">
|
||||
<th
|
||||
class="sticky left-0 top-0 z-20 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 text-left text-sm font-medium text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.student_name')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[150px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.lessons_completed')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[140px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.exercises_submitted')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.average_grade')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.last_seen')}
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[120px] whitespace-nowrap px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
{$t('analytics.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-600">
|
||||
{#each paginatedStudents as student}
|
||||
<tr class="group h-[100px] border-b hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<!-- Fixed Name Column -->
|
||||
<td
|
||||
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
src={student.profile.avatar_url}
|
||||
name={student.profile.fullname}
|
||||
width="w-8"
|
||||
height="h-8"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{student.profile.fullname}
|
||||
</p>
|
||||
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
|
||||
{student.profile.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<!-- Scrollable Content -->
|
||||
<td class="min-w-[150px] whitespace-nowrap px-4 py-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{student.lessonsCompleted}/{student.totalLessons}
|
||||
</span>
|
||||
<div class="w-20 flex-shrink-0">
|
||||
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-blue-500"
|
||||
style="width: {student.progressPercentage}%"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-[140px] whitespace-nowrap px-4 py-3">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{student.exercisesSubmitted} out of {student.totalExercises}
|
||||
</span>
|
||||
</td>
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<Tag
|
||||
type={student.averageGrade >= 80
|
||||
? 'green'
|
||||
: student.averageGrade >= 60
|
||||
? 'blue'
|
||||
: 'red'}
|
||||
size="sm"
|
||||
>
|
||||
{student.averageGrade}%
|
||||
</Tag>
|
||||
</td>
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{student.lastSeen}
|
||||
</span>
|
||||
</td>
|
||||
<td class="min-w-[120px] whitespace-nowrap px-4 py-3">
|
||||
<button
|
||||
class="whitespace-nowrap rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-blue-700"
|
||||
on:click={() => GotoFullProfile(student)}
|
||||
>
|
||||
{$t('analytics.view_details')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
{#if students.length > pageSize}
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-neutral-600"
|
||||
>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{$t('analytics.showing_students', {
|
||||
start: startItem,
|
||||
end: endItem,
|
||||
total: students.length
|
||||
})}
|
||||
</div>
|
||||
<Pagination
|
||||
page={currentPage}
|
||||
totalItems={students.length}
|
||||
{pageSize}
|
||||
on:change={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
export let className: string = '';
|
||||
</script>
|
||||
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
class="h-6 w-6 {className}"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z"
|
||||
/>
|
||||
</svg>
|
||||
@@ -9,6 +9,7 @@
|
||||
import IbmWatsonKnowledgeCatalog from 'carbon-icons-svelte/lib/IbmWatsonKnowledgeCatalog.svelte';
|
||||
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
|
||||
import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte';
|
||||
import Analytics from 'carbon-icons-svelte/lib/Analytics.svelte';
|
||||
import { NAV_IDS } from './constants';
|
||||
|
||||
export let name = '';
|
||||
@@ -28,6 +29,8 @@
|
||||
<Result size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === NAV_IDS.PEOPLE}
|
||||
<Group size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === NAV_IDS.ANALYTICS}
|
||||
<Analytics size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === NAV_IDS.LANDING_PAGE}
|
||||
<ApplicationWeb size={20} class="carbon-icon mr-2" />
|
||||
{:else if name === NAV_IDS.CERTIFICATES}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const NAV_IDS = {
|
||||
CERTIFICATES: 'CERTIFICATES',
|
||||
LANDING_PAGE: 'LANDING_PAGE',
|
||||
PEOPLE: 'PEOPLE',
|
||||
ANALYTICS: 'ANALYTICS',
|
||||
SETTINGS: 'SETTINGS',
|
||||
SECTION: 'SECTION'
|
||||
};
|
||||
|
||||
@@ -158,6 +158,16 @@
|
||||
return isStudent ? $currentOrg.customization.course.newsfeed : true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.ANALYTICS,
|
||||
label: $t('course.navItems.nav_analytics'),
|
||||
to: getNavItemRoute($course.id, 'analytics'),
|
||||
isPaidFeature: false,
|
||||
hideSortIcon: true,
|
||||
show() {
|
||||
return !isStudent;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.LESSONS,
|
||||
label: $t('course.navItems.nav_content'),
|
||||
@@ -262,14 +272,14 @@
|
||||
class={`
|
||||
${
|
||||
$sideBar.hidden
|
||||
? '-translate-x-[100%] absolute z-[40]'
|
||||
: 'translate-x-0 absolute md:relative z-[40]'
|
||||
? 'absolute z-[40] -translate-x-[100%]'
|
||||
: 'absolute z-[40] translate-x-0 md:relative'
|
||||
}
|
||||
transition w-[90vw] md:w-[300px] lg:w-[350px] bg-gray-100 dark:bg-black h-[calc(100vh-48px)]
|
||||
h-[calc(100vh-48px)] w-[90vw] bg-gray-100 transition md:w-[300px] lg:w-[350px] dark:bg-black
|
||||
|
||||
${
|
||||
resize ? 'border-r-8 border-r-blue-500' : 'dark:border-r-neutral-600'
|
||||
} overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`}
|
||||
} border-r-1 overflow-y-auto border border-b-0 border-l-0 border-t-0`}
|
||||
style={$sideBar.hidden === true ? 'width:0' : 'width:300px'}
|
||||
bind:this={sidebarRef}
|
||||
>
|
||||
@@ -297,7 +307,7 @@
|
||||
{#if $course.version === COURSE_VERSION.V1}
|
||||
{#each $lessons as item, index}
|
||||
<a
|
||||
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
|
||||
class="mb-2 w-[95%] pl-7 text-[0.80rem] text-black dark:text-white {isStudent &&
|
||||
!item.is_unlocked
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
@@ -309,7 +319,7 @@
|
||||
title={item.title}
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 px-4 {NavClasses.item} {(
|
||||
class="flex items-center px-4 py-2 {NavClasses.item} {(
|
||||
path || $page.url.pathname
|
||||
).includes(item.id) && NavClasses.active}"
|
||||
>
|
||||
@@ -319,7 +329,7 @@
|
||||
size="sm"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
|
||||
<span class="line-clamp-2 w-[85%] text-ellipsis">{item.title}</span>
|
||||
<span class="grow" />
|
||||
{#if !item.is_unlocked}
|
||||
<span class="text-md ml-2" title="This lesson is locked.">
|
||||
@@ -348,7 +358,7 @@
|
||||
>
|
||||
{#each section.lessons as item}
|
||||
<a
|
||||
class="pl-7 w-[95%] text-[0.80rem] mb-2 text-black dark:text-white {isStudent &&
|
||||
class="mb-2 w-[95%] pl-7 text-[0.80rem] text-black dark:text-white {isStudent &&
|
||||
!item.is_unlocked
|
||||
? 'cursor-not-allowed'
|
||||
: ''}"
|
||||
@@ -360,11 +370,11 @@
|
||||
title={item.title}
|
||||
>
|
||||
<div
|
||||
class="flex items-center py-2 px-4 {NavClasses.item} {(
|
||||
class="flex items-center px-4 py-2 {NavClasses.item} {(
|
||||
path || $page.url.pathname
|
||||
).includes(item.id) && NavClasses.active}"
|
||||
>
|
||||
<span class="w-[85%] text-ellipsis line-clamp-2">{item.title}</span>
|
||||
<span class="line-clamp-2 w-[85%] text-ellipsis">{item.title}</span>
|
||||
<span class="grow" />
|
||||
{#if !item.is_unlocked}
|
||||
<span class="text-md ml-2" title="This lesson is locked.">
|
||||
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Stornieren",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analyse"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Zertifikate",
|
||||
"nav_landing_page": "Landingpage",
|
||||
"nav_settings": "Einstellungen",
|
||||
"nav_content": "Inhalt"
|
||||
"nav_content": "Inhalt",
|
||||
"nav_analytics": "Analyse"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Keine Erlaubnis",
|
||||
@@ -1552,11 +1556,27 @@
|
||||
"assignment_completion": "Abschluss der Aufgabe",
|
||||
"assignment_completion_description": "Prozentsatz der abgeschlossenen Aufgaben",
|
||||
"average_grade": "Durchschnittsnote",
|
||||
"average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben"
|
||||
"average_grade_description": "Durchschnittsnote aller abgeschlossenen Aufgaben",
|
||||
"students": "Studenten",
|
||||
"lessons": "Unterricht",
|
||||
"tutors": "Tutoren",
|
||||
"avg_grade": "AVG -Note",
|
||||
"completion_rate": "Abschlussrate",
|
||||
"student_progress_overview": "Übersicht über den Fortschritt der Schüler",
|
||||
"student_name": "Schülername",
|
||||
"lessons_completed": "Lektionen abgeschlossen",
|
||||
"exercises_submitted": "Übungen eingereicht",
|
||||
"actions": "Aktionen",
|
||||
"view_details": "Details anzeigen",
|
||||
"no_students_found": "Keine Schüler gefunden",
|
||||
"no_students_description": "In diesem Kurs sind noch keine Studenten eingeschrieben. Die Schüler werden hier erscheinen, sobald sie sich dem Kurs anschließen.",
|
||||
"showing_students": "Zeigen {Start} bis {End} von {Total} -Studenten",
|
||||
"no_analytics_data": "Keine Analysedaten",
|
||||
"no_analytics_description": "Analysedaten für diesen Kurs können nicht geladen werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Support, wenn das Problem bestehen bleibt."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "Sie haben nicht gerettete Änderungen. Bist du sicher, dass du gehen willst?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -535,6 +535,7 @@
|
||||
"nav_submissions": "Submissions",
|
||||
"nav_marks": "Marks",
|
||||
"nav_people": "People",
|
||||
"nav_analytics": "Analytics",
|
||||
"nav_certificates": "Certificates",
|
||||
"nav_landing_page": "Landing Page",
|
||||
"nav_settings": "Settings"
|
||||
@@ -1147,6 +1148,10 @@
|
||||
"courses": "courses"
|
||||
},
|
||||
|
||||
"analytics": {
|
||||
"title": "Analytics"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"heading": "Settings",
|
||||
"cover_image": "Cover Image",
|
||||
@@ -1257,8 +1262,24 @@
|
||||
"overall_course_progress_user_description": "Based on lesson completion",
|
||||
"assignment_completion": "Assignment Completion",
|
||||
"assignment_completion_description": "Percentage of completed assignments",
|
||||
"average_grade_description": "Average grade of all completed assignments",
|
||||
"students": "Students",
|
||||
"lessons": "Lessons",
|
||||
"tutors": "tutors",
|
||||
"avg_grade": "Avg grade",
|
||||
"completion_rate": "Completion rate",
|
||||
"student_progress_overview": "Student Progress Overview",
|
||||
"student_name": "Student Name",
|
||||
"lessons_completed": "Lessons Completed",
|
||||
"exercises_submitted": "Exercises Submitted",
|
||||
"average_grade": "Average Grade",
|
||||
"average_grade_description": "Average grade of all completed assignments"
|
||||
"actions": "Actions",
|
||||
"view_details": "View Details",
|
||||
"no_students_found": "No Students Found",
|
||||
"no_students_description": "There are no students enrolled in this course yet. Students will appear here once they join the course.",
|
||||
"showing_students": "Showing {start} to {end} of {total} students",
|
||||
"no_analytics_data": "No Analytics Data",
|
||||
"no_analytics_description": "Unable to load analytics data for this course. Please try again later or contact support if the issue persists."
|
||||
},
|
||||
|
||||
"upgrade": {
|
||||
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Cancelar",
|
||||
"post": "Correo"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analítica"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Certificados",
|
||||
"nav_landing_page": "Página de destino",
|
||||
"nav_settings": "Ajustes",
|
||||
"nav_content": "Contenido"
|
||||
"nav_content": "Contenido",
|
||||
"nav_analytics": "Analítica"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Sin autorización",
|
||||
@@ -1552,11 +1556,27 @@
|
||||
"assignment_completion": "Finalización de la tarea",
|
||||
"assignment_completion_description": "Porcentaje de tareas completadas",
|
||||
"average_grade": "Calificación promedio",
|
||||
"average_grade_description": "Calificación promedio de todas las tareas completadas"
|
||||
"average_grade_description": "Calificación promedio de todas las tareas completadas",
|
||||
"students": "Estudiantes",
|
||||
"lessons": "Lecciones",
|
||||
"tutors": "tutores",
|
||||
"avg_grade": "AVG Grado",
|
||||
"completion_rate": "Tasa de finalización",
|
||||
"student_progress_overview": "Descripción general del progreso del estudiante",
|
||||
"student_name": "Nombre de estudiante",
|
||||
"lessons_completed": "Lecciones completadas",
|
||||
"exercises_submitted": "Ejercicios presentados",
|
||||
"actions": "Comportamiento",
|
||||
"view_details": "Ver detalles",
|
||||
"no_students_found": "No se encontraron estudiantes",
|
||||
"no_students_description": "Todavía no hay estudiantes matriculados en este curso. Los estudiantes aparecerán aquí una vez que se unan al curso.",
|
||||
"showing_students": "Mostrando {inicio} a {final} de {Total} Estudiantes",
|
||||
"no_analytics_data": "Sin datos de análisis",
|
||||
"no_analytics_description": "No se puede cargar datos de análisis para este curso. Vuelva a intentarlo más tarde o comuníquese con el soporte si el problema persiste."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "Tienes cambios no salvos. ¿Estás seguro de que quieres irte?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Annuler",
|
||||
"post": "Poste"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analytique"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Certificats",
|
||||
"nav_landing_page": "Page de destination",
|
||||
"nav_settings": "Paramètres",
|
||||
"nav_content": "Contenu"
|
||||
"nav_content": "Contenu",
|
||||
"nav_analytics": "Analytique"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Aucune autorisation",
|
||||
@@ -1552,7 +1556,23 @@
|
||||
"assignment_completion": "Achèvement de la mission",
|
||||
"assignment_completion_description": "Pourcentage de missions terminées",
|
||||
"average_grade": "Note moyenne",
|
||||
"average_grade_description": "Note moyenne de tous les devoirs terminés"
|
||||
"average_grade_description": "Note moyenne de tous les devoirs terminés",
|
||||
"students": "Étudiants",
|
||||
"lessons": "Leçons",
|
||||
"tutors": "tuteurs",
|
||||
"avg_grade": "Grade AVG",
|
||||
"completion_rate": "Taux d'achèvement",
|
||||
"student_progress_overview": "Aperçu des progrès des étudiants",
|
||||
"student_name": "Nom de l'étudiant",
|
||||
"lessons_completed": "Leçons terminées",
|
||||
"exercises_submitted": "Exercices soumis",
|
||||
"actions": "Actes",
|
||||
"view_details": "Afficher les détails",
|
||||
"no_students_found": "Aucun étudiant trouvé",
|
||||
"no_students_description": "Il n'y a pas encore d'étudiants inscrits à ce cours. Les étudiants apparaîtront ici une fois qu'ils auront participé au cours.",
|
||||
"showing_students": "Affichage {démarrer} à {fin} des étudiants {totaux}",
|
||||
"no_analytics_data": "Aucune donnée d'analyse",
|
||||
"no_analytics_description": "Impossible de charger des données d'analyse pour ce cours. Veuillez réessayer plus tard ou contacter le support si le problème persiste."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "रद्द करना",
|
||||
"post": "डाक"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "एनालिटिक्स"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "प्रमाण पत्र",
|
||||
"nav_landing_page": "लैंडिंग पृष्ठ",
|
||||
"nav_settings": "समायोजन",
|
||||
"nav_content": "सामग्री"
|
||||
"nav_content": "सामग्री",
|
||||
"nav_analytics": "एनालिटिक्स"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "अनुमति नहीं",
|
||||
@@ -1552,11 +1556,27 @@
|
||||
"assignment_completion": "असाइनमेंट पूरा करना",
|
||||
"assignment_completion_description": "पूर्ण किये गये कार्यों का प्रतिशत",
|
||||
"average_grade": "औसत क्षेणी",
|
||||
"average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड"
|
||||
"average_grade_description": "सभी पूर्ण किए गए असाइनमेंट का औसत ग्रेड",
|
||||
"students": "छात्र",
|
||||
"lessons": "पाठ",
|
||||
"tutors": "ट्यूटर्स",
|
||||
"avg_grade": "AVG ग्रेड",
|
||||
"completion_rate": "पूर्णता दर",
|
||||
"student_progress_overview": "छात्र प्रगति अवलोकन",
|
||||
"student_name": "छात्र नाम",
|
||||
"lessons_completed": "सबक पूरा हुआ",
|
||||
"exercises_submitted": "प्रस्तुत अभ्यास",
|
||||
"actions": "कार्रवाई",
|
||||
"view_details": "विवरण देखें",
|
||||
"no_students_found": "कोई छात्र नहीं मिला",
|
||||
"no_students_description": "इस पाठ्यक्रम में अभी तक कोई छात्र नामांकित नहीं हैं। पाठ्यक्रम में शामिल होने के बाद छात्र यहां दिखाई देंगे।",
|
||||
"showing_students": "{TOTAL} {TOTAL} छात्रों के {अंत} को दिखा रहा है",
|
||||
"no_analytics_data": "कोई एनालिटिक्स डेटा नहीं",
|
||||
"no_analytics_description": "इस पाठ्यक्रम के लिए एनालिटिक्स डेटा लोड करने में असमर्थ। यदि समस्या बनी रहती है, तो कृपया बाद में पुनः प्रयास करें या समर्थन से संपर्क करें।"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "आपके पास अनसुना परिवर्तन हैं। क्या आपकी वाकई छोड़ने की इच्छा है?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -331,7 +331,23 @@
|
||||
"assignment_completion": "Zakończenie przypisania",
|
||||
"assignment_completion_description": "Procent wypełnionych zadań",
|
||||
"average_grade": "Średnia ocena",
|
||||
"average_grade_description": "Średnia ocena wszystkich zakończonych zadań"
|
||||
"average_grade_description": "Średnia ocena wszystkich zakończonych zadań",
|
||||
"students": "Studenci",
|
||||
"lessons": "Lekcje",
|
||||
"tutors": "Tutorzy",
|
||||
"avg_grade": "Klasa AVG",
|
||||
"completion_rate": "Wskaźnik zakończenia",
|
||||
"student_progress_overview": "Przegląd postępu uczniów",
|
||||
"student_name": "Nazwa ucznia",
|
||||
"lessons_completed": "Ukończone lekcje",
|
||||
"exercises_submitted": "Przedstawione ćwiczenia",
|
||||
"actions": "Działania",
|
||||
"view_details": "Zobacz szczegóły",
|
||||
"no_students_found": "Nie znaleziono uczniów",
|
||||
"no_students_description": "Na ten kurs nie zapisano jeszcze studentów. Studenci pojawią się tutaj, gdy dołączą do kursu.",
|
||||
"showing_students": "Pokazuje {start} do {end} studentów {total}",
|
||||
"no_analytics_data": "Brak danych analitycznych",
|
||||
"no_analytics_description": "Nie można załadować danych analitycznych dla tego kursu. Spróbuj ponownie później lub skontaktuj się z wsparciem, jeśli problem będzie się utrzymywał."
|
||||
},
|
||||
"audience": {
|
||||
"title": "Publiczność",
|
||||
@@ -1000,6 +1016,9 @@
|
||||
"cancel": "Anulować",
|
||||
"post": "Post"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Analityka"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -1011,7 +1030,8 @@
|
||||
"nav_people": "Ludzie",
|
||||
"nav_certificates": "Certyfikaty",
|
||||
"nav_landing_page": "Strona docelowa",
|
||||
"nav_settings": "Ustawienia"
|
||||
"nav_settings": "Ustawienia",
|
||||
"nav_analytics": "Analityka"
|
||||
}
|
||||
},
|
||||
"courses": {
|
||||
@@ -1523,5 +1543,10 @@
|
||||
"resend_in": "Ponownie weryfikacja w",
|
||||
"seconds": "towary drugiej jakości",
|
||||
"snackbar_error": "Błąd podczas wysyłania kodu"
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "Masz niezbawione zmiany. Czy na pewno chcesz odejść?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Cancelar",
|
||||
"post": "Publicar"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Análise"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Certificados",
|
||||
"nav_landing_page": "Página inicial",
|
||||
"nav_settings": "Configurações",
|
||||
"nav_content": "Contente"
|
||||
"nav_content": "Contente",
|
||||
"nav_analytics": "Análise"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Sem permissão",
|
||||
@@ -1552,11 +1556,27 @@
|
||||
"assignment_completion": "Conclusão da tarefa",
|
||||
"assignment_completion_description": "Porcentagem de tarefas concluídas",
|
||||
"average_grade": "Nota Média",
|
||||
"average_grade_description": "Nota média de todas as tarefas concluídas"
|
||||
"average_grade_description": "Nota média de todas as tarefas concluídas",
|
||||
"students": "Alunos",
|
||||
"lessons": "Lições",
|
||||
"tutors": "tutores",
|
||||
"avg_grade": "Grau AVG",
|
||||
"completion_rate": "Taxa de conclusão",
|
||||
"student_progress_overview": "Visão geral do progresso do aluno",
|
||||
"student_name": "Nome do aluno",
|
||||
"lessons_completed": "Lições concluídas",
|
||||
"exercises_submitted": "Exercícios enviados",
|
||||
"actions": "Ações",
|
||||
"view_details": "Ver detalhes",
|
||||
"no_students_found": "Nenhum aluno encontrado",
|
||||
"no_students_description": "Ainda não há alunos matriculados neste curso. Os alunos aparecerão aqui quando ingressarem no curso.",
|
||||
"showing_students": "Mostrando {start} para {end} de {total} alunos",
|
||||
"no_analytics_data": "Sem dados de análise",
|
||||
"no_analytics_description": "Não é possível carregar dados de análise para este curso. Tente novamente mais tarde ou entre em contato com o suporte se o problema persistir."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "Você tem mudanças não salvas. Tem certeza de que deseja sair?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Отмена",
|
||||
"post": "Почта"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Аналитика"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Сертификаты",
|
||||
"nav_landing_page": "Целевая страница",
|
||||
"nav_settings": "Настройки",
|
||||
"nav_content": "Содержание"
|
||||
"nav_content": "Содержание",
|
||||
"nav_analytics": "Аналитика"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Нет разрешения",
|
||||
@@ -1552,7 +1556,23 @@
|
||||
"assignment_completion": "Выполнение задания",
|
||||
"assignment_completion_description": "Процент выполненных заданий",
|
||||
"average_grade": "Средняя оценка",
|
||||
"average_grade_description": "Средняя оценка за все выполненные задания"
|
||||
"average_grade_description": "Средняя оценка за все выполненные задания",
|
||||
"students": "Студенты",
|
||||
"lessons": "Уроки",
|
||||
"tutors": "репетиторы",
|
||||
"avg_grade": "AVG Grade",
|
||||
"completion_rate": "Коэффициент завершения",
|
||||
"student_progress_overview": "Студенческий прогресс обзор",
|
||||
"student_name": "Студенческое имя",
|
||||
"lessons_completed": "Уроки завершены",
|
||||
"exercises_submitted": "Упражнения представлены",
|
||||
"actions": "Действия",
|
||||
"view_details": "Просмотреть детали",
|
||||
"no_students_found": "Никаких студентов не нашли",
|
||||
"no_students_description": "В этом курсе еще нет студентов. Студенты появятся здесь, как только они присоединятся к курсу.",
|
||||
"showing_students": "Показ {start} to {end} {otto} Студенты",
|
||||
"no_analytics_data": "Нет аналитических данных",
|
||||
"no_analytics_description": "Невозможно загрузить данные аналитики для этого курса. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой, если проблема сохранится."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
|
||||
@@ -861,6 +861,9 @@
|
||||
"cancel": "Hủy bỏ",
|
||||
"post": "Bưu kiện"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Phân tích"
|
||||
}
|
||||
},
|
||||
"navItems": {
|
||||
@@ -872,7 +875,8 @@
|
||||
"nav_certificates": "Chứng chỉ",
|
||||
"nav_landing_page": "Trang đích",
|
||||
"nav_settings": "Cài đặt",
|
||||
"nav_content": "Nội dung"
|
||||
"nav_content": "Nội dung",
|
||||
"nav_analytics": "Phân tích"
|
||||
},
|
||||
"not_permitted": {
|
||||
"header": "Không cho phép",
|
||||
@@ -1552,11 +1556,27 @@
|
||||
"assignment_completion": "Hoàn thành bài tập",
|
||||
"assignment_completion_description": "Tỷ lệ bài tập đã hoàn thành",
|
||||
"average_grade": "Điểm trung bình",
|
||||
"average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành"
|
||||
"average_grade_description": "Điểm trung bình của tất cả các bài tập đã hoàn thành",
|
||||
"students": "Sinh viên",
|
||||
"lessons": "Bài học",
|
||||
"tutors": "gia sư",
|
||||
"avg_grade": "Lớp AVG",
|
||||
"completion_rate": "Tỷ lệ hoàn thành",
|
||||
"student_progress_overview": "Tổng quan về tiến trình học sinh",
|
||||
"student_name": "Tên sinh viên",
|
||||
"lessons_completed": "Bài học hoàn thành",
|
||||
"exercises_submitted": "Bài tập được gửi",
|
||||
"actions": "Hành động",
|
||||
"view_details": "Xem chi tiết",
|
||||
"no_students_found": "Không có học sinh tìm thấy",
|
||||
"no_students_description": "Không có sinh viên đăng ký vào khóa học này. Học sinh sẽ xuất hiện ở đây khi họ tham gia khóa học.",
|
||||
"showing_students": "Hiển thị {start} đến {end} của {Total} sinh viên",
|
||||
"no_analytics_data": "Không có dữ liệu phân tích",
|
||||
"no_analytics_description": "Không thể tải dữ liệu phân tích cho khóa học này. Vui lòng thử lại sau hoặc liên hệ với hỗ trợ nếu vấn đề vẫn còn."
|
||||
},
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "Bạn có những thay đổi chưa được lưu. Bạn có chắc bạn muốn rời đi?"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,64 @@
|
||||
export interface CourseAnalytics {
|
||||
totalTutors: number;
|
||||
totalStudents: number;
|
||||
totalLessons: number;
|
||||
totalExercises: number;
|
||||
lessonCompletionRate: number;
|
||||
exerciseCompletionRate: number;
|
||||
averageGrade: number;
|
||||
students: StudentOverview[];
|
||||
}
|
||||
|
||||
export interface StudentOverview {
|
||||
id: string;
|
||||
profile: {
|
||||
fullname: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
lessonsCompleted: number;
|
||||
totalLessons: number;
|
||||
exercisesSubmitted: number;
|
||||
totalExercises: number;
|
||||
averageGrade: number;
|
||||
lastSeen: string;
|
||||
progressPercentage: number;
|
||||
}
|
||||
|
||||
export interface StudentDetailModal {
|
||||
student: {
|
||||
profile: {
|
||||
id: string;
|
||||
fullname: string;
|
||||
email: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
overallStats: {
|
||||
totalCourses: number;
|
||||
totalLessonsCompleted: number;
|
||||
totalExercisesSubmitted: number;
|
||||
overallAverageGrade: number;
|
||||
lastSeen: string;
|
||||
};
|
||||
courses: StudentCourseAnalytics[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface StudentCourseAnalytics {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
courseLogo: string;
|
||||
role: 'student' | 'tutor' | 'admin';
|
||||
lessonsCompleted: number;
|
||||
totalLessons: number;
|
||||
exercisesSubmitted: number;
|
||||
totalExercises: number;
|
||||
averageGrade: number;
|
||||
progressPercentage: number;
|
||||
lastActivity: string;
|
||||
enrollmentDate: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
fullName: string;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import type { CourseAnalytics, StudentOverview } from '$lib/utils/types/analytics';
|
||||
|
||||
import type { UserExercisesStats } from '$lib/utils/types/analytics';
|
||||
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const CACHE_DURATION = 60 * 5; // 5 minutes
|
||||
|
||||
export async function POST({ setHeaders, request }) {
|
||||
const { courseId } = await request.json();
|
||||
|
||||
if (!courseId) {
|
||||
return json({ success: false, message: 'Request is missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
setHeaders({
|
||||
'cache-control': `max-age=${CACHE_DURATION}`,
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
|
||||
try {
|
||||
const analytics = await getCourseAnalytics(courseId);
|
||||
return json(analytics, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error('Course analytics error:', error);
|
||||
return json({ error: 'Something went wrong' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
async function getCourseAnalytics(courseId: string): Promise<CourseAnalytics> {
|
||||
const analytics: CourseAnalytics = {
|
||||
totalTutors: 0,
|
||||
totalStudents: 0,
|
||||
totalLessons: 0,
|
||||
totalExercises: 0,
|
||||
lessonCompletionRate: 0,
|
||||
exerciseCompletionRate: 0,
|
||||
averageGrade: 0,
|
||||
students: []
|
||||
};
|
||||
|
||||
// Fetch course basic info and group members
|
||||
const { data: courseData, error: courseError } = await supabase
|
||||
.from('course')
|
||||
.select(
|
||||
`
|
||||
id,
|
||||
title,
|
||||
group(
|
||||
members:groupmember(
|
||||
id,
|
||||
role_id,
|
||||
profile_id,
|
||||
profile(
|
||||
id,
|
||||
fullname,
|
||||
email,
|
||||
avatar_url
|
||||
)
|
||||
)
|
||||
),
|
||||
lessons:lesson(
|
||||
id,
|
||||
title,
|
||||
exercise(id)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
|
||||
if (courseError) {
|
||||
throw new Error('Failed to fetch course data');
|
||||
}
|
||||
|
||||
// Count tutors and students
|
||||
const members = (courseData.group as any)?.members || [];
|
||||
analytics.totalTutors = members.filter(
|
||||
(member) => member.role_id === 1 || member.role_id === 2
|
||||
).length;
|
||||
analytics.totalStudents = members.filter((member) => member.role_id === 3).length;
|
||||
|
||||
// Count lessons and exercises
|
||||
analytics.totalLessons = courseData.lessons?.length || 0;
|
||||
analytics.totalExercises =
|
||||
courseData.lessons?.reduce((total, lesson) => total + (lesson.exercise?.length || 0), 0) || 0;
|
||||
|
||||
// Get student analytics
|
||||
const studentAnalytics = await Promise.all(
|
||||
members
|
||||
.filter((member) => member.role_id === 3)
|
||||
.map((member) => getStudentOverview(courseId, member))
|
||||
);
|
||||
|
||||
analytics.students = studentAnalytics.filter(
|
||||
(student): student is StudentOverview => student !== null
|
||||
);
|
||||
|
||||
// Calculate aggregated metrics
|
||||
if (analytics.students.length > 0) {
|
||||
analytics.lessonCompletionRate = Math.round(
|
||||
analytics.students.reduce((sum, student) => sum + student.progressPercentage, 0) /
|
||||
analytics.students.length
|
||||
);
|
||||
|
||||
analytics.exerciseCompletionRate = Math.round(
|
||||
analytics.students.reduce((sum, student) => {
|
||||
const completionRate =
|
||||
student.totalExercises > 0
|
||||
? (student.exercisesSubmitted / student.totalExercises) * 100
|
||||
: 0;
|
||||
return sum + completionRate;
|
||||
}, 0) / analytics.students.length
|
||||
);
|
||||
|
||||
analytics.averageGrade = Math.round(
|
||||
analytics.students.reduce((sum, student) => sum + student.averageGrade, 0) /
|
||||
analytics.students.length
|
||||
);
|
||||
}
|
||||
|
||||
return analytics;
|
||||
}
|
||||
|
||||
async function getLastLogin(userId: string): Promise<string | undefined> {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('analytics_login_events')
|
||||
.select('logged_in_at')
|
||||
.eq('user_id', userId)
|
||||
.order('logged_in_at', { ascending: false })
|
||||
.limit(1);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
return data?.[0]?.logged_in_at;
|
||||
} catch (error) {
|
||||
console.error('Error fetching last login:', error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStudentOverview(courseId: string, member: any): Promise<StudentOverview | null> {
|
||||
try {
|
||||
// Use the same service function that the working analytics/user API uses
|
||||
const { data: courseProgressData, error: progressError } = await fetchProfileCourseProgress(
|
||||
courseId,
|
||||
member.profile_id
|
||||
);
|
||||
|
||||
if (progressError) {
|
||||
console.error('Error fetching course progress:', progressError);
|
||||
return null;
|
||||
}
|
||||
|
||||
const courseProgress = courseProgressData?.[0];
|
||||
if (!courseProgress) {
|
||||
console.error('No course progress data found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch exercise stats using the same method as the people page
|
||||
const userExercisesStats = await fetchUserExercisesStats(courseId, member.profile_id);
|
||||
|
||||
// Calculate exercise completion using the same logic as people page
|
||||
const completedExercises =
|
||||
userExercisesStats?.filter((exercise) => exercise.isCompleted)?.length || 0;
|
||||
const totalExercises = userExercisesStats?.length || 0;
|
||||
|
||||
// Get last login using the same approach as analytics/user API
|
||||
const lastLoginDate = await getLastLogin(member.profile_id);
|
||||
|
||||
// Format last seen
|
||||
let lastSeen = 'Never';
|
||||
if (lastLoginDate) {
|
||||
const lastLogin = new Date(lastLoginDate);
|
||||
const now = new Date();
|
||||
const diffInHours = Math.floor((now.getTime() - lastLogin.getTime()) / (1000 * 60 * 60));
|
||||
|
||||
if (diffInHours < 1) {
|
||||
lastSeen = 'Just now';
|
||||
} else if (diffInHours < 24) {
|
||||
lastSeen = `${diffInHours} hours ago`;
|
||||
} else {
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
lastSeen = `${diffInDays} days ago`;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress percentage
|
||||
const lessonsCompleted = courseProgress.lessons_completed || 0;
|
||||
const totalLessons = courseProgress.lessons_count || 0;
|
||||
const progressPercentage =
|
||||
totalLessons > 0 ? Math.round((lessonsCompleted / totalLessons) * 100) : 0;
|
||||
|
||||
// Calculate average grade using the same logic as the people page
|
||||
const totalEarnedPoints =
|
||||
userExercisesStats?.reduce((sum, exercise) => sum + exercise.score, 0) || 0;
|
||||
const totalPoints =
|
||||
userExercisesStats?.reduce((sum, exercise) => sum + exercise.totalPoints, 0) || 0;
|
||||
const averageGrade = totalPoints > 0 ? Math.round((totalEarnedPoints / totalPoints) * 100) : 0;
|
||||
|
||||
return {
|
||||
id: member.profile_id,
|
||||
profile: {
|
||||
fullname: member.profile?.fullname || 'Unknown',
|
||||
email: member.profile?.email || '',
|
||||
avatar_url: member.profile?.avatar_url || ''
|
||||
},
|
||||
lessonsCompleted,
|
||||
totalLessons,
|
||||
exercisesSubmitted: completedExercises,
|
||||
totalExercises: totalExercises,
|
||||
averageGrade,
|
||||
lastSeen,
|
||||
progressPercentage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting student overview:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserExercisesStats(
|
||||
courseId: string,
|
||||
userId: string
|
||||
): Promise<UserExercisesStats[] | undefined> {
|
||||
try {
|
||||
const { data: courseData, error: queryError } = await supabase
|
||||
.from('course')
|
||||
.select(
|
||||
`
|
||||
lesson!inner (
|
||||
title,exercise!inner (
|
||||
id,title,lesson_id,created_at,question (points),
|
||||
submission!left (
|
||||
id,total,status_id,submitted_by,groupmember!inner (id,profile_id)
|
||||
)
|
||||
)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('lesson.exercise.submission.groupmember.profile_id', userId)
|
||||
.eq('id', courseId);
|
||||
|
||||
if (queryError) {
|
||||
console.error('Error fetching exercise data:', queryError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!courseData || courseData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userExercisesStats = courseData[0].lesson.flatMap(
|
||||
(lesson) =>
|
||||
lesson.exercise?.map((exercise) => {
|
||||
const totalPoints = exercise.question?.reduce((sum, q) => sum + (q.points || 0), 0) || 0;
|
||||
const userSubmission = exercise.submission?.[0];
|
||||
|
||||
return {
|
||||
id: exercise.id,
|
||||
lessonId: exercise.lesson_id,
|
||||
lessonTitle: lesson.title,
|
||||
title: exercise.title,
|
||||
status: userSubmission?.status_id,
|
||||
score: userSubmission?.total || 0,
|
||||
totalPoints,
|
||||
isCompleted: !!userSubmission
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
|
||||
return userExercisesStats;
|
||||
} catch (error) {
|
||||
console.error('Unexpected error:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import CourseContainer from '$lib/components/CourseContainer/index.svelte';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { CourseAnalytics } from '$lib/utils/types/analytics';
|
||||
import { fade } from 'svelte/transition';
|
||||
import StudentTable from '$lib/components/Course/components/Analytics/StudentTable.svelte';
|
||||
import AnalyticsGraph from '$lib/components/Course/components/Analytics/AnalyticsGraph.svelte';
|
||||
import AnalyticsSkeleton from '$lib/components/Course/components/Analytics/AnalyticsSkeleton.svelte';
|
||||
import AnalyticsCard from '$lib/components/Course/components/Analytics/AnalyticsCard.svelte';
|
||||
import { ChartBar } from 'carbon-icons-svelte';
|
||||
import StudentsIcon from '$lib/components/Course/components/Analytics/icons/StudentsIcon.svelte';
|
||||
import LessonsIcon from '$lib/components/Course/components/Analytics/icons/LessonsIcon.svelte';
|
||||
import ProgressIcon from '$lib/components/Course/components/Analytics/icons/ProgressIcon.svelte';
|
||||
import ExercisesIcon from '$lib/components/Course/components/Analytics/icons/ExercisesIcon.svelte';
|
||||
import EmptyState from '$lib/components/Course/components/Analytics/EmptyState.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
let courseAnalytics: CourseAnalytics | null = null;
|
||||
let isLoading = true;
|
||||
|
||||
async function fetchCourseAnalytics() {
|
||||
try {
|
||||
isLoading = true;
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch('/api/analytics/course', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ courseId: data.courseId })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
courseAnalytics = (await response.json()) as CourseAnalytics;
|
||||
} else {
|
||||
console.error('Failed to fetch course analytics:', response);
|
||||
snackbar.error('Failed to load analytics data');
|
||||
courseAnalytics = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch course analytics:', error);
|
||||
snackbar.error('Failed to load analytics data');
|
||||
courseAnalytics = null;
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchCourseAnalytics();
|
||||
});
|
||||
</script>
|
||||
|
||||
<CourseContainer bind:courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.analytics.title')} />
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12">
|
||||
{#if isLoading}
|
||||
<AnalyticsSkeleton />
|
||||
{:else if courseAnalytics}
|
||||
<div class="min-h-screen" in:fade>
|
||||
<!-- Main Content Area -->
|
||||
<div class="px-4 py-8">
|
||||
<!-- Course Overview Cards -->
|
||||
<div class="mb-12">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Students Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.students')}
|
||||
value={courseAnalytics?.totalStudents || 0}
|
||||
subtitle="{$t('analytics.tutors')}: {courseAnalytics?.totalTutors || 0}"
|
||||
icon={StudentsIcon}
|
||||
iconColor="blue"
|
||||
/>
|
||||
|
||||
<!-- Lessons Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.lessons')}
|
||||
value={courseAnalytics?.totalLessons || 0}
|
||||
subtitle="{$t('analytics.exercises')}: {courseAnalytics?.totalExercises || 0}"
|
||||
icon={LessonsIcon}
|
||||
iconColor="green"
|
||||
/>
|
||||
|
||||
<!-- Progress Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.progress')}
|
||||
value="{courseAnalytics?.lessonCompletionRate || 0}%"
|
||||
subtitle="{$t('analytics.avg_grade')}: {courseAnalytics?.averageGrade || 0}%"
|
||||
icon={ProgressIcon}
|
||||
iconColor="purple"
|
||||
/>
|
||||
|
||||
<!-- Exercise Completion Card -->
|
||||
<AnalyticsCard
|
||||
title={$t('analytics.exercises')}
|
||||
value="{courseAnalytics?.exerciseCompletionRate || 0}%"
|
||||
subtitle={$t('analytics.completion_rate')}
|
||||
icon={ExercisesIcon}
|
||||
iconColor="orange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Section -->
|
||||
<div class="mb-12">
|
||||
<AnalyticsGraph {courseAnalytics} />
|
||||
</div>
|
||||
|
||||
<!-- Students Overview Table -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white dark:border-gray-700 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="border-b border-gray-200 px-6 py-4 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{$t('analytics.student_progress_overview')}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<StudentTable students={courseAnalytics?.students || []} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<EmptyState
|
||||
title={$t('analytics.no_analytics_data')}
|
||||
description={$t('analytics.no_analytics_description')}
|
||||
icon={ChartBar}
|
||||
className="h-screen"
|
||||
/>
|
||||
{/if}
|
||||
</PageBody>
|
||||
</CourseContainer>
|
||||
@@ -0,0 +1,7 @@
|
||||
export const load = ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
return {
|
||||
courseId: id
|
||||
};
|
||||
};
|
||||
@@ -11,10 +11,21 @@
|
||||
|
||||
export let data;
|
||||
|
||||
// Get back URL from query parameters
|
||||
$: backUrl = $page.url.searchParams.get('back');
|
||||
|
||||
const handleClick = () => {
|
||||
const url = $page.url.href + '?add=true';
|
||||
goto(url);
|
||||
};
|
||||
|
||||
const handleBackNavigation = () => {
|
||||
if (backUrl) {
|
||||
goto(backUrl);
|
||||
} else {
|
||||
goto(`/courses/${data.courseId}/people`);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<CourseContainer bind:courseId={data.courseId}>
|
||||
@@ -22,12 +33,7 @@
|
||||
<slot:fragment slot="image">
|
||||
{#if data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton
|
||||
size="large"
|
||||
onClick={() => {
|
||||
goto(`/courses/${data.courseId}/people`);
|
||||
}}
|
||||
>
|
||||
<IconButton size="large" onClick={handleBackNavigation}>
|
||||
<ArrowLeft size={16} class="carbon-icon dark:text-white " />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 dark:border-neutral-600 md:p-5">
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
|
||||
<h3 class="text-2xl font-bold">
|
||||
{$t('analytics.exercises')}
|
||||
</h3>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
set check_function_bodies = off;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.get_course_progress(course_id_arg uuid, profile_id_arg uuid)
|
||||
RETURNS TABLE(lessons_count bigint, lessons_completed bigint, exercises_count bigint, exercises_completed bigint)
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
select
|
||||
count(distinct lesson.id) as lessons_count,
|
||||
count(distinct lesson_completion.id) as lessons_completed,
|
||||
count(distinct exercise.id) as exercises_count,
|
||||
count(distinct submission.id) as exercises_completed
|
||||
from
|
||||
course
|
||||
join "group" on "group".id = course.group_id
|
||||
join groupmember on groupmember.group_id = course.group_id
|
||||
join profile on profile.id = groupmember.profile_id
|
||||
left join lesson on lesson.course_id = course.id
|
||||
left join lesson_completion on lesson_completion.lesson_id = lesson.id
|
||||
and lesson_completion.is_complete = true
|
||||
and lesson_completion.profile_id = profile.id
|
||||
left join exercise on exercise.lesson_id = lesson.id
|
||||
left join submission on submission.exercise_id = exercise.id
|
||||
and submission.submitted_by = groupmember.id
|
||||
where
|
||||
course.id = course_id_arg
|
||||
and profile.id = profile_id_arg;
|
||||
END;
|
||||
$function$
|
||||
;
|
||||
Reference in New Issue
Block a user