feat: migrate course UI from supabase (#615)
* chore: fix storybook build * chore: implement theming * community list not showing properly * refactor: migrate Chip component to @cio/ui/custom/chip - Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package. - Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality. - Adjusted related components to ensure consistent usage of the updated Chip component. * fix: align course sidebar design to match organization design (#611) * fix: align course sidebar design to match organization design * feat: add hideSearch prop to AppHeader and update sidebar layout --------- Co-authored-by: Rotimi Best <best@classroomio.com> * refactor: replace PrimaryButton with Button component across multiple files - Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton. - Ensured consistent button usage throughout the application for improved styling and functionality. - Removed the PrimaryButton component and its associated constants from the codebase. * refactor: standardize button and input usage across components - Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton). - Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions. - Removed unused components and streamlined imports to enhance code maintainability and readability. * chore: themeing, course card, use $features * refactor: centralize UI components and update modal implementations - Refactored various components to centralize UI elements, improving discoverability and maintainability. - Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application. - Removed unused components and streamlined imports to enhance code clarity and reduce complexity. * feat: add new documentation and refactor org and lms UI components - Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference. - Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality. - Removed deprecated components and streamlined imports for better maintainability. - Updated translations across multiple languages to ensure consistency in user experience. * refactor: update UI components and improve styling consistency - Refactored various components to enhance styling consistency, including updates to button and text color classes. - Removed deprecated components and streamlined imports for better maintainability. - Replaced instances of PageBody with a more flexible Page component structure across multiple routes. - Updated translations and UI elements to align with the new design system. - Deleted unused components to reduce code complexity and improve clarity. * update output.css --------- Co-authored-by: Abdulmuizz Hamzat <103611443+canhamzacode@users.noreply.github.com>
This commit is contained in:
co-authored by
Abdulmuizz Hamzat
parent
e6a3cf9332
commit
9fb6c7cbaa
@@ -1,9 +1,27 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { CourseSidebar } from '$features/course/components/sidebar';
|
||||
import { CourseHeader } from '$features/course/components';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
path: string;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
let { children, path }: Props = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
<Sidebar.Provider>
|
||||
<CourseSidebar {path} />
|
||||
|
||||
<Sidebar.Inset>
|
||||
<CourseHeader />
|
||||
|
||||
<Page.Root
|
||||
class="mx-auto flex w-[calc(100vw-var(--sidebar-width))] overflow-x-hidden px-4 group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))] lg:max-w-6xl"
|
||||
>
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -1,219 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import NewFeedModal from '$lib/components/Course/components/NewsFeed/NewFeedModal.svelte';
|
||||
import NewsFeedCard from '$lib/components/Course/components/NewsFeed/NewsFeedCard.svelte';
|
||||
import NewsFeedLoader from '$lib/components/Course/components/NewsFeed/NewsFeedLoader.svelte';
|
||||
import { isNewFeedModal, newsFeed } from '$lib/components/Course/components/NewsFeed/store';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import { NewsFeedPage } from '$features/course/pages';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
createComment,
|
||||
deleteNewsFeed,
|
||||
deleteNewsFeedComment,
|
||||
fetchNewsFeedReaction,
|
||||
fetchNewsFeeds,
|
||||
handleEditFeed,
|
||||
toggleFeedIsPinned
|
||||
} from '$lib/utils/services/newsfeed';
|
||||
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Feed } from '$lib/utils/types/feed';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import { onMount } from 'svelte';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getGreeting } from '$lib/utils/functions/date.js';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { isNewFeedModal } from '$lib/components/Course/components/NewsFeed/store';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
let createdComment;
|
||||
let edit = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let editFeed: Feed | null = $state(null);
|
||||
|
||||
let query = new URLSearchParams(page.url.search);
|
||||
let feedId = query.get('feedId') || '';
|
||||
|
||||
const author = $derived.by(() => {
|
||||
const groupMember = $group.people.find((person) => person.profile_id === $profile.id);
|
||||
|
||||
return {
|
||||
id: groupMember?.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatar_url: $profile.avatarUrl || ''
|
||||
};
|
||||
});
|
||||
|
||||
const onEdit = (id: string, content: string) => {
|
||||
handleEditFeed(id, content);
|
||||
|
||||
$newsFeed = $newsFeed.map((feed) => {
|
||||
if (feed.id === id) {
|
||||
return { ...feed, content: content };
|
||||
}
|
||||
|
||||
return feed;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteComment = (id: string) => {
|
||||
deleteNewsFeedComment(id);
|
||||
$newsFeed = $newsFeed.flatMap((feed) => ({
|
||||
...feed,
|
||||
comment: feed.comment.filter((comment) => comment.id !== id)
|
||||
}));
|
||||
|
||||
return snackbar.success('snackbar.course.success.comment_deleted');
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
const { data } = await fetchNewsFeedReaction(feedId);
|
||||
|
||||
if (!data) return;
|
||||
|
||||
const reactedFeed = data || $newsFeed.find((feed) => feed.id === feedId);
|
||||
|
||||
let reactedAuthorIds: string[] = reactedFeed.reaction[reactionType];
|
||||
|
||||
if (reactedAuthorIds.includes(authorId)) {
|
||||
reactedAuthorIds = reactedAuthorIds.filter((reactionAuthorId) => reactionAuthorId !== authorId);
|
||||
} else {
|
||||
reactedAuthorIds = [...reactedAuthorIds, authorId];
|
||||
}
|
||||
|
||||
reactedFeed.reaction = {
|
||||
...reactedFeed.reaction,
|
||||
[reactionType]: reactedAuthorIds
|
||||
};
|
||||
|
||||
const response = await supabase
|
||||
.from('course_newsfeed')
|
||||
.update({
|
||||
reaction: reactedFeed.reaction
|
||||
})
|
||||
.eq('id', feedId);
|
||||
|
||||
if (response.error) {
|
||||
return snackbar.error('snackbar.course.error.reaction_error');
|
||||
}
|
||||
|
||||
$newsFeed = $newsFeed.map((feed) => {
|
||||
if (feed.id === feedId) {
|
||||
feed.reaction = reactedFeed.reaction;
|
||||
}
|
||||
|
||||
return feed;
|
||||
});
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string, authorId: string) => {
|
||||
const { response } = await createComment({
|
||||
content: comment,
|
||||
author_id: authorId,
|
||||
course_newsfeed_id: feedId
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
return snackbar.error('snackbar.course.error.commenting_error');
|
||||
}
|
||||
|
||||
if (!response.data) return;
|
||||
|
||||
createdComment = response?.data[0];
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.NEWSFEED, {
|
||||
authorId: author.id,
|
||||
feedId: feedId,
|
||||
comment
|
||||
});
|
||||
|
||||
$newsFeed = $newsFeed.map((feed) => {
|
||||
if (feed.id === feedId) {
|
||||
const newComment = {
|
||||
id: createdComment.id,
|
||||
author: {
|
||||
profile: { ...author }
|
||||
},
|
||||
created_at: createdComment.created_at,
|
||||
content: comment
|
||||
};
|
||||
|
||||
snackbar.success('snackbar.course.success.comment_added');
|
||||
|
||||
return {
|
||||
...feed,
|
||||
comment: [...feed.comment, { ...newComment }]
|
||||
};
|
||||
}
|
||||
|
||||
return feed;
|
||||
});
|
||||
};
|
||||
|
||||
const onPin = async (feedId, isPinned) => {
|
||||
const newIsPinned = !isPinned;
|
||||
const { response } = await toggleFeedIsPinned(feedId, newIsPinned);
|
||||
|
||||
if (response.error) {
|
||||
return snackbar.error('snackbar.course.error.toggle_error');
|
||||
}
|
||||
|
||||
$newsFeed = $newsFeed.map((feed) => {
|
||||
if (feed.id === feedId) {
|
||||
snackbar.success(
|
||||
`${
|
||||
newIsPinned ? 'snackbar.course.success.pinned' : 'snackbar.course.success.unpinned'
|
||||
} snackbar.course.success.successfully`
|
||||
);
|
||||
|
||||
feed.isPinned = newIsPinned;
|
||||
return feed;
|
||||
}
|
||||
|
||||
return feed;
|
||||
});
|
||||
};
|
||||
|
||||
const deleteFeed = (id: string) => {
|
||||
deleteNewsFeed(id);
|
||||
|
||||
const deletedFeed = $newsFeed.filter((feed) => feed.id !== id);
|
||||
snackbar.success('snackbar.course.success.feed_delete_success');
|
||||
|
||||
$newsFeed = deletedFeed;
|
||||
};
|
||||
|
||||
const initNewsFeed = async (courseId: string) => {
|
||||
if (!courseId) return;
|
||||
try {
|
||||
isLoading = true;
|
||||
const { data, error } = await fetchNewsFeeds(courseId);
|
||||
if (error) {
|
||||
snackbar.error('snackbar.course.error.news_feed_fail');
|
||||
}
|
||||
if (data) {
|
||||
$newsFeed = data
|
||||
.map((feedItem) => ({
|
||||
...feedItem,
|
||||
isPinned: feedItem.is_pinned
|
||||
}))
|
||||
.sort((a, b) => Number(b.isPinned) - Number(a.isPinned));
|
||||
}
|
||||
} catch (error) {
|
||||
snackbar.error('snackbar.course.error.feed_fetch_error');
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles: number[] = [1, 2];
|
||||
|
||||
@@ -223,12 +23,12 @@
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
initNewsFeed(data.courseId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>News Feed - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
@@ -236,70 +36,24 @@
|
||||
goto(`/courses/${data.courseId}/lessons?next=true`);
|
||||
}}
|
||||
>
|
||||
<PageNav title={$t('course.navItem.news_feed.heading')} disableSticky={true}>
|
||||
{#snippet widget()}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton
|
||||
className="mr-2"
|
||||
label={$t('course.navItem.news_feed.heading_button.title')}
|
||||
onClick={() => ($isNewFeedModal.open = true)}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button class="mr-2" onclick={() => ($isNewFeedModal.open = true)}>
|
||||
{$t('course.navItem.news_feed.heading_button.title')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewsFeedPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
|
||||
<PageBody width="max-w-4xl px-3">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<NewFeedModal
|
||||
courseId={data.courseId}
|
||||
{author}
|
||||
bind:edit
|
||||
bind:editFeed
|
||||
onSave={(newFeed) => {
|
||||
$newsFeed = [newFeed, ...$newsFeed];
|
||||
}}
|
||||
{onEdit}
|
||||
/>
|
||||
</RoleBasedSecurity>
|
||||
{#if isLoading}
|
||||
<div>
|
||||
<NewsFeedLoader />
|
||||
<NewsFeedLoader />
|
||||
<NewsFeedLoader />
|
||||
</div>
|
||||
{:else if !$newsFeed.length}
|
||||
<Box>
|
||||
<div class="flex w-[90%] flex-col items-center justify-between md:w-96">
|
||||
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
|
||||
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.news_feed.body_header')}</h2>
|
||||
<p class="text-center text-sm text-slate-500">
|
||||
{$t('course.navItem.news_feed.body_content')}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
{:else}
|
||||
{#each $newsFeed as feed}
|
||||
{#if feed.isPinned}
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<PinIcon size={16} class="filled" />
|
||||
|
||||
<p class="text-sm">{$t('course.navItem.news_feed.pinned')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
{deleteFeed}
|
||||
{addNewComment}
|
||||
{deleteComment}
|
||||
{addNewReaction}
|
||||
{onPin}
|
||||
{author}
|
||||
bind:edit
|
||||
bind:editFeed
|
||||
isActive={feedId === feed.id}
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</PageBody>
|
||||
</Page.Body>
|
||||
</RoleBasedSecurity>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -1,138 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { AnalyticsPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/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 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';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
let courseAnalytics: CourseAnalytics | null = $state(null);
|
||||
let isLoading = $state(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();
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Analytics - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer 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={ChartBarIcon}
|
||||
className="h-screen"
|
||||
/>
|
||||
{/if}
|
||||
</PageBody>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('analytics.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AnalyticsPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -1,255 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
|
||||
|
||||
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';
|
||||
import { attendance } from '$lib/utils/store/attendance';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { course, group } from '$lib/components/Course/store';
|
||||
import { takeAttendance } from '$lib/utils/services/attendance';
|
||||
import { getLectureNo } from '$lib/components/Course/function.js';
|
||||
import type { GroupPerson, Lesson } from '$lib/utils/types/index';
|
||||
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { AttendancePage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
|
||||
interface CourseData {
|
||||
attendance: {
|
||||
student_id: string;
|
||||
lesson_id: string;
|
||||
is_present: boolean;
|
||||
id: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
let hasFetched = $state(false);
|
||||
|
||||
const students: GroupPerson[] = $derived(
|
||||
$globalStore.isStudent
|
||||
? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
|
||||
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT)
|
||||
);
|
||||
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));
|
||||
|
||||
function setAttendance(courseData: CourseData) {
|
||||
for (const attendanceItem of courseData.attendance) {
|
||||
const { student_id, lesson_id, is_present, id } = attendanceItem;
|
||||
|
||||
if (!$attendance[student_id]) {
|
||||
$attendance[student_id] = {
|
||||
[lesson_id]: {
|
||||
id,
|
||||
is_present
|
||||
}
|
||||
};
|
||||
} else {
|
||||
$attendance[student_id] = {
|
||||
...$attendance[student_id],
|
||||
[lesson_id]: {
|
||||
id,
|
||||
is_present
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAttendanceChange(e: any, student: GroupPerson, lesson: Lesson) {
|
||||
if ($globalStore.isStudent) return;
|
||||
|
||||
const attendanceItem = $attendance[student.id]
|
||||
? $attendance[student.id][lesson.id] || { id: undefined }
|
||||
: { id: undefined };
|
||||
|
||||
/*
|
||||
This validation is useless. We shouldn't assume we already have an attendance item id
|
||||
because it might be a new attendance.
|
||||
if (!attendanceItem.id) {
|
||||
snackbar.error("Something isn't right. Please reload and take attendance again");
|
||||
console.error(`Attendance Id Missing`, courseId, student.id, lesson.id);
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
const _data = {
|
||||
id: attendanceItem.id,
|
||||
student_id: student.id,
|
||||
is_present: e.target.checked,
|
||||
lesson_id: lesson.id,
|
||||
course_id: data.courseId
|
||||
};
|
||||
|
||||
takeAttendance(_data).then((res) => {
|
||||
if (res.error) {
|
||||
console.error(`res.error`, res.error);
|
||||
snackbar.error('snackbar.attendance.error.reload');
|
||||
} else {
|
||||
// console.log('res', JSON.stringify(res));
|
||||
const { id, is_present } = res.data[0];
|
||||
if ($attendance[student.id]) {
|
||||
$attendance[student.id][lesson.id] = {
|
||||
id,
|
||||
is_present
|
||||
};
|
||||
} else {
|
||||
$attendance[student.id] = {
|
||||
[lesson.id]: {
|
||||
id,
|
||||
is_present
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// function for the searchbar
|
||||
function searchStudents(query: string, _students: GroupPerson[]) {
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return _students.filter((student) => student.profile?.fullname?.toLowerCase()?.includes(lowercaseQuery));
|
||||
}
|
||||
|
||||
async function firstRender(courseId?: string) {
|
||||
if (!courseId || hasFetched) return;
|
||||
|
||||
hasFetched = true;
|
||||
|
||||
if (!Object.keys($attendance).length) {
|
||||
setAttendance($course);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
firstRender($course.id);
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Attendance - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.attendance.title')} />
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12">
|
||||
<section class="mx-2 my-5 flex items-center lg:mx-9">
|
||||
<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>
|
||||
|
||||
<div class="relative w-full">
|
||||
<Input type="text" placeholder={$t('course.navItem.attendance.search_students')} bind:value={searchValue} />
|
||||
|
||||
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-2 my-5 lg:mx-9">
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-1/4">{$t('course.navItem.attendance.student')}</Table.Head>
|
||||
{#each $lessons as _lesson, index}
|
||||
<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">
|
||||
{student.profile.fullname}
|
||||
</Table.Cell>
|
||||
{#each $lessons as lesson}
|
||||
<Table.Cell class="text-center">
|
||||
<div class="flex justify-center">
|
||||
<Checkbox
|
||||
disabled={$globalStore.isStudent}
|
||||
checked={$attendance[student.id]
|
||||
? $attendance[student.id][lesson.id]
|
||||
? $attendance[student.id][lesson.id].is_present
|
||||
: false
|
||||
: false}
|
||||
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}
|
||||
<Box className="h-[300px] w-full">
|
||||
<BadgeXIcon size={48} />
|
||||
<h3 class="text-center text-3xl text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.attendance.no_student')}
|
||||
</h3>
|
||||
</Box>
|
||||
{/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>
|
||||
</PageBody>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.attendance.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AttendancePage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -1,57 +1,27 @@
|
||||
<script lang="ts">
|
||||
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
|
||||
import { CertificatesPage } from '$features/course/pages';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
|
||||
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
|
||||
// import Reports from '$lib/components/Course/components/Ceritficate/Reports.svelte';
|
||||
// import { issueCertificateModal } from '$lib/components/Course/components/Ceritficate/store';
|
||||
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<IssueCertificateModal />
|
||||
<svelte:head>
|
||||
<title>Certificates - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.certificates.title')} disableSticky={true}>
|
||||
{#snippet widget()}
|
||||
<slot:fragment>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<!-- Hide Functionality For V1 -->
|
||||
<!-- <PrimaryButton
|
||||
className="mr-2 rounded-md text-sm font-medium"
|
||||
label="Issue Cert"
|
||||
onClick={() => ($issueCertificateModal.open = true)}
|
||||
/> -->
|
||||
</RoleBasedSecurity>
|
||||
</slot:fragment>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.certificates.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CertificatesPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
|
||||
<PageBody className="w-full mx-0" padding="px-3 pb-10">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Design />
|
||||
</RoleBasedSecurity>
|
||||
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
|
||||
<StudentCertificate />
|
||||
</RoleBasedSecurity>
|
||||
|
||||
<!-- <Tabs autoWidth class="border-b border-gray-200 dark:border-neutral-600">
|
||||
<Tab label="Design" />
|
||||
<Tab label="Reports" />
|
||||
<svelte:fragment slot="content">
|
||||
<TabContent>
|
||||
<Design />
|
||||
</TabContent>
|
||||
|
||||
Temp disable Reports tab for V2
|
||||
<TabContent>
|
||||
<Reports />
|
||||
</TabContent>
|
||||
</svelte:fragment>
|
||||
</Tabs> -->
|
||||
</PageBody>
|
||||
</Page.Body>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
import { lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
|
||||
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
|
||||
import Editor from '$lib/components/CourseLandingPage/components/Editor/index.svelte';
|
||||
import { CourseLandingPage } from '$features/ui';
|
||||
import Editor from '$features/ui/course-landing-page/components/editor/editor.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
|
||||
@@ -1,159 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import ActivateSectionsModal from '$lib/components/Course/components/Lesson/ActivateSectionsModal.svelte';
|
||||
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
|
||||
import LessonList from '$lib/components/Course/components/Lesson/LessonList.svelte';
|
||||
import LessonSectionList from '$lib/components/Course/components/Lesson/LessonSectionList.svelte';
|
||||
import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
|
||||
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
|
||||
import { handleDelete, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { LessonsPage } from '$features/course/pages';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { Lesson } from '$lib/utils/types';
|
||||
import { COURSE_VERSION } from '$lib/utils/types';
|
||||
import { getGreeting } from '$lib/utils/functions/date.js';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { handleAddLessonWidget } from '$lib/components/Course/components/Lesson/store';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const query = new URLSearchParams(page.url.search);
|
||||
|
||||
const lessonsLength = $derived($course.version === COURSE_VERSION.V1 ? $lessons.length : $lessonSections.length);
|
||||
|
||||
let lessonEditing: string | undefined;
|
||||
let lessonToDelete: Lesson | undefined = $state();
|
||||
let openDeleteModal: boolean = $state(false);
|
||||
let isFetching: boolean = $state(false);
|
||||
let reorder = $state(false);
|
||||
let activateSections = $state(false);
|
||||
let lessonEditing: string | undefined;
|
||||
|
||||
function addLesson() {
|
||||
$handleAddLessonWidget.open = true;
|
||||
|
||||
$handleAddLessonWidget.isSection = $course.version === COURSE_VERSION.V2;
|
||||
}
|
||||
|
||||
function hasUserCompletedLesson(completion) {
|
||||
return completion?.find((c) => c.profile_id === $profile.id);
|
||||
}
|
||||
|
||||
const getLessons = () => {
|
||||
if ($course.version === COURSE_VERSION.V1) {
|
||||
return $lessons;
|
||||
} else {
|
||||
const _lessons: Lesson[] = [];
|
||||
|
||||
$lessonSections.forEach((section) => {
|
||||
_lessons.push(...section.lessons);
|
||||
});
|
||||
|
||||
return _lessons;
|
||||
}
|
||||
};
|
||||
|
||||
function findFirstIncompleteLesson() {
|
||||
return getLessons().find(
|
||||
(lesson) => !hasUserCompletedLesson(lesson.lesson_completion) && lesson.is_unlocked === true
|
||||
);
|
||||
}
|
||||
|
||||
function onNextQuery(lessons) {
|
||||
if (!isFetching && lessons.length > 0) {
|
||||
const incompleteLesson = findFirstIncompleteLesson();
|
||||
|
||||
if (incompleteLesson) {
|
||||
goto(`/courses/${data.courseId}/lessons/${incompleteLesson.id}`);
|
||||
} else {
|
||||
goto(`/courses/${data.courseId}/lessons`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shouldGoToNextLesson = $derived(query.get('next') === 'true');
|
||||
$effect(() => {
|
||||
!isFetching && shouldGoToNextLesson && onNextQuery($lessons);
|
||||
});
|
||||
</script>
|
||||
|
||||
<NewLessonModal />
|
||||
<svelte:head>
|
||||
<title>Lessons - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<ActivateSectionsModal bind:open={activateSections} />
|
||||
|
||||
<DeleteLessonConfirmation bind:openDeleteModal deleteLesson={() => handleDelete(lessonToDelete?.id)} />
|
||||
|
||||
<CourseContainer
|
||||
onFetchingChange={(fetching) => {
|
||||
isFetching = fetching;
|
||||
}}
|
||||
courseId={data.courseId}
|
||||
>
|
||||
<PageNav title={$t('course.navItem.lessons.heading_v2')}>
|
||||
{#snippet widget()}
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if $course.version === COURSE_VERSION.V1}
|
||||
<PrimaryButton
|
||||
label={$t(`course.navItem.lessons.section_prompt.cta`)}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => (activateSections = !activateSections)}
|
||||
isDisabled={!!lessonEditing}
|
||||
/>
|
||||
<Button variant="outline" onclick={() => (activateSections = !activateSections)} disabled={!!lessonEditing}>
|
||||
{$t(`course.navItem.lessons.section_prompt.cta`)}
|
||||
</Button>
|
||||
{/if}
|
||||
<PrimaryButton
|
||||
label={$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => (reorder = !reorder)}
|
||||
isDisabled={!!lessonEditing}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.lessons.add_lesson.button_title')}
|
||||
onClick={addLesson}
|
||||
isDisabled={!!lessonEditing}
|
||||
/>
|
||||
<Button variant="outline" onclick={() => (reorder = !reorder)} disabled={!!lessonEditing}>
|
||||
{$t(`course.navItem.lessons.add_lesson.${reorder ? 'end_reorder' : 'start_reorder'}`)}
|
||||
</Button>
|
||||
<Button onclick={addLesson} disabled={!!lessonEditing}>
|
||||
{$t('course.navItem.lessons.add_lesson.button_title')}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LessonsPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
|
||||
<PageBody width="max-w-6xl" padding="p-0">
|
||||
{#if shouldGoToNextLesson}
|
||||
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
|
||||
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.lessons.no_lesson')}</h2>
|
||||
<p class="text-center text-sm text-slate-500">
|
||||
{$t('course.navItem.lessons.share_your_knowledge')}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
{:else if lessonsLength > 0}
|
||||
{#if reorder}
|
||||
<p class="text-center text-xs italic text-gray-400 dark:text-white">
|
||||
{$t('course.navItem.lessons.drag')}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if $course.version === COURSE_VERSION.V1}
|
||||
<LessonList {reorder} {lessonEditing} bind:lessonToDelete bind:openDeleteModal />
|
||||
{:else if $course.version === COURSE_VERSION.V2}
|
||||
<LessonSectionList {reorder} {lessonEditing} />
|
||||
{/if}
|
||||
{:else}
|
||||
<Box className="w-full lg:w-11/12 lg:px-4 m-auto">
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="mx-auto my-2.5" />
|
||||
<h2 class="my-1.5 text-xl font-normal">{$t('course.navItem.lessons.body_header')}</h2>
|
||||
<p class="text-center text-sm text-slate-500">
|
||||
{$t('course.navItem.lessons.body_content')}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
{/if}
|
||||
</PageBody>
|
||||
</Page.Body>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
|
||||
import MODES from '$lib/utils/constants/mode';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { course, group } from '$lib/components/Course/store';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { getGroupMemberId } from '$lib/components/Course/function';
|
||||
@@ -26,10 +26,10 @@
|
||||
import { checkExercisesComplete, fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
|
||||
import { lesson, setLesson, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
|
||||
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
|
||||
|
||||
@@ -207,36 +207,28 @@
|
||||
courseId={data.courseId}
|
||||
isExercisePage={!data.isMaterialsTabActive && !!data.exerciseId}
|
||||
>
|
||||
<PageNav
|
||||
hideOnMobile={$globalStore.isStudent}
|
||||
navItems={[
|
||||
{
|
||||
label: $t('course.navItem.lessons.lesson_nav.materials'),
|
||||
isActive: data.isMaterialsTabActive,
|
||||
href: path
|
||||
},
|
||||
{
|
||||
label: $t('course.navItem.lessons.lesson_nav.exercises'),
|
||||
badgeValue: data.isMaterialsTabActive ? $lesson.totalExercises : $lesson.exercises.length,
|
||||
isActive: !data.isMaterialsTabActive,
|
||||
href: `${path}/exercises`
|
||||
}
|
||||
]}
|
||||
>
|
||||
{#snippet widget()}
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{data.isMaterialsTabActive
|
||||
? $t('course.navItem.lessons.lesson_nav.materials')
|
||||
: $t('course.navItem.lessons.lesson_nav.exercises')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex items-center gap-1">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if data.isMaterialsTabActive}
|
||||
<!-- Version control -->
|
||||
{#if mode === MODES.edit && window.innerWidth >= 1024}
|
||||
<IconButton onClick={() => (isVersionDrawerOpen = true)}>
|
||||
<IconButton onclick={() => (isVersionDrawerOpen = true)}>
|
||||
<HistoryIcon size={20} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
|
||||
<div class="flex-row items-center lg:flex">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
onclick={() => {
|
||||
toggleMode();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
@@ -266,13 +258,13 @@
|
||||
{/if}
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
{#if !data.isMaterialsTabActive}
|
||||
<Exercises lessonId={data.lessonId} exerciseId={data.exerciseId} path={`${path}/exercises`} />
|
||||
{:else if !!data.lessonId}
|
||||
<PageBody isPageNavHidden={$globalStore.isStudent} width="lg:w-full xl:w-11/12" className="overflow-x-hidden">
|
||||
<div class="overflow-x-hidden lg:w-full xl:w-11/12">
|
||||
<!-- Shows Lesson Note / Slides / Video -->
|
||||
<Materials
|
||||
lessonId={data.lessonId}
|
||||
@@ -282,7 +274,7 @@
|
||||
bind:isSaving
|
||||
isStudent={$globalStore.isStudent}
|
||||
/>
|
||||
</PageBody>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bottom Lesson Widget -->
|
||||
|
||||
@@ -1,133 +1,65 @@
|
||||
<script lang="ts">
|
||||
import jsPDF from 'jspdf';
|
||||
import Papa from 'papaparse';
|
||||
import { goto } from '$app/navigation';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { MarksPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import type { GroupPerson } from '$lib/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { fetchMarks } from '$lib/utils/services/marks';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { getLectureNo } from '$lib/components/Course/function.js';
|
||||
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import jsPDF from 'jspdf';
|
||||
import Papa from 'papaparse';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
import type { GroupPerson } from '$lib/utils/types';
|
||||
import { getLectureNo } from '$lib/components/Course/function.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let borderBottomGrey = 'border-r-0 border-t-0 border-b border-l-0 border-gray-300';
|
||||
let borderleftGrey = 'border-r-0 border-t-0 border-b-0 border-l border-gray-300';
|
||||
let students: GroupPerson[] = $state([]);
|
||||
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({}); // { lessonId: { exerciseId: exerciseTitle, ... }, ... }
|
||||
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({}); // { groupMemberId: { exerciseId: `total_gotten/points`, ... }, ... }
|
||||
let isDownloading = $state(false);
|
||||
let hasFetched = $state(false);
|
||||
let students: GroupPerson[] = $state([]);
|
||||
let lessonMapping: Record<string, Record<string, { title: string; points: number }>> = $state({});
|
||||
let studentMarksByExerciseId: Record<string, Record<string, string>> = $state({});
|
||||
|
||||
function calculateStudentTotal(studentExerciseData) {
|
||||
if (!studentExerciseData) return 0;
|
||||
|
||||
return Object.values(studentExerciseData).reduce((total: number, point) => (total += parseInt(point as string)), 0);
|
||||
}
|
||||
|
||||
async function firstRender(courseId?: string) {
|
||||
if (!courseId || hasFetched) return;
|
||||
|
||||
hasFetched = true;
|
||||
|
||||
const { data: marks, error } = await fetchMarks(courseId);
|
||||
if (error) {
|
||||
console.error('Error fetching marks', error);
|
||||
snackbar.error('snackbar.marks.error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!marks || !Array.isArray(marks)) return;
|
||||
|
||||
marks.forEach((mark) => {
|
||||
const { groupmember_id, exercise_id, total_points_gotten } = mark;
|
||||
|
||||
if (studentMarksByExerciseId[groupmember_id]) {
|
||||
studentMarksByExerciseId[groupmember_id] = {
|
||||
...studentMarksByExerciseId[groupmember_id],
|
||||
[exercise_id]: total_points_gotten
|
||||
};
|
||||
} else {
|
||||
studentMarksByExerciseId[groupmember_id] = {
|
||||
[exercise_id]: total_points_gotten
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
const { data: exercises } = await fetchExercisesByMarks(courseId);
|
||||
|
||||
// Map exercises to { lessonId: { exerciseId: exerciseTitle, ... }, ... }
|
||||
exercises.forEach((exercise) => {
|
||||
const { lesson_id, exercise_id, exercise_title, points } = exercise;
|
||||
|
||||
if (lessonMapping[lesson_id]) {
|
||||
lessonMapping[lesson_id] = {
|
||||
...lessonMapping[lesson_id],
|
||||
[exercise_id]: { title: exercise_title, points }
|
||||
};
|
||||
} else {
|
||||
lessonMapping[lesson_id] = {
|
||||
[exercise_id]: { title: exercise_title, points }
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
// TODO WRITE A REDIRECT FUNCTION FOR THIS THIS PAGE
|
||||
|
||||
function getPageRoles(org: AccountOrg) {
|
||||
const roles = [1, 2];
|
||||
|
||||
if (org.customization?.course?.grading) {
|
||||
roles.push(3);
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
const downloadCSV = () => {
|
||||
isDownloading = true;
|
||||
|
||||
const exportData = students.map((student) => {
|
||||
const rowData = {
|
||||
name: student.profile.fullname,
|
||||
email: student.profile.email,
|
||||
Total: 0
|
||||
};
|
||||
|
||||
const totalPoints = calculateStudentTotal(studentMarksByExerciseId[student.id]);
|
||||
|
||||
$lessons.forEach((lesson, lessonIndex) => {
|
||||
const quizzes = lessonMapping[lesson.id] || {};
|
||||
const quizMark = studentMarksByExerciseId[student.id] || {};
|
||||
|
||||
Object.keys(quizzes).forEach((quizId) => {
|
||||
const quiz = quizzes[quizId];
|
||||
const key = `L${lessonIndex + 1} - ${quiz.title} (${lesson.title})`;
|
||||
|
||||
rowData[key] = quizMark[quizId] || '-';
|
||||
});
|
||||
});
|
||||
|
||||
rowData['Total'] = totalPoints;
|
||||
return rowData;
|
||||
});
|
||||
|
||||
const csv = Papa.unparse(exportData);
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
@@ -137,20 +69,14 @@
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
isDownloading = false;
|
||||
};
|
||||
|
||||
function downloadPDF() {
|
||||
isDownloading = true;
|
||||
|
||||
try {
|
||||
const doc = new jsPDF({
|
||||
orientation: 'landscape'
|
||||
});
|
||||
|
||||
// Construct Headers
|
||||
const head = [['Student']]; // Starting with 'Student' as the first header
|
||||
const doc = new jsPDF({ orientation: 'landscape' });
|
||||
const head = [['Student']];
|
||||
$lessons.forEach((lesson, lessonIndex) => {
|
||||
if (lessonMapping[lesson.id]) {
|
||||
Object.values(lessonMapping[lesson.id]).forEach((exercise) => {
|
||||
@@ -158,13 +84,10 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
head[0].push('Total'); // Add 'Total' as the last header
|
||||
|
||||
// Construct Rows
|
||||
head[0].push('Total');
|
||||
const body = students.map((student) => {
|
||||
const row: string[] = [];
|
||||
row.push(student?.profile?.fullname ?? ''); // Student name
|
||||
|
||||
row.push(student?.profile?.fullname ?? '');
|
||||
$lessons.forEach((lesson) => {
|
||||
if (lessonMapping[lesson.id]) {
|
||||
Object.keys(lessonMapping[lesson.id]).forEach((exerciseId) => {
|
||||
@@ -173,15 +96,10 @@
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate total
|
||||
const total = '' + calculateStudentTotal(studentMarksByExerciseId[student.id]);
|
||||
row.push(total);
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
// Generate the table in PDF
|
||||
autoTable(doc, {
|
||||
head,
|
||||
body,
|
||||
@@ -190,14 +108,11 @@
|
||||
headStyles: { fillColor: [22, 160, 133] },
|
||||
footStyles: { fillColor: [22, 160, 133] },
|
||||
didDrawPage: function (data) {
|
||||
// Header
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(40);
|
||||
doc.text(`${$course?.title} - Marks`, data.settings.margin.left, 10);
|
||||
}
|
||||
});
|
||||
|
||||
// Save the PDF
|
||||
doc.save(`${$course?.title}-marks.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
@@ -205,12 +120,12 @@
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
firstRender($course.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marks - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<RoleBasedSecurity
|
||||
allowedRoles={getPageRoles($currentOrg)}
|
||||
@@ -218,132 +133,43 @@
|
||||
goto(`/courses/${data.courseId}/lessons?next=true`);
|
||||
}}
|
||||
>
|
||||
<PageNav title={$t('course.navItem.marks.title')}>
|
||||
{#snippet widget()}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="rounded-full">
|
||||
{#if isDownloading}
|
||||
<Progress />
|
||||
{:else}
|
||||
<DownloadIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={downloadCSV}>
|
||||
{$t('course.navItem.marks.export.csv')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={downloadPDF}>
|
||||
{$t('course.navItem.marks.export.pdf')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12">
|
||||
<div id="tableContainer" class="table w-full rounded-md border border-gray-300">
|
||||
<div class="flex items-center {borderBottomGrey}">
|
||||
<div class="box flex items-center p-3">
|
||||
<p class="w-40 dark:text-white">{$t('course.navItem.marks.student')}</p>
|
||||
</div>
|
||||
{#each $lessons as lesson, index}
|
||||
{#if lessonMapping[lesson.id]}
|
||||
<div class="box flex flex-col items-center {borderleftGrey}">
|
||||
<p class="col lesson-number dark:text-white" title={lesson.title}>
|
||||
{getLectureNo(index + 1)}
|
||||
</p>
|
||||
<div class="flex h-full items-center border-b-0 border-l-0 border-r-0 border-t border-gray-300">
|
||||
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
|
||||
<p
|
||||
class="col text-sm dark:text-white {index && borderleftGrey}"
|
||||
title={lessonMapping[lesson.id][exerciseId].title}
|
||||
>
|
||||
{lessonMapping[lesson.id][exerciseId].title}
|
||||
<span>({lessonMapping[lesson.id][exerciseId].points})</span>
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="box flex items-center {borderleftGrey}">
|
||||
<p class="w-20 text-center dark:text-white">{$t('course.navItem.marks.total')}</p>
|
||||
</div>
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.marks.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<div class="flex w-full justify-end gap-2">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<Button variant="ghost" size="icon" class="rounded-full">
|
||||
{#if isDownloading}
|
||||
<Progress />
|
||||
{:else}
|
||||
<DownloadIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={downloadCSV}>
|
||||
{$t('course.navItem.marks.export.csv')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={downloadPDF}>
|
||||
{$t('course.navItem.marks.export.pdf')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
{#each students as student}
|
||||
<div class="relative flex cursor-pointer items-center p-3 {borderBottomGrey}">
|
||||
<div class="flex w-40 items-center">
|
||||
<img alt="Student avatar" src={student.profile.avatar_url} class="mr-2 h-8 w-8 rounded-full" />
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold dark:text-white">
|
||||
{student.profile.fullname}
|
||||
</p>
|
||||
<p class="dark:text-white">
|
||||
{`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#each $lessons as lesson}
|
||||
{#if lessonMapping[lesson.id]}
|
||||
<div class="flex items-center">
|
||||
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId}
|
||||
<p class="col dark:text-white">
|
||||
{studentMarksByExerciseId[student.id]
|
||||
? studentMarksByExerciseId[student.id][exerciseId] || '-'
|
||||
: '-'}
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<div class="flex w-20 items-center">
|
||||
<div class="text-sm">
|
||||
<p class="col dark:text-white">
|
||||
{calculateStudentTotal(studentMarksByExerciseId[student.id])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Box>
|
||||
<BadgeXIcon size={16} />
|
||||
<h3 class="text-3xl text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.marks.no_student')}
|
||||
</h3>
|
||||
</Box>
|
||||
{/each}
|
||||
</div>
|
||||
</PageBody>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<MarksPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</RoleBasedSecurity>
|
||||
</CourseContainer>
|
||||
|
||||
<style>
|
||||
.col {
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.br {
|
||||
border-right: 1px solid rgba(209, 213, 219, var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.box {
|
||||
height: 95px;
|
||||
}
|
||||
|
||||
.lesson-number {
|
||||
height: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
|
||||
let { data = $bindable(), children } = $props();
|
||||
@@ -29,25 +29,35 @@
|
||||
</script>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.people.title')} disableSticky={true}>
|
||||
{#snippet image()}
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<Page.Header>
|
||||
{#if data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<IconButton size="large" onClick={handleBackNavigation}>
|
||||
<IconButton onclick={handleBackNavigation}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
</IconButton>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet widget()}
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PrimaryButton className="mr-2" label={$t('course.navItem.people.add')} onClick={handleClick} />
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageNav>
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12">
|
||||
{@render children?.()}
|
||||
</PageBody>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.people.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if !data.personId}
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button class="mr-2" onclick={handleClick}>
|
||||
{$t('course.navItem.people.add')}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{@render children?.()}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</div>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -1,245 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { PeoplePage } from '$features/course/pages';
|
||||
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import InvitationModal from '$lib/components/Course/components/People/InvitationModal.svelte';
|
||||
import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { GroupPerson } from '$lib/utils/types';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import Select from '$lib/components/Form/Select.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
|
||||
import { deleteGroupMember } from '$lib/utils/services/courses';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import type { ProfileRole } from '$lib/components/Course/components/People/types';
|
||||
import { deleteMemberModal } from '$lib/components/Course/components/People/store';
|
||||
|
||||
let member: { id?: string; email?: string; profile?: { email: string } } = $state({});
|
||||
let filterBy: ProfileRole = $state(ROLES[0]);
|
||||
let searchValue = $state('');
|
||||
let copiedEmail = $state<string | null>(null);
|
||||
|
||||
const people: Array<GroupPerson> = $derived(sortAndFilterPeople($group.people, filterBy));
|
||||
|
||||
function filterPeople(_query, people) {
|
||||
const query = _query.toLowerCase();
|
||||
return people.filter((person) => {
|
||||
const { profile, email } = person;
|
||||
return profile?.fullname?.toLowerCase()?.includes(query) || email?.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePerson() {
|
||||
if (!member.id) return;
|
||||
$group.people = $group.people.filter((person: { id: string }) => person.id !== member.id);
|
||||
$group.tutors = $group.tutors.filter((person: GroupPerson) => person.memberId !== member.id);
|
||||
|
||||
await deleteGroupMember(member.id);
|
||||
}
|
||||
|
||||
function sortAndFilterPeople(_people: Array<GroupPerson>, filterBy: ProfileRole) {
|
||||
return (_people || [])
|
||||
.filter((person) => {
|
||||
if (filterBy.value === 'all') return true;
|
||||
|
||||
return person.role_id === filterBy.value;
|
||||
})
|
||||
.sort((a: GroupPerson, b: GroupPerson) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
|
||||
.sort((a: GroupPerson, b: GroupPerson) => a.role_id - b.role_id);
|
||||
}
|
||||
|
||||
function getEmail(person) {
|
||||
const { profile, email } = person;
|
||||
|
||||
return profile ? profile.email : email;
|
||||
}
|
||||
|
||||
function obscureEmail(email) {
|
||||
const [username, domain] = email.split('@');
|
||||
const obscuredUsername =
|
||||
username.charAt(0) + '*'.repeat(username.length - 2) + username.charAt(username.length - 1);
|
||||
|
||||
return `${obscuredUsername}@${domain}`;
|
||||
}
|
||||
|
||||
function gotoPerson(person) {
|
||||
goto(`${page.url.href}/${person.profile_id}`);
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedEmail = text;
|
||||
setTimeout(() => {
|
||||
copiedEmail = null;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
}
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<InvitationModal />
|
||||
|
||||
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
|
||||
|
||||
<section class="mx-2 my-5 md:mx-9">
|
||||
<div class="flex-end mb-7 flex flex-col items-start justify-end gap-2 md:flex-row md:items-center">
|
||||
<div class="relative max-w-[320px]">
|
||||
<Input type="text" placeholder={$t('course.navItem.people.search')} bind:value={searchValue} class="w-full" />
|
||||
|
||||
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Select
|
||||
bind:value={filterBy}
|
||||
options={ROLES.map((role) => ({ label: $t(role.label), value: role.value }))}
|
||||
className="dark:text-black mt-3 max-w-[80px]"
|
||||
/>
|
||||
</div>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<p class="hidden w-20 text-lg lg:block dark:text-white"></p>
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('course.navItem.people.name')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.people.role')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.people.action')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each filterPeople(searchValue, people) as person}
|
||||
<Table.Row>
|
||||
<!-- first column -->
|
||||
<Table.Cell class="w-4/6 md:w-3/6">
|
||||
{#if person.profile}
|
||||
<div class="flex items-start lg:items-center">
|
||||
<Avatar.Root class="mr-3">
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||
<div class="mr-2">
|
||||
<p class="text-base font-normal dark:text-white">
|
||||
{person.profile.fullname}
|
||||
</p>
|
||||
<p class="text-primary-600 line-clamp-1 text-xs">
|
||||
{obscureEmail(getEmail(person))}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => copyToClipboard(getEmail(person))}
|
||||
>
|
||||
{#if copiedEmail === getEmail(person)}
|
||||
<CheckIcon size={16} class="text-green-600" />
|
||||
{:else}
|
||||
<CopyIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
{#if person.profile_id == $profile.id}
|
||||
<ComingSoon label={$t('course.navItem.people.you')} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex w-2/4 items-start lg:items-center">
|
||||
<TextChip
|
||||
value={shortenName(person.email)}
|
||||
className="bg-primary-200 text-black font-semibold text-xs mr-3"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
<a href="mailto:{person.email}" class="text-md text-primary-600 mr-2 dark:text-white">
|
||||
{person.email}
|
||||
</a>
|
||||
<div class="flex items-center justify-between">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
onclick={() => copyToClipboard(getEmail(person))}
|
||||
>
|
||||
{#if copiedEmail === getEmail(person)}
|
||||
<CheckIcon size={16} class="text-green-600" />
|
||||
{:else}
|
||||
<CopyIcon size={16} />
|
||||
{/if}
|
||||
</Button>
|
||||
</RoleBasedSecurity>
|
||||
|
||||
<TextChip
|
||||
value={$t('course.navItem.people.pending')}
|
||||
className="text-xs bg-yellow-200 text-yellow-700 h-fit"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
|
||||
<!-- second column -->
|
||||
<Table.Cell class="w-1/4">
|
||||
<p class=" w-1/4 text-center text-base font-normal dark:text-white">
|
||||
{$t(ROLE_LABEL[person.role_id])}
|
||||
</p>
|
||||
</Table.Cell>
|
||||
|
||||
<!-- third column -->
|
||||
<Table.Cell class="w-1/4">
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<div class="hidden space-x-2 sm:flex sm:items-center">
|
||||
{#if person.profile_id !== $profile.id}
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
member = person;
|
||||
$deleteMemberModal.open = true;
|
||||
}}
|
||||
>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label={$t('course.navItem.people.view')}
|
||||
onClick={() => gotoPerson(person)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</RoleBasedSecurity>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</section>
|
||||
<PeoplePage courseId={data.courseId} />
|
||||
|
||||
@@ -7,13 +7,12 @@
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import type { UserCourseAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -62,7 +61,7 @@
|
||||
{
|
||||
description: $t('analytics.overall_course_progress_user_description'),
|
||||
icon: BookOpenIcon,
|
||||
percentage: userCourseAnalytics?.progressPercentage,
|
||||
percentage: userCourseAnalytics?.progressPercentage ?? 0,
|
||||
title: $t('analytics.overall_course_progress')
|
||||
},
|
||||
{
|
||||
@@ -77,7 +76,7 @@
|
||||
{
|
||||
description: $t('analytics.average_grade_description'),
|
||||
icon: UnfoldVerticalIcon,
|
||||
percentage: userCourseAnalytics?.averageGrade,
|
||||
percentage: userCourseAnalytics?.averageGrade ?? 0,
|
||||
title: $t('analytics.average_grade')
|
||||
}
|
||||
]);
|
||||
@@ -102,13 +101,13 @@
|
||||
<section class="px-1">
|
||||
<HeroProfileCard user={userCourseAnalytics.user} />
|
||||
|
||||
<Grid class="mt-5 px-0">
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each learningActivities as activity}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
|
||||
<h3 class="text-2xl">
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
<script lang="ts">
|
||||
import Settings from '$lib/components/Course/components/Settings/index.svelte';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { CourseSettingsPage } from '$features/course/pages';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let settingsComponent: CourseSettingsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await settingsComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<PageNav title={$t('course.navItem.settings.heading')} />
|
||||
<PageBody>
|
||||
<Settings />
|
||||
</PageBody>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.settings.heading')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isSaving} onclick={handleSave}>
|
||||
{$t('course.navItem.settings.save')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CourseSettingsPage bind:this={settingsComponent} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</div>
|
||||
</CourseContainer>
|
||||
|
||||
@@ -1,459 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { flip } from 'svelte/animate';
|
||||
import { goto } from '$app/navigation';
|
||||
import { dndzone } from 'svelte-dnd-action';
|
||||
|
||||
import {
|
||||
deleteSubmission,
|
||||
fetchSubmissions,
|
||||
updateQuestionAnswer,
|
||||
updateSubmission
|
||||
} from '$lib/utils/services/submissions';
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import formatDate from '$lib/utils/functions/formatDate';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { formatAnswers } from '$lib/components/Course/function.js';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import isSubmissionEarly from '$lib/utils/functions/isSubmissionEarly';
|
||||
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
|
||||
import type { SubmissionIdData, SubmissionItem, SubmissionSection } from '$lib/utils/types/submission';
|
||||
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import { PageBody, PageNav } from '$lib/components/Page';
|
||||
import { SubmissionsPage } from '$features/course/pages';
|
||||
import { CourseContainer } from '$lib/components/CourseContainer';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
|
||||
import { RoleBasedSecurity } from '$features/ui';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { data = $bindable() } = $props();
|
||||
const { courseId } = data;
|
||||
|
||||
const flipDurationMs = 300;
|
||||
let exerciseDetails: { id: string; title: string };
|
||||
let lessonDetails: { id: string; title: string };
|
||||
let totalMark = 0;
|
||||
let maxMark = 0;
|
||||
let submissionIdData: { [key: number]: SubmissionIdData } = $state({});
|
||||
let isGradeWithAI = $state(false);
|
||||
let fetching = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let hasFetched = $state(false);
|
||||
|
||||
const submissionId = $derived(new URLSearchParams(page.url.search).get('submissionId') ?? '');
|
||||
let openExercise = $derived.by(() => {
|
||||
return !!submissionId && !!submissionIdData[submissionId];
|
||||
});
|
||||
|
||||
const submissionStatus: { [key: number]: string } = {
|
||||
1: $t('course.navItem.submissions.submission_status.submitted'),
|
||||
2: $t('course.navItem.submissions.submission_status.in_progress'),
|
||||
3: $t('course.navItem.submissions.submission_status.graded')
|
||||
};
|
||||
|
||||
let sections: SubmissionSection[] = $state([
|
||||
{
|
||||
id: 1,
|
||||
title: $t('course.navItem.submissions.submission_status.submitted'),
|
||||
value: 0,
|
||||
items: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: $t('course.navItem.submissions.submission_status.in_progress'),
|
||||
value: 0,
|
||||
items: []
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: $t('course.navItem.submissions.submission_status.graded'),
|
||||
value: 10,
|
||||
items: []
|
||||
}
|
||||
]);
|
||||
|
||||
function getMaxPoints(questions) {
|
||||
return (questions || []).reduce((acc, question) => acc + question.points, 0);
|
||||
}
|
||||
|
||||
function calculateTotal(grades: string[]): number {
|
||||
if (!grades) return 0;
|
||||
return Object.values(grades).reduce((acc, grade) => acc + parseInt(grade), 0);
|
||||
}
|
||||
|
||||
const notifyStudent = (submissionData) => {
|
||||
maxMark = getMaxPoints(submissionData?.questions);
|
||||
totalMark = calculateTotal(submissionData?.questionAnswerByPoint);
|
||||
|
||||
const { fullname, email } = submissionData?.student;
|
||||
const { title, statusId }: { title: string; statusId: number } = submissionData;
|
||||
const exerciseLink = `${$currentOrgDomain}/courses/${courseId}/lessons/${lessonDetails.id}/exercises/${exerciseDetails.id}`;
|
||||
|
||||
const content = `
|
||||
<p>Hello ${fullname},</p>
|
||||
<p>The status of your submitted exercise on <strong>${title}</strong> has been updated to ${
|
||||
submissionStatus[statusId]
|
||||
}</p>
|
||||
${
|
||||
statusId == 3
|
||||
? `<p>Your score was ${totalMark}/${maxMark}</p>
|
||||
<a class="button" href="${exerciseLink}">View your Result</a>
|
||||
`
|
||||
: `<a class="button" href="${exerciseLink}">Open Exercise</a>`
|
||||
}
|
||||
<p>This exercise is for <strong>${
|
||||
lessonDetails.title
|
||||
}</strong> in a course you are taking titled <strong>${$course.title}</strong></p>
|
||||
`;
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.SUBMISSION_UPDATE, {
|
||||
to: email,
|
||||
content,
|
||||
orgName: $currentOrg?.name
|
||||
});
|
||||
};
|
||||
|
||||
function handleItemFinalize(
|
||||
columnIdx: number,
|
||||
newItems: { map: (arg0: (item: SubmissionItem) => SubmissionItem) => SubmissionItem[] }
|
||||
) {
|
||||
let itemToWithNewStatus: SubmissionItem | undefined;
|
||||
|
||||
const { id } = sections[columnIdx];
|
||||
|
||||
// Set column in the UI
|
||||
sections[columnIdx].items = newItems.map((item) => {
|
||||
if (item.statusId !== id) {
|
||||
itemToWithNewStatus = item;
|
||||
item.statusId = id;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
|
||||
// Update backend
|
||||
if (itemToWithNewStatus) {
|
||||
// Update key mapping for each submission also
|
||||
submissionIdData[itemToWithNewStatus.id] = {
|
||||
...submissionIdData[itemToWithNewStatus.id],
|
||||
statusId: itemToWithNewStatus.statusId
|
||||
};
|
||||
|
||||
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
|
||||
|
||||
updateSubmission({
|
||||
id: itemToWithNewStatus.id,
|
||||
status_id: itemToWithNewStatus.statusId
|
||||
}).then((res) => console.log('Updated submission', res));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDndConsiderCards(columnIdx: number) {
|
||||
return function (e) {
|
||||
sections[columnIdx].items = e.detail.items;
|
||||
};
|
||||
}
|
||||
|
||||
function handleDndFinalizeCards(columnIdx: number) {
|
||||
return (e) => handleItemFinalize(columnIdx, e.detail.items);
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
isGradeWithAI = false;
|
||||
goto(page.url.pathname);
|
||||
}
|
||||
|
||||
// Via dialog
|
||||
function updateStatus({
|
||||
submissionId,
|
||||
prevStatusId,
|
||||
nextStatusId,
|
||||
total
|
||||
}: {
|
||||
submissionId: string;
|
||||
prevStatusId: number;
|
||||
nextStatusId: number;
|
||||
total: number;
|
||||
}) {
|
||||
let itemToWithNewStatus: SubmissionItem | undefined;
|
||||
|
||||
// Remove from current column
|
||||
const { items } = sections[prevStatusId - 1];
|
||||
sections[prevStatusId - 1].items = items?.filter((item) => {
|
||||
if (item.id === submissionId) {
|
||||
itemToWithNewStatus = Object.assign(item);
|
||||
if (itemToWithNewStatus) {
|
||||
itemToWithNewStatus.statusId = nextStatusId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
// Move to right column
|
||||
if (itemToWithNewStatus) {
|
||||
sections[nextStatusId - 1].items = [...sections[nextStatusId - 1].items, itemToWithNewStatus];
|
||||
}
|
||||
|
||||
// If something changed
|
||||
if (itemToWithNewStatus) {
|
||||
// Update key mapping for each submission also
|
||||
submissionIdData[itemToWithNewStatus.id] = {
|
||||
...submissionIdData[itemToWithNewStatus.id],
|
||||
statusId: itemToWithNewStatus.statusId
|
||||
};
|
||||
|
||||
notifyStudent(submissionIdData[itemToWithNewStatus.id]);
|
||||
// Update backend
|
||||
updateSubmission({
|
||||
id: itemToWithNewStatus.id,
|
||||
status_id: itemToWithNewStatus.statusId,
|
||||
total
|
||||
}).then((res) => console.log('Updated submission', res));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSubmission(id: string, statusId: number) {
|
||||
const { items } = sections[statusId - 1];
|
||||
|
||||
sections[statusId - 1].items = items?.filter((item) => {
|
||||
return item.id === id ? false : true;
|
||||
});
|
||||
|
||||
submissionIdData[id] = undefined;
|
||||
|
||||
const { error } = await deleteSubmission(id);
|
||||
|
||||
if (error) {
|
||||
console.error('Error deleting submission', error);
|
||||
snackbar.error('course.navItem.submissions.grading_modal.delete_error');
|
||||
return;
|
||||
}
|
||||
|
||||
snackbar.success('course.navItem.submissions.grading_modal.delete_success');
|
||||
|
||||
handleModalClose();
|
||||
}
|
||||
|
||||
async function handleSave(submission: { questionAnswerByPoint: any; questionAnswers: any; feedback: any }) {
|
||||
isSaving = true;
|
||||
const { questionAnswerByPoint, questionAnswers, feedback } = submission;
|
||||
|
||||
let totalPoints = 0;
|
||||
|
||||
for (const questionId in questionAnswerByPoint) {
|
||||
if (Object.prototype.hasOwnProperty.call(questionAnswerByPoint, questionId)) {
|
||||
const questionAnswer = questionAnswers.find(
|
||||
(answer: { question_id: string }) => answer.question_id == questionId
|
||||
);
|
||||
const point = questionAnswerByPoint[questionId];
|
||||
|
||||
totalPoints += parseInt(point, 10);
|
||||
|
||||
const qaResponse = await updateQuestionAnswer({ point }, { id: questionAnswer?.id });
|
||||
|
||||
if (qaResponse.error) {
|
||||
console.error('Error saving', qaResponse.error);
|
||||
snackbar.error(`snackbar.something`);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await updateSubmission({
|
||||
id: submissionId,
|
||||
total: totalPoints,
|
||||
feedback: feedback
|
||||
});
|
||||
|
||||
snackbar.success('snackbar.submissions.success.grading');
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
async function firstRender(courseId?: string) {
|
||||
if (!courseId || hasFetched) return;
|
||||
|
||||
hasFetched = true;
|
||||
|
||||
fetching = true;
|
||||
const { data: submissions } = await fetchSubmissions(courseId);
|
||||
const sectionById: { [key: number]: SubmissionSection[] } = {};
|
||||
|
||||
if (submissions) {
|
||||
for (const submission of submissions) {
|
||||
const { id, created_at, answers, groupmember, feedback, exercise } = submission;
|
||||
|
||||
const isEarly = isSubmissionEarly(created_at, exercise.due_by);
|
||||
|
||||
const submissionItem = {
|
||||
id,
|
||||
statusId: submission.status_id,
|
||||
isEarly,
|
||||
feedback,
|
||||
submittedAt: formatDate(created_at),
|
||||
exercise: {
|
||||
id: exercise.id,
|
||||
title: exercise.title
|
||||
},
|
||||
answers,
|
||||
student: groupmember && groupmember.profile ? groupmember.profile : {},
|
||||
lesson: {
|
||||
id: exercise.lesson.id,
|
||||
title: exercise.lesson.title
|
||||
}
|
||||
};
|
||||
|
||||
exerciseDetails = { id: submissionItem.exercise.id, title: submissionItem.exercise.title };
|
||||
|
||||
lessonDetails = {
|
||||
id: submissionItem.lesson.id,
|
||||
title: submissionItem.lesson.title
|
||||
};
|
||||
|
||||
submissionIdData[id] = {
|
||||
id,
|
||||
statusId: submissionItem.statusId,
|
||||
feedback,
|
||||
isEarly,
|
||||
title: exercise.title,
|
||||
student: submissionItem.student,
|
||||
questions: exercise.questions,
|
||||
answers: formatAnswers({ questions: exercise.questions, answers }),
|
||||
questionAnswers: answers,
|
||||
questionAnswerByPoint: answers.reduce((acc, answer) => {
|
||||
acc[answer.question_id] = answer.point;
|
||||
|
||||
return acc;
|
||||
}, {})
|
||||
};
|
||||
|
||||
if (Array.isArray(sectionById[submission.status_id])) {
|
||||
sectionById[submission.status_id].push(submissionItem);
|
||||
} else {
|
||||
sectionById[submission.status_id] = [submissionItem];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections = sections.map((section, index) => ({
|
||||
...section,
|
||||
value: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1].length : 0,
|
||||
items: Array.isArray(sectionById[index + 1]) ? sectionById[index + 1] : []
|
||||
}));
|
||||
|
||||
fetching = false;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
firstRender($course.id);
|
||||
});
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<MarkExerciseModal
|
||||
bind:open={openExercise}
|
||||
onClose={handleModalClose}
|
||||
data={submissionIdData[submissionId] || {}}
|
||||
{handleSave}
|
||||
{updateStatus}
|
||||
deleteSubmission={handleDeleteSubmission}
|
||||
bind:isGradeWithAI
|
||||
{isSaving}
|
||||
/>
|
||||
<svelte:head>
|
||||
<title>Submissions - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<CourseContainer courseId={data.courseId}>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
<PageNav title={$t('course.navItem.submissions.title')} />
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('course.navItem.submissions.title')}
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<PageBody width="w-full max-w-6xl md:w-11/12 overflow-x-auto">
|
||||
<div class="flex w-full items-center">
|
||||
{#each sections as { id, title, items }, idx (id)}
|
||||
<div
|
||||
class="section mr-3 h-80 overflow-hidden rounded-md border border-gray-50 bg-gray-100 p-3 dark:border-neutral-600 dark:bg-black"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<div class="mb-2 flex items-center">
|
||||
<Chip value={items.length} className="bg-set dark:bg-neutral-800" />
|
||||
<p class="ml-2 dark:text-white">{title}</p>
|
||||
</div>
|
||||
{#if fetching}
|
||||
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
|
||||
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
|
||||
<Skeleton class="my-2 h-[170px] w-full rounded-md" />
|
||||
{:else}
|
||||
<div
|
||||
class="content mb-3 overflow-y-auto pr-2"
|
||||
use:dndzone={{
|
||||
items,
|
||||
flipDurationMs,
|
||||
dropTargetStyle: { outline: 'blue' }
|
||||
}}
|
||||
onconsider={handleDndConsiderCards(idx)}
|
||||
onfinalize={handleDndFinalizeCards(idx)}
|
||||
>
|
||||
{#each items as item (item.id)}
|
||||
<div
|
||||
class="{item.isEarly
|
||||
? 'border-none'
|
||||
: 'border border-red-700'} mx-0 my-2 w-full rounded-md bg-white px-3 py-3 dark:bg-neutral-800"
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
<a
|
||||
class="mb-2 flex w-full cursor-pointer items-center text-black"
|
||||
href={`${page.url.pathname}?submissionId=${item.id}`}
|
||||
>
|
||||
<img alt="Student avatar" class="block h-6 w-6 rounded-full" src={item.student.avatar_url} />
|
||||
<p class="ml-2 text-sm dark:text-white">
|
||||
{item.student.username}
|
||||
</p>
|
||||
</a>
|
||||
<a class="text-primary-700 text-md" href="{page.url.pathname}?submissionId={item.id}">
|
||||
{item.exercise.title}
|
||||
</a>
|
||||
<a
|
||||
class="my-2 flex items-center text-black no-underline hover:underline"
|
||||
href="{page.url?.pathname?.replace('submissions', 'lessons')}/{item.lesson.id}/exercises/{item
|
||||
.exercise.id}"
|
||||
>
|
||||
<p class="text-grey text-sm dark:text-white">
|
||||
#{item.lesson.title}
|
||||
</p>
|
||||
</a>
|
||||
<p class="text-xs text-gray-500 dark:text-white">
|
||||
{item.submittedAt}
|
||||
</p>
|
||||
<!-- <div class="badge rounded-md px-2 bg-green-500 text-white">
|
||||
early
|
||||
</div>
|
||||
<div class="badge rounded-md px-2 bg-red-600 text-white">
|
||||
late
|
||||
</div> -->
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</PageBody>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<SubmissionsPage courseId={data.courseId} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</RoleBasedSecurity>
|
||||
</CourseContainer>
|
||||
|
||||
<style>
|
||||
.section {
|
||||
max-width: 355px;
|
||||
min-width: 355px;
|
||||
height: 75vh;
|
||||
}
|
||||
|
||||
.content {
|
||||
height: 95%;
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.section {
|
||||
min-width: 250px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user