2025-12-22 02:36:48 +01:00
|
|
|
<script lang="ts">
|
|
|
|
|
import * as Table from '@cio/ui/base/table';
|
2026-03-09 11:18:22 +01:00
|
|
|
import { Search } from '@cio/ui/custom/search';
|
2025-12-22 02:36:48 +01:00
|
|
|
import { Checkbox } from '@cio/ui/base/checkbox';
|
|
|
|
|
import * as Pagination from '@cio/ui/base/pagination';
|
2026-03-09 11:18:22 +01:00
|
|
|
import { Empty } from '@cio/ui/custom/empty';
|
|
|
|
|
import UserXIcon from '@lucide/svelte/icons/user-x';
|
2025-12-22 02:36:48 +01:00
|
|
|
|
|
|
|
|
import { profile } from '$lib/utils/store/user';
|
|
|
|
|
import { ROLE } from '@cio/utils/constants';
|
|
|
|
|
import { globalStore } from '$lib/utils/store/app';
|
|
|
|
|
import { t } from '$lib/utils/functions/translations';
|
2026-03-09 11:18:22 +01:00
|
|
|
import { courseApi, attendanceApi } from '$features/course/api';
|
|
|
|
|
import { getLectureNo } from '$features/course/utils/functions';
|
|
|
|
|
import type { CourseMember, CourseMembers, CourseContentItem } from '$features/course/utils/types';
|
|
|
|
|
import { getCourseContent } from '$features/course/utils/content';
|
|
|
|
|
import { ContentType } from '@cio/utils/constants/content';
|
2025-12-22 02:36:48 +01:00
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
courseId: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let { courseId }: Props = $props();
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const students: CourseMembers = $derived(
|
2025-12-22 02:36:48 +01:00
|
|
|
$globalStore.isStudent
|
2026-03-09 11:18:22 +01:00
|
|
|
? courseApi.group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
|
|
|
|
|
: courseApi.group.people.filter((person) => !!person.profile && Number(person.roleId) === ROLE.STUDENT)
|
2025-12-22 02:36:48 +01:00
|
|
|
);
|
|
|
|
|
let searchValue = $state('');
|
|
|
|
|
let currentPage = $state(1);
|
|
|
|
|
let pageSize = $state(10);
|
|
|
|
|
|
|
|
|
|
const filteredStudents = $derived(searchStudents(searchValue, students));
|
|
|
|
|
const totalStudents = $derived(filteredStudents.length);
|
|
|
|
|
const paginatedStudents = $derived(filteredStudents.slice((currentPage - 1) * pageSize, currentPage * pageSize));
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const contentData = $derived(getCourseContent(courseApi.course));
|
|
|
|
|
const lessonItems = $derived(
|
|
|
|
|
(contentData.grouped ? contentData.sections.flatMap((section) => section.items) : contentData.items).filter(
|
|
|
|
|
(item) => item.type === ContentType.Lesson
|
|
|
|
|
)
|
|
|
|
|
);
|
2025-12-22 02:36:48 +01:00
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const attendanceLookup = $derived.by(() => {
|
|
|
|
|
if (!courseApi.course?.attendance) return {};
|
|
|
|
|
|
|
|
|
|
const lookup: Record<string, Record<string, { id: number; isPresent: boolean }>> = {};
|
|
|
|
|
for (const attendanceItem of courseApi.course.attendance) {
|
|
|
|
|
const studentId = attendanceItem.studentId;
|
|
|
|
|
const lessonId = attendanceItem.lessonId;
|
|
|
|
|
const isPresent = attendanceItem.isPresent ?? false;
|
|
|
|
|
const id = attendanceItem.id;
|
|
|
|
|
|
|
|
|
|
if (!studentId || !lessonId) continue;
|
|
|
|
|
|
|
|
|
|
if (!lookup[studentId]) {
|
|
|
|
|
lookup[studentId] = {};
|
2025-12-22 02:36:48 +01:00
|
|
|
}
|
2026-03-09 11:18:22 +01:00
|
|
|
lookup[studentId][lessonId] = {
|
|
|
|
|
id: Number(id),
|
|
|
|
|
isPresent
|
|
|
|
|
};
|
2025-12-22 02:36:48 +01:00
|
|
|
}
|
2026-03-09 11:18:22 +01:00
|
|
|
return lookup;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Helper function to get attendance status for a student and lesson
|
|
|
|
|
function getAttendanceStatus(studentId: string, lessonId: string): boolean {
|
|
|
|
|
return attendanceLookup[studentId]?.[lessonId]?.isPresent ?? false;
|
2025-12-22 02:36:48 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
async function handleAttendanceChange(e: any, student: CourseMember, lesson: CourseContentItem) {
|
2025-12-22 02:36:48 +01:00
|
|
|
if ($globalStore.isStudent) return;
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const isPresent = e.target.checked;
|
2025-12-22 02:36:48 +01:00
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
const result = await attendanceApi.upsert({
|
|
|
|
|
courseId,
|
|
|
|
|
lessonId: lesson.id,
|
|
|
|
|
studentId: student.id,
|
|
|
|
|
isPresent
|
2025-12-22 02:36:48 +01:00
|
|
|
});
|
2026-03-09 11:18:22 +01:00
|
|
|
|
|
|
|
|
if (result?.data && courseApi.course) {
|
|
|
|
|
const {
|
|
|
|
|
id,
|
|
|
|
|
isPresent: responseIsPresent,
|
|
|
|
|
studentId: responseStudentId,
|
|
|
|
|
lessonId: responseLessonId
|
|
|
|
|
} = result.data;
|
|
|
|
|
|
|
|
|
|
// Update courseApi.course.attendance array
|
|
|
|
|
const existingIndex = courseApi.course.attendance.findIndex(
|
|
|
|
|
(item) => item.studentId === responseStudentId && item.lessonId === responseLessonId
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (existingIndex >= 0) {
|
|
|
|
|
// Update existing attendance record
|
|
|
|
|
courseApi.course.attendance[existingIndex] = {
|
|
|
|
|
...courseApi.course.attendance[existingIndex],
|
|
|
|
|
id: Number(id),
|
|
|
|
|
isPresent: responseIsPresent ?? false
|
|
|
|
|
};
|
|
|
|
|
} else {
|
|
|
|
|
// Add new attendance record
|
|
|
|
|
courseApi.course.attendance = [
|
|
|
|
|
...courseApi.course.attendance,
|
|
|
|
|
{
|
|
|
|
|
id: Number(id),
|
|
|
|
|
studentId: responseStudentId ?? student.id,
|
|
|
|
|
lessonId: responseLessonId ?? lesson.id,
|
|
|
|
|
isPresent: responseIsPresent ?? false,
|
|
|
|
|
courseId: courseId,
|
|
|
|
|
createdAt: new Date().toISOString(),
|
|
|
|
|
updatedAt: new Date().toISOString()
|
|
|
|
|
}
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-12-22 02:36:48 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// function for the searchbar
|
2026-03-09 11:18:22 +01:00
|
|
|
function searchStudents(query: string, _students: CourseMembers) {
|
2025-12-22 02:36:48 +01:00
|
|
|
const lowercaseQuery = query.toLowerCase();
|
|
|
|
|
return _students.filter((student) => student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery));
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
<section class="flex items-center">
|
2025-12-22 02:36:48 +01:00
|
|
|
<div class="flex w-full flex-col items-start justify-between gap-2 lg:flex-row lg:items-center">
|
|
|
|
|
<div class="flex gap-5">
|
|
|
|
|
<p class="flex items-center gap-2">
|
|
|
|
|
<Checkbox disabled />
|
|
|
|
|
<span>{$t('course.navItem.attendance.present')}</span>
|
|
|
|
|
</p>
|
|
|
|
|
<p class="flex items-center gap-2">
|
|
|
|
|
<Checkbox disabled />
|
|
|
|
|
<span>{$t('course.navItem.attendance.absent')}</span>
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
<Search placeholder={$t('course.navItem.attendance.search_students')} bind:value={searchValue} />
|
2025-12-22 02:36:48 +01:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-03-09 11:18:22 +01:00
|
|
|
<section class="my-2 space-y-4">
|
2026-04-12 08:58:41 +01:00
|
|
|
<div class="overflow-x-auto rounded-md border">
|
2025-12-22 02:36:48 +01:00
|
|
|
<Table.Root>
|
|
|
|
|
<Table.Header>
|
|
|
|
|
<Table.Row>
|
|
|
|
|
<Table.Head class="w-1/4">{$t('course.navItem.attendance.student')}</Table.Head>
|
2026-03-09 11:18:22 +01:00
|
|
|
{#each lessonItems as _lesson, index}
|
2025-12-22 02:36:48 +01:00
|
|
|
<Table.Head class="text-center"
|
|
|
|
|
>{$t('course.navItem.attendance.lesson')} 0{getLectureNo(index + 1)}</Table.Head
|
|
|
|
|
>
|
|
|
|
|
{/each}
|
|
|
|
|
</Table.Row>
|
|
|
|
|
</Table.Header>
|
|
|
|
|
<Table.Body>
|
|
|
|
|
{#each paginatedStudents as student}
|
|
|
|
|
<Table.Row>
|
|
|
|
|
<Table.Cell class="font-semibold">
|
2026-03-09 11:18:22 +01:00
|
|
|
{student.profile?.fullname}
|
2025-12-22 02:36:48 +01:00
|
|
|
</Table.Cell>
|
2026-03-09 11:18:22 +01:00
|
|
|
{#each lessonItems as lesson}
|
2025-12-22 02:36:48 +01:00
|
|
|
<Table.Cell class="text-center">
|
|
|
|
|
<div class="flex justify-center">
|
|
|
|
|
<Checkbox
|
|
|
|
|
disabled={$globalStore.isStudent}
|
2026-03-09 11:18:22 +01:00
|
|
|
checked={getAttendanceStatus(student.id, lesson.id)}
|
2025-12-22 02:36:48 +01:00
|
|
|
onCheckedChange={(checked) => {
|
|
|
|
|
const e = { target: { checked } };
|
|
|
|
|
handleAttendanceChange(e, student, lesson);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</Table.Cell>
|
|
|
|
|
{/each}
|
|
|
|
|
</Table.Row>
|
|
|
|
|
{/each}
|
|
|
|
|
</Table.Body>
|
|
|
|
|
</Table.Root>
|
|
|
|
|
</div>
|
|
|
|
|
{#if students.length === 0}
|
|
|
|
|
<Empty title={$t('course.navItem.attendance.no_student')} icon={UserXIcon} variant="page" />
|
|
|
|
|
{/if}
|
|
|
|
|
{#if totalStudents > 0}
|
|
|
|
|
<div class="mt-4">
|
|
|
|
|
<Pagination.Root
|
|
|
|
|
count={totalStudents}
|
|
|
|
|
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>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|