2025-09-18 01:00:49 +01:00
|
|
|
<script lang="ts">
|
|
|
|
|
import { goto } from '$app/navigation';
|
2025-11-21 12:18:04 +01:00
|
|
|
import { Badge } from '@cio/ui/base/badge';
|
|
|
|
|
import UserIcon from '@lucide/svelte/icons/user';
|
|
|
|
|
import * as Pagination from '@cio/ui/base/pagination';
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
import { courseApi } from '$features/course/api';
|
2025-09-18 01:00:49 +01:00
|
|
|
import { t } from '$lib/utils/functions/translations';
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
import EmptyState from './empty-state.svelte';
|
2025-12-02 15:27:41 +01:00
|
|
|
import * as Avatar from '@cio/ui/base/avatar';
|
2026-03-09 11:18:22 +01:00
|
|
|
import type { CourseAnalytics } from '$features/course/utils/types';
|
2025-12-02 15:27:41 +01:00
|
|
|
import { shortenName } from '$lib/utils/functions/string';
|
2025-11-21 12:18:04 +01:00
|
|
|
|
2025-11-01 15:49:29 +01:00
|
|
|
interface Props {
|
2026-03-09 11:18:22 +01:00
|
|
|
students?: CourseAnalytics['students'];
|
2025-11-01 15:49:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let { students = [] }: Props = $props();
|
2025-09-18 01:00:49 +01:00
|
|
|
|
|
|
|
|
// Pagination state
|
2025-11-01 15:49:29 +01:00
|
|
|
let currentPage = $state(1);
|
2025-09-18 01:00:49 +01:00
|
|
|
const pageSize = 15;
|
|
|
|
|
|
|
|
|
|
// Computed values
|
2025-11-01 15:49:29 +01:00
|
|
|
let totalPages = $derived(Math.ceil(students.length / pageSize));
|
|
|
|
|
let startIndex = $derived((currentPage - 1) * pageSize);
|
|
|
|
|
let endIndex = $derived(startIndex + pageSize);
|
|
|
|
|
let paginatedStudents = $derived(students.slice(startIndex, endIndex));
|
|
|
|
|
let startItem = $derived(startIndex + 1);
|
|
|
|
|
let endItem = $derived(Math.min(endIndex, students.length));
|
2025-09-18 01:00:49 +01:00
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const GotoFullProfile = (student: CourseAnalytics['students'][number]) => {
|
2025-09-18 01:00:49 +01:00
|
|
|
if (!student) return;
|
2025-11-01 15:49:29 +01:00
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
goto(`/courses/${courseApi.course?.id}/people/${student.id}?back=/courses/${courseApi.course?.id}/analytics`);
|
2025-09-18 01:00:49 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Reset to first page when students array changes
|
2025-11-01 15:49:29 +01:00
|
|
|
$effect(() => {
|
|
|
|
|
if (students.length > 0 && currentPage > totalPages) {
|
|
|
|
|
currentPage = 1;
|
|
|
|
|
}
|
|
|
|
|
});
|
2025-09-18 01:00:49 +01:00
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
{#if students.length === 0}
|
|
|
|
|
<EmptyState
|
|
|
|
|
title={$t('analytics.no_students_found')}
|
|
|
|
|
description={$t('analytics.no_students_description')}
|
2025-11-01 15:49:29 +01:00
|
|
|
icon={UserIcon}
|
2025-09-18 01:00:49 +01:00
|
|
|
/>
|
|
|
|
|
{: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">
|
2026-03-09 11:18:22 +01:00
|
|
|
<tr class="border-b border-gray-200">
|
2025-09-18 01:00:49 +01:00
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:bg-neutral-800 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.student_name')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="min-w-[150px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.lessons_completed')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="min-w-[140px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.exercises_submitted')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.average_grade')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.last_seen')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
2026-03-09 11:18:22 +01:00
|
|
|
class="min-w-[120px] px-4 py-3 text-left text-sm font-medium whitespace-nowrap text-gray-500 dark:text-gray-300"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$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
|
2026-03-09 11:18:22 +01:00
|
|
|
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 whitespace-nowrap transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
<div class="flex items-center gap-3">
|
2025-12-02 15:27:41 +01:00
|
|
|
<Avatar.Root class="size-8">
|
|
|
|
|
<Avatar.Image
|
|
|
|
|
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
2026-03-09 11:18:22 +01:00
|
|
|
alt={student.profile.fullname ? student.profile.fullname : $t('analytics.student')}
|
2025-12-02 15:27:41 +01:00
|
|
|
/>
|
|
|
|
|
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
|
|
|
|
</Avatar.Root>
|
2025-09-18 01:00:49 +01:00
|
|
|
<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 -->
|
2026-03-09 11:18:22 +01:00
|
|
|
<td class="min-w-[150px] px-4 py-3 whitespace-nowrap">
|
2025-09-18 01:00:49 +01:00
|
|
|
<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">
|
2025-11-01 15:49:29 +01:00
|
|
|
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
|
2025-09-18 01:00:49 +01:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
2026-03-09 11:18:22 +01:00
|
|
|
<td class="min-w-[140px] px-4 py-3 whitespace-nowrap">
|
2025-09-18 01:00:49 +01:00
|
|
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
|
|
|
|
{student.exercisesSubmitted} out of {student.totalExercises}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
2026-03-09 11:18:22 +01:00
|
|
|
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
2025-11-21 12:18:04 +01:00
|
|
|
<Badge
|
|
|
|
|
class={student.averageGrade >= 80
|
|
|
|
|
? 'bg-green-600'
|
|
|
|
|
: student.averageGrade >= 60
|
|
|
|
|
? 'bg-blue-600'
|
|
|
|
|
: 'bg-red-600'}
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{student.averageGrade}%
|
2025-11-21 12:18:04 +01:00
|
|
|
</Badge>
|
2025-09-18 01:00:49 +01:00
|
|
|
</td>
|
2026-03-09 11:18:22 +01:00
|
|
|
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
2025-09-18 01:00:49 +01:00
|
|
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
|
|
|
|
{student.lastSeen}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
2026-03-09 11:18:22 +01:00
|
|
|
<td class="min-w-[120px] px-4 py-3 whitespace-nowrap">
|
2025-09-18 01:00:49 +01:00
|
|
|
<button
|
2026-03-09 11:18:22 +01:00
|
|
|
class="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium whitespace-nowrap text-white transition-colors hover:bg-blue-700"
|
2025-11-01 15:49:29 +01:00
|
|
|
onclick={() => GotoFullProfile(student)}
|
2025-09-18 01:00:49 +01:00
|
|
|
>
|
|
|
|
|
{$t('analytics.view_details')}
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
{/each}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Pagination Controls -->
|
|
|
|
|
{#if students.length > pageSize}
|
2026-03-09 11:18:22 +01:00
|
|
|
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
|
2025-09-18 01:00:49 +01:00
|
|
|
<div class="text-sm text-gray-700 dark:text-gray-300">
|
|
|
|
|
{$t('analytics.showing_students', {
|
|
|
|
|
start: startItem,
|
|
|
|
|
end: endItem,
|
|
|
|
|
total: students.length
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-11-21 12:18:04 +01:00
|
|
|
|
|
|
|
|
<Pagination.Root
|
|
|
|
|
count={students.length}
|
|
|
|
|
perPage={pageSize}
|
|
|
|
|
page={currentPage}
|
|
|
|
|
onPageChange={(page) => (currentPage = page)}
|
|
|
|
|
>
|
|
|
|
|
{#snippet children({ pages, currentPage: activePage })}
|
|
|
|
|
<Pagination.Content>
|
|
|
|
|
<Pagination.Item>
|
|
|
|
|
<Pagination.PrevButton />
|
|
|
|
|
</Pagination.Item>
|
|
|
|
|
{#each pages as page (page.key)}
|
|
|
|
|
{#if page.type === 'ellipsis'}
|
|
|
|
|
<Pagination.Item>
|
|
|
|
|
<Pagination.Ellipsis />
|
|
|
|
|
</Pagination.Item>
|
|
|
|
|
{:else}
|
|
|
|
|
<Pagination.Item>
|
|
|
|
|
<Pagination.Link {page} isActive={activePage === page.value}>
|
|
|
|
|
{page.value}
|
|
|
|
|
</Pagination.Link>
|
|
|
|
|
</Pagination.Item>
|
|
|
|
|
{/if}
|
|
|
|
|
{/each}
|
|
|
|
|
<Pagination.Item>
|
|
|
|
|
<Pagination.NextButton />
|
|
|
|
|
</Pagination.Item>
|
|
|
|
|
</Pagination.Content>
|
|
|
|
|
{/snippet}
|
|
|
|
|
</Pagination.Root>
|
2025-09-18 01:00:49 +01:00
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|