feat: widgets and ai chat
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CourseComplianceOverview,
|
||||
ExtendCourseComplianceRequest,
|
||||
GetCourseComplianceOverviewRequest,
|
||||
GetLearnerComplianceHistoryRequest,
|
||||
LearnerComplianceHistory,
|
||||
ResetCourseComplianceRequest,
|
||||
WaiveCourseComplianceRequest
|
||||
} from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import {
|
||||
ZCourseComplianceExtend,
|
||||
ZCourseComplianceReset,
|
||||
ZCourseComplianceWaive,
|
||||
type TCourseComplianceExtend,
|
||||
type TCourseComplianceReset,
|
||||
type TCourseComplianceWaive
|
||||
} from '@cio/utils/validation/course';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export class ComplianceApi extends BaseApiWithErrors {
|
||||
overview = $state<CourseComplianceOverview | null>(null);
|
||||
learnerHistory = $state<LearnerComplianceHistory | null>(null);
|
||||
|
||||
private overviewKey = $state<string | null>(null);
|
||||
private learnerHistoryKey = $state<string | null>(null);
|
||||
|
||||
async getOverview(courseId: string) {
|
||||
const result = await this.execute<GetCourseComplianceOverviewRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.$get({
|
||||
param: { courseId }
|
||||
}),
|
||||
logContext: 'fetching course compliance overview',
|
||||
onSuccess: (response) => {
|
||||
this.overview = response.data;
|
||||
this.overviewKey = courseId;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.overview_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureOverview(courseId: string) {
|
||||
if (this.overviewKey === courseId && this.overview) {
|
||||
return this.overview;
|
||||
}
|
||||
|
||||
return this.getOverview(courseId);
|
||||
}
|
||||
|
||||
async getLearnerHistory(courseId: string, profileId: string) {
|
||||
const result = await this.execute<GetLearnerComplianceHistoryRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.learners[':profileId'].$get({
|
||||
param: { courseId, profileId }
|
||||
}),
|
||||
logContext: 'fetching learner compliance history',
|
||||
onSuccess: (response) => {
|
||||
this.learnerHistory = response.data;
|
||||
this.learnerHistoryKey = `${courseId}:${profileId}`;
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.history_failed');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result?.data;
|
||||
}
|
||||
|
||||
async ensureLearnerHistory(courseId: string, profileId: string) {
|
||||
const requestKey = `${courseId}:${profileId}`;
|
||||
if (this.learnerHistoryKey === requestKey && this.learnerHistory) {
|
||||
return this.learnerHistory;
|
||||
}
|
||||
|
||||
return this.getLearnerHistory(courseId, profileId);
|
||||
}
|
||||
|
||||
async resetCourseCompliance(courseId: string, fields: TCourseComplianceReset) {
|
||||
const result = ZCourseComplianceReset.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ResetCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.reset.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'resetting course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.reset_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.reset_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async extendCourseCompliance(courseId: string, fields: TCourseComplianceExtend) {
|
||||
const result = ZCourseComplianceExtend.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<ExtendCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.extend.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'extending course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.extend_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.extend_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
async waiveCourseCompliance(courseId: string, fields: TCourseComplianceWaive) {
|
||||
const result = ZCourseComplianceWaive.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'course');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await this.execute<WaiveCourseComplianceRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.course[':courseId'].compliance.waive.$post({
|
||||
param: { courseId },
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'waiving course compliance',
|
||||
onSuccess: () => {
|
||||
snackbar.success('snackbar.course.compliance.waive_success');
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.course.compliance.waive_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
if ('error' in result && 'field' in result && result.field) {
|
||||
this.errors[result.field] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (response?.data) {
|
||||
this.overviewKey = null;
|
||||
await this.getOverview(courseId);
|
||||
}
|
||||
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
override reset() {
|
||||
super.reset();
|
||||
this.overview = null;
|
||||
this.learnerHistory = null;
|
||||
this.overviewKey = null;
|
||||
this.learnerHistoryKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const complianceApi = new ComplianceApi();
|
||||
@@ -26,9 +26,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to update content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to update content');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -51,9 +50,8 @@ export class ContentApi extends BaseApiWithErrors {
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
if (typeof error === 'string') {
|
||||
snackbar.error('Failed to delete content');
|
||||
}
|
||||
const errorMessage = typeof error === 'string' ? error : (error as any)?.error;
|
||||
snackbar.error(errorMessage || 'Failed to delete content');
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
} from '../utils/types';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import { ZCourseCreate, ZCourseUpdate, type TCourseUpdate } from '@cio/utils/validation/course/course';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
@@ -357,7 +358,8 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
title: fields.title,
|
||||
description: fields.description,
|
||||
type: fields.type,
|
||||
organizationId: org.id
|
||||
organizationId: org.id,
|
||||
compliance: fields.type === 'COMPLIANCE' ? DEFAULT_COMPLIANCE_SETTINGS : undefined
|
||||
};
|
||||
|
||||
// Client-side validation
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './attendance.svelte';
|
||||
export * from './compliance.svelte';
|
||||
export * from './course-clone.svelte';
|
||||
export * from './course.svelte';
|
||||
export * from './courses.svelte';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import { copyCourseModal, deleteCourseModal } from '$features/course/utils/store';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -12,9 +13,10 @@
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isPublished?: boolean;
|
||||
}
|
||||
|
||||
let { id, title, description }: Props = $props();
|
||||
let { id, title, description, isPublished = false }: Props = $props();
|
||||
|
||||
function redirect(url: string) {
|
||||
goto(resolve(url, {}));
|
||||
@@ -40,6 +42,10 @@
|
||||
$copyCourseModal.title = title;
|
||||
$copyCourseModal.description = description;
|
||||
}
|
||||
|
||||
function handleOpenCourseSite() {
|
||||
window.open(`/courses/${id}`, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -52,6 +58,13 @@
|
||||
</IconButton>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if isPublished}
|
||||
<DropdownMenu.Item onclick={handleOpenCourseSite}>
|
||||
<ExternalLinkIcon size={16} class="mr-2" />
|
||||
{$t('courses.course_card.context_menu.open_course_site')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{/if}
|
||||
<DropdownMenu.Item onclick={handleCloneCourse}>
|
||||
{$t('courses.course_card.context_menu.clone')}
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { CourseCard, DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
@@ -17,6 +19,11 @@
|
||||
import { calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||
import { calcCourseProgress, calcProgressRate } from '$features/course/utils/functions';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
getStudentCourseComplianceStatusKey,
|
||||
getStudentCourseComplianceStatusVariant
|
||||
} from '$features/course/utils/compliance-utils';
|
||||
import CardDropdown from './card-dropdown.svelte';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
import CourseTagsOverflow from './course-tags-overflow.svelte';
|
||||
@@ -117,6 +124,12 @@
|
||||
icon: UserIcon,
|
||||
iconStyle: 'custom ui:text-primary'
|
||||
},
|
||||
['COMPLIANCE']: {
|
||||
style: '',
|
||||
label: $t('course.navItem.settings.compliance'),
|
||||
icon: ShieldCheckIcon,
|
||||
iconStyle: 'custom text-emerald-700'
|
||||
},
|
||||
SPECIALIZATION: {
|
||||
style: '',
|
||||
label: $t('specialization.course_tag'),
|
||||
@@ -126,9 +139,21 @@
|
||||
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived(
|
||||
href ?? (isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`)
|
||||
);
|
||||
let courseUrl = $derived.by(() => {
|
||||
if (href) {
|
||||
return href;
|
||||
}
|
||||
|
||||
if (isOnLandingPage || isExplore) {
|
||||
if (!slug) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `/course/${slug}`;
|
||||
}
|
||||
|
||||
return `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
|
||||
});
|
||||
|
||||
const typeBadge = $derived(
|
||||
type && COURSE_TAG[type]
|
||||
@@ -139,9 +164,46 @@
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
|
||||
const complianceStatusKey = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusKey(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
const complianceStatusVariant = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceStatusVariant(course as UserEnrolledCourses[number])
|
||||
: 'outline'
|
||||
);
|
||||
const complianceDate = $derived(
|
||||
isLMS && type === 'COMPLIANCE' && !isExplore
|
||||
? getStudentCourseComplianceDate(course as UserEnrolledCourses[number])
|
||||
: null
|
||||
);
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<CourseCard href={resolve(courseUrl, {})} {title} {description} {typeBadge} class="group relative">
|
||||
<CourseCard
|
||||
href={courseUrl ? resolve(courseUrl, {}) : undefined}
|
||||
{title}
|
||||
{description}
|
||||
{typeBadge}
|
||||
class="group relative"
|
||||
>
|
||||
{#snippet media()}
|
||||
<Image src={bannerImage} alt="Course banner image" className="w-full h-full rounded-sm object-cover" />
|
||||
{/snippet}
|
||||
@@ -150,7 +212,7 @@
|
||||
{#if actions}
|
||||
{@render actions()}
|
||||
{:else if !isLMS && !isOnLandingPage}
|
||||
<CardDropdown {id} {title} {description} />
|
||||
<CardDropdown {id} {title} {description} {isPublished} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -195,6 +257,22 @@
|
||||
<Progress value={progressRate} />
|
||||
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
|
||||
</div>
|
||||
|
||||
{#if type === 'COMPLIANCE'}
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
{#if complianceStatusKey}
|
||||
<Badge variant={complianceStatusVariant}>
|
||||
{$t(complianceStatusKey)}
|
||||
</Badge>
|
||||
{/if}
|
||||
|
||||
{#if complianceDate?.value}
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t(complianceDate.labelKey)}: {formatDate(complianceDate.value)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<CoursePublishBadge {isPublished} />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type CourseSortOrder
|
||||
} from '../utils/constants';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { SortPopover } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
slug: string;
|
||||
name: string;
|
||||
color: string;
|
||||
courseCount: number;
|
||||
courseCount?: number;
|
||||
}
|
||||
|
||||
interface FilterTagGroup {
|
||||
@@ -34,8 +35,14 @@
|
||||
tagGroups?: FilterTagGroup[];
|
||||
sortOptions?: readonly SortOption[];
|
||||
isFiltering?: boolean;
|
||||
hasActiveFilters?: boolean;
|
||||
publishedStatus?: 'all' | 'published' | 'unpublished';
|
||||
courseType?: string;
|
||||
courseTypeOptions?: { value: string; label: string }[];
|
||||
onToggleTag?: (tagSlug: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
onPublishedStatusChange?: (status: 'all' | 'published' | 'unpublished') => void;
|
||||
onCourseTypeChange?: (type: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -45,8 +52,14 @@
|
||||
tagGroups = [],
|
||||
sortOptions = COURSE_SORT_OPTIONS,
|
||||
isFiltering = false,
|
||||
hasActiveFilters: hasActiveFiltersOverride = undefined,
|
||||
publishedStatus = $bindable('all'),
|
||||
courseType = $bindable('all'),
|
||||
courseTypeOptions = [],
|
||||
onToggleTag = () => {},
|
||||
onClearFilters = () => {}
|
||||
onClearFilters = () => {},
|
||||
onPublishedStatusChange,
|
||||
onCourseTypeChange
|
||||
}: Props = $props();
|
||||
|
||||
const translatedSortOptions = $derived(
|
||||
@@ -54,12 +67,28 @@
|
||||
);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COURSE_SORT || selectedOrder !== DEFAULT_SORT_ORDER || selectedTags.length > 0
|
||||
hasActiveFiltersOverride !== undefined
|
||||
? hasActiveFiltersOverride
|
||||
: sortKey !== DEFAULT_COURSE_SORT ||
|
||||
selectedOrder !== DEFAULT_SORT_ORDER ||
|
||||
selectedTags.length > 0 ||
|
||||
publishedStatus !== 'all' ||
|
||||
courseType !== 'all'
|
||||
);
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
}
|
||||
|
||||
function setPublishedStatus(status: 'all' | 'published' | 'unpublished') {
|
||||
publishedStatus = status;
|
||||
onPublishedStatusChange?.(status);
|
||||
}
|
||||
|
||||
function setCourseType(type: string) {
|
||||
courseType = type;
|
||||
onCourseTypeChange?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<SortPopover
|
||||
@@ -74,45 +103,95 @@
|
||||
>
|
||||
{#snippet additionalContent()}
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
{#if tagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{:else}
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.status')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'published' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('published')}>{$t('widgets.status.published')}</Button
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={publishedStatus === 'unpublished' ? 'secondary' : 'outline'}
|
||||
onclick={() => setPublishedStatus('unpublished')}>{$t('widgets.filter.unpublished')}</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if courseTypeOptions.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('widgets.filter.course_type')}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === 'all' ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType('all')}>{$t('widgets.filter.all')}</Button
|
||||
>
|
||||
{#each courseTypeOptions as opt (opt.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={courseType === opt.value ? 'secondary' : 'outline'}
|
||||
onclick={() => setCourseType(opt.value)}>{opt.label}</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if tagGroups.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">{$t('courses.tag_filters.tags')}</p>
|
||||
<div class="max-h-[320px] space-y-4 overflow-y-auto pr-1">
|
||||
{#each tagGroups as group (group.id)}
|
||||
{#if group.tags.length > 0}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-medium">{group.name}</p>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => onToggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
{#if tag.courseCount}
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
{/if}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else if tagGroups.length === 0 && courseTypeOptions.length === 0 && publishedStatus === undefined}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('courses.tag_filters.empty_tags')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -10,10 +10,13 @@
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import { toggleAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import CoursePublishBadge from './course-publish-badge.svelte';
|
||||
|
||||
const siteName = $derived($currentOrg.siteName);
|
||||
@@ -56,6 +59,13 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
{#if !$isFreePlan}
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
</Button>
|
||||
{/if}
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
import OrderModal from './order-modal.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import DeleteConfirmationModal from './delete-confirmation.svelte';
|
||||
@@ -57,7 +56,6 @@
|
||||
}: Props = $props();
|
||||
|
||||
let questionIdToDelete = $state(null);
|
||||
let isDeleting = $state(false);
|
||||
let errors = $derived($questionnaireValidation);
|
||||
const questionLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
@@ -181,54 +179,12 @@
|
||||
return { ...q, questions: next };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<DeleteConfirmationModal onCancel={() => (questionIdToDelete = null)} onDelete={onFinalDeleteClicked} />
|
||||
|
||||
<OrderModal />
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDelete} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="mb-20 w-full">
|
||||
{#if Object.values(errors).length}
|
||||
<div class="mb-4 flex w-full justify-center">
|
||||
|
||||
@@ -29,10 +29,12 @@
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { toggleConfetti } from '$features/ui/confetti/store';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { isOrgStudent } from '$lib/utils/store/app';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
import axios from 'axios';
|
||||
@@ -152,6 +154,9 @@
|
||||
}
|
||||
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
|
||||
|
||||
toggleConfetti();
|
||||
setTimeout(toggleConfetti, 3000);
|
||||
|
||||
const allContentItems = getOrderedNavigableContent(courseApi.course);
|
||||
const allComplete =
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
@@ -573,7 +578,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if courseApi.course?.type === 'SELF_PACED'}
|
||||
{:else if isSelfPacedLikeCourse(courseApi.course?.type)}
|
||||
<Badge
|
||||
variant="success"
|
||||
title={$t('course.navItem.lessons.exercises.all_exercises.view_mode.status_submitted')}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import ContentRow from './content-row.svelte';
|
||||
import ContentActions from './content-actions.svelte';
|
||||
import { deleteContent, saveContent, toggleLock } from './content-action-helpers';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
|
||||
const flipDurationMs = 300;
|
||||
|
||||
@@ -45,7 +46,7 @@
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const isStudentView = $derived($isOrgStudent === true);
|
||||
const isLiveCourse = $derived(courseApi.course?.type === 'LIVE_CLASS');
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const metaChipClass =
|
||||
'inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-1 text-[10px] text-gray-700 dark:bg-neutral-700 dark:text-gray-200';
|
||||
|
||||
@@ -262,7 +263,7 @@
|
||||
/>
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
class="mx-auto w-full p-3 lg:w-11/12 {reorder ? 'cursor-grab active:cursor-grabbing' : ''}"
|
||||
use:dndzone={{
|
||||
items: contentItems,
|
||||
flipDurationMs,
|
||||
|
||||
+1
-3
@@ -63,9 +63,7 @@
|
||||
const showMarkComplete = $derived(!!lessonId && !exerciseId);
|
||||
|
||||
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
|
||||
const isLessonLocked = $derived(
|
||||
$isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
|
||||
);
|
||||
const isLessonLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
|
||||
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
|
||||
|
||||
async function markLessonComplete(currentLessonId: string) {
|
||||
|
||||
+5
-3
@@ -283,6 +283,7 @@
|
||||
const wasDeleted = await contentApi.deleteContent(courseId, { sectionId: deletingData.id });
|
||||
if (wasDeleted) {
|
||||
removeSection(deletingData.id);
|
||||
snackbar.success('snackbar.lessons.section_deleted');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -438,7 +439,7 @@
|
||||
<DeleteLessonConfirmation bind:openDeleteModal {deleteLesson} />
|
||||
|
||||
<section
|
||||
class="mx-auto w-full p-3 lg:w-11/12 lg:px-4"
|
||||
class="mx-auto w-full p-3 lg:w-11/12"
|
||||
use:dndzone={{
|
||||
items: sections,
|
||||
flipDurationMs,
|
||||
@@ -462,7 +463,8 @@
|
||||
: `${$t('course.navItem.lessons.add_lesson.unlock')} ${$t('course.navItem.lessons.add_lesson.all')}`
|
||||
: undefined}
|
||||
<div
|
||||
class="border-border m-auto mb-3 max-w-xl rounded-md border transition dark:bg-neutral-800"
|
||||
id={isUngrouped ? undefined : `section-${section.id}`}
|
||||
class="border-border m-auto mb-3 max-w-xl scroll-mt-24 rounded-md border transition dark:bg-neutral-800"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
>
|
||||
<SectionHeader
|
||||
@@ -518,7 +520,7 @@
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
<div
|
||||
class="mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md border border-gray-200 px-3 py-1 transition"
|
||||
class="ui:border mb-2 flex min-h-[50px] max-w-xl items-start justify-between rounded-md px-3 py-1 transition"
|
||||
style={reorder ? 'border: 2px #1d4ed8 solid; border-style: dashed; cursor: grab;' : ''}
|
||||
animate:flip={{ duration: flipDurationMs }}
|
||||
>
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
{:else}
|
||||
<div class="flex w-4/6 items-center gap-2">
|
||||
<CourseContentIcon type={ContentType.Section} size={16} />
|
||||
<p class="font-semibold">{section.title}</p>
|
||||
<p class="text-sm">{section.title}</p>
|
||||
<ContentCountBadges lessons={sectionCount.lessons} exercises={sectionCount.exercises} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
subtitle: $t('new_course_modal.self_paced_subtitle'),
|
||||
type: 'SELF_PACED' as TCourseType,
|
||||
isDisabled: false
|
||||
},
|
||||
{
|
||||
id: 'compliance',
|
||||
title: $t('new_course_modal.compliance_label'),
|
||||
subtitle: $t('new_course_modal.compliance_subtitle'),
|
||||
type: 'COMPLIANCE' as TCourseType,
|
||||
isDisabled: false
|
||||
}
|
||||
];
|
||||
const courseTypeOptionsForGroup = options.map((o) => ({
|
||||
@@ -68,7 +75,7 @@
|
||||
{#snippet course_type_selector()}
|
||||
<Field.Description>{$t('courses.new_course_modal.type_selector_title')}</Field.Description>
|
||||
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-2" />
|
||||
<RadioOptionCardGroup options={courseTypeOptionsForGroup} bind:value={type} class="grid-cols-1 md:grid-cols-3" />
|
||||
{/snippet}
|
||||
|
||||
<Dialog.Root
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
id={feed.id}
|
||||
class="flex flex-col justify-between gap-2 {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'border border-gray-200'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
: 'ui:border'} mb-7 w-[90%] rounded-md md:max-w-3xl"
|
||||
>
|
||||
<Header {feed} {onPin} onEdit={openEditFeed} onRequestDelete={() => (isDeleteFeedModal = true)} />
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="border-t border-gray-200 p-3">
|
||||
<section class="ui:border-t p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -201,7 +201,7 @@
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('course.navItem.news_feed.comments.placeholder')}
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
class="ui:border w-full rounded-3xl bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
/>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
|
||||
import type { Feed } from '$features/course/utils/types';
|
||||
import { HTMLRender, RoleBasedSecurity } from '$features/ui';
|
||||
@@ -25,19 +24,7 @@
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<Avatar.Root class="h-full w-full">
|
||||
<Avatar.Image
|
||||
src={feed.authorAvatarUrl ?? ''}
|
||||
alt={$t('course.navItem.news_feed.user_avatar_alt')}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<Avatar.Fallback aria-hidden="true">
|
||||
<UserIcon class="text-muted-foreground h-5 w-5" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
</div>
|
||||
<UserAvatar src={feed.authorAvatarUrl} />
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md border border-gray-300 p-3">
|
||||
<div class="ui:border mb-7 flex w-full max-w-3xl flex-col gap-3 rounded-md p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Skeleton class="h-8 w-8 overflow-hidden rounded-full" />
|
||||
<Skeleton class="h-4 w-[30%]" />
|
||||
|
||||
@@ -191,7 +191,7 @@
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-3">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
@@ -208,13 +208,14 @@
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="mt-6 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
@@ -4,10 +4,17 @@ export const NAV_IDS = {
|
||||
ATTENDANCE: 'ATTENDANCE',
|
||||
SUBMISSIONS: 'SUBMISSIONS',
|
||||
MARKS: 'MARKS',
|
||||
COMPLIANCE: 'COMPLIANCE',
|
||||
CERTIFICATES: 'CERTIFICATES',
|
||||
LANDING_PAGE: 'LANDING_PAGE',
|
||||
PEOPLE: 'PEOPLE',
|
||||
ANALYTICS: 'ANALYTICS',
|
||||
SETTINGS: 'SETTINGS',
|
||||
SECTION: 'SECTION'
|
||||
SECTION: 'SECTION',
|
||||
AI_ASSISTANT: 'AI_ASSISTANT'
|
||||
};
|
||||
|
||||
export const COURSE_SIDEBAR_DEFAULT_WIDTH = 256;
|
||||
export const COURSE_SIDEBAR_MIN_WIDTH = 220;
|
||||
export const COURSE_SIDEBAR_MAX_WIDTH = 420;
|
||||
export const COURSE_SIDEBAR_STORAGE_KEY = 'classroomio-course-sidebar-width';
|
||||
|
||||
+90
-1
@@ -2,6 +2,7 @@
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { BackButton } from '@cio/ui';
|
||||
@@ -27,7 +28,7 @@
|
||||
import ContentCountBadges from '../content-count-badges.svelte';
|
||||
|
||||
import { NAV_IDS } from './constants';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
@@ -35,6 +36,7 @@
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
@@ -61,6 +63,7 @@
|
||||
};
|
||||
});
|
||||
const showContentCount = $derived(sidebar.open && !sidebar.isMobile && contentCount.total > 0);
|
||||
const studentComplianceRecord = $derived(complianceApi.learnerHistory?.currentRecord ?? null);
|
||||
|
||||
const navItems = $derived(
|
||||
[
|
||||
@@ -130,6 +133,16 @@
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.MARKS)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.COMPLIANCE,
|
||||
title: $t('course.navItems.nav_compliance'),
|
||||
url: getNavItemRoute(id, 'compliance'),
|
||||
isActive: (path || page.url.pathname) === getNavItemRoute(id, 'compliance'),
|
||||
show() {
|
||||
return courseApi.course?.type === 'COMPLIANCE';
|
||||
},
|
||||
icon: getNavIcon(NAV_IDS.COMPLIANCE)
|
||||
},
|
||||
{
|
||||
id: NAV_IDS.CERTIFICATES,
|
||||
title: $t('course.navItems.nav_certificates'),
|
||||
@@ -176,6 +189,14 @@
|
||||
].filter((item) => !item.show || item.show())
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!isStudent || courseApi.course?.type !== 'COMPLIANCE' || !courseApi.course?.id || !$profile.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
void complianceApi.ensureLearnerHistory(courseApi.course.id, $profile.id);
|
||||
});
|
||||
|
||||
function openContentModal(courseId: string, sectionId = '') {
|
||||
goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
contentEditingStore.set(undefined);
|
||||
@@ -213,6 +234,8 @@
|
||||
return SubmissionIcon;
|
||||
} else if (id === NAV_IDS.MARKS) {
|
||||
return MarksIcon;
|
||||
} else if (id === NAV_IDS.COMPLIANCE) {
|
||||
return CertificateIcon;
|
||||
} else if (id === NAV_IDS.PEOPLE) {
|
||||
return PeopleIcon;
|
||||
} else if (id === NAV_IDS.ANALYTICS) {
|
||||
@@ -227,11 +250,77 @@
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStatusVariant(status: string | null | undefined) {
|
||||
if (status === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'expiring_soon' || status === 'in_progress' || status === 'in_grace_period') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (status === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium'
|
||||
}).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton href={resolve(coursesListPath, {})} label={$t('org_navigation.courses')} class="px-2! py-2!" />
|
||||
|
||||
{#if isStudent && courseApi.course?.type === 'COMPLIANCE'}
|
||||
<div
|
||||
class="mx-2 mb-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 dark:border-emerald-900/50 dark:bg-emerald-950/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-xs font-semibold tracking-wide text-emerald-900 uppercase dark:text-emerald-100">
|
||||
{$t('course.sidebar.compliance.title')}
|
||||
</p>
|
||||
{#if studentComplianceRecord?.status}
|
||||
<Badge variant={getStatusVariant(studentComplianceRecord.status)}>
|
||||
{$t(`course.navItem.compliance.status.${studentComplianceRecord.status}`)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-1 text-xs text-emerald-900 dark:text-emerald-100">
|
||||
{#if studentComplianceRecord?.dueDate}
|
||||
<p>
|
||||
{$t('course.sidebar.compliance.due_date')}: {formatDate(studentComplianceRecord.dueDate)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if studentComplianceRecord?.validUntil}
|
||||
<p>
|
||||
{$t('course.sidebar.compliance.valid_until')}: {formatDate(studentComplianceRecord.validUntil)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
{#if item.isLesson}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
@@ -12,6 +13,8 @@
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import UpgradePoweredBy from '$features/ui/upgrade-powered-by.svelte';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import { startResizablePanelDrag } from '$lib/utils/functions/resizable-panel';
|
||||
import { COURSE_SIDEBAR_DEFAULT_WIDTH, COURSE_SIDEBAR_MAX_WIDTH, COURSE_SIDEBAR_MIN_WIDTH } from './constants';
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
|
||||
@@ -23,12 +26,86 @@
|
||||
id: string;
|
||||
/** If false, sidebar nav shows loading skeletons. Omit or true = show real nav. */
|
||||
isCourseReady?: boolean;
|
||||
sidebarWidth?: number;
|
||||
onSidebarWidthPreview?: (width: number) => void;
|
||||
onSidebarWidthChange?: (width: number) => void;
|
||||
}
|
||||
|
||||
let { path, id, isCourseReady = true }: Props = $props();
|
||||
let {
|
||||
path,
|
||||
id,
|
||||
isCourseReady = true,
|
||||
sidebarWidth = COURSE_SIDEBAR_DEFAULT_WIDTH,
|
||||
onSidebarWidthPreview = () => {},
|
||||
onSidebarWidthChange = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
const sidebar = useSidebar();
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
let shouldIgnoreRailClick = $state(false);
|
||||
let stopSidebarResize: (() => void) | null = null;
|
||||
|
||||
function clampSidebarWidth(width: number) {
|
||||
return Math.min(COURSE_SIDEBAR_MAX_WIDTH, Math.max(COURSE_SIDEBAR_MIN_WIDTH, width));
|
||||
}
|
||||
|
||||
function clearSidebarResizeListeners() {
|
||||
stopSidebarResize?.();
|
||||
stopSidebarResize = null;
|
||||
}
|
||||
|
||||
function handleRailPointerDown(event: PointerEvent) {
|
||||
if (sidebar.isMobile || !sidebar.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
clearSidebarResizeListeners();
|
||||
|
||||
const startWidth = sidebarWidth;
|
||||
|
||||
stopSidebarResize = startResizablePanelDrag({
|
||||
event,
|
||||
startWidth,
|
||||
resolveWidth: ({ startWidth, deltaX }) => clampSidebarWidth(startWidth + deltaX),
|
||||
onPreview: onSidebarWidthPreview,
|
||||
onCommit: ({ hasDragged, width }) => {
|
||||
if (hasDragged) {
|
||||
onSidebarWidthChange(width);
|
||||
}
|
||||
},
|
||||
onDragStart: () => {
|
||||
document.body.dataset.courseSidebarResizing = 'true';
|
||||
},
|
||||
onDragEnd: ({ hasDragged }) => {
|
||||
shouldIgnoreRailClick = hasDragged;
|
||||
delete document.body.dataset.courseSidebarResizing;
|
||||
stopSidebarResize = null;
|
||||
|
||||
if (hasDragged) {
|
||||
window.setTimeout(() => {
|
||||
shouldIgnoreRailClick = false;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRailClick(event: MouseEvent) {
|
||||
if (shouldIgnoreRailClick) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
sidebar.toggle();
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
clearSidebarResizeListeners();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
@@ -62,7 +139,7 @@
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Rail />
|
||||
<Sidebar.Rail onclick={handleRailClick} onpointerdown={handleRailPointerDown} />
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Footer>
|
||||
@@ -74,3 +151,10 @@
|
||||
{/if}
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-gap']),
|
||||
:global(body[data-course-sidebar-resizing='true'] [data-slot='sidebar-container']) {
|
||||
transition: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,641 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as Tabs from '@cio/ui/base/tabs';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { CourseComplianceLearner, LearnerComplianceRecord } from '$features/course/utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
type ComplianceStatus = CourseComplianceLearner['status'] | LearnerComplianceRecord['status'] | 'no_record';
|
||||
type StaffAction = 'reset' | 'extend' | 'waive' | null;
|
||||
|
||||
let lastRequestKey = $state<string | null>(null);
|
||||
let staffTab = $state<'overview' | 'learners'>('overview');
|
||||
let activeAction = $state<StaffAction>(null);
|
||||
let bulkActionType = $state<Exclude<StaffAction, null> | null>(null);
|
||||
let isBulkActionModalOpen = $state(false);
|
||||
let actionFields = $state({
|
||||
dueDate: '',
|
||||
waiverReason: '',
|
||||
waiverExpiresAt: ''
|
||||
});
|
||||
|
||||
let selectedLearnerIds = new SvelteSet<string>();
|
||||
|
||||
const currentMember = $derived(courseApi.group.people.find((person) => person.profileId === $profile.id));
|
||||
const currentRoleId = $derived(currentMember ? Number(currentMember.roleId) : null);
|
||||
const isComplianceCourse = $derived(courseApi.course?.type === 'COMPLIANCE');
|
||||
const isStudent = $derived(currentRoleId === 3);
|
||||
const overview = $derived(complianceApi.overview);
|
||||
const learnerHistory = $derived(complianceApi.learnerHistory);
|
||||
const historyRecords = $derived(learnerHistory?.history ?? []);
|
||||
const currentRecord = $derived(learnerHistory?.currentRecord ?? null);
|
||||
const learnerRows = $derived(overview?.learners ?? []);
|
||||
const selectedProfileIds = $derived(Array.from(selectedLearnerIds));
|
||||
const allVisibleSelected = $derived(
|
||||
learnerRows.length > 0 &&
|
||||
learnerRows.every((learner) => (learner.profileId ? selectedLearnerIds.has(learner.profileId) : false))
|
||||
);
|
||||
const someVisibleSelected = $derived(
|
||||
learnerRows.some((learner) => learner.profileId && selectedLearnerIds.has(learner.profileId))
|
||||
);
|
||||
const summaryCards = $derived.by(() => {
|
||||
if (!overview) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{ labelKey: 'course.navItem.compliance.summary.total_learners', value: overview.summary.totalLearners },
|
||||
{ labelKey: 'course.navItem.compliance.summary.no_record', value: overview.summary.noRecord },
|
||||
{ labelKey: 'course.navItem.compliance.summary.not_started', value: overview.summary.notStarted },
|
||||
{ labelKey: 'course.navItem.compliance.summary.in_progress', value: overview.summary.inProgress },
|
||||
{ labelKey: 'course.navItem.compliance.summary.compliant', value: overview.summary.compliant },
|
||||
{ labelKey: 'course.navItem.compliance.summary.expiring_soon', value: overview.summary.expiringSoon },
|
||||
{ labelKey: 'course.navItem.compliance.summary.in_grace_period', value: overview.summary.inGracePeriod },
|
||||
{ labelKey: 'course.navItem.compliance.summary.non_compliant', value: overview.summary.nonCompliant },
|
||||
{ labelKey: 'course.navItem.compliance.summary.waived', value: overview.summary.waived }
|
||||
];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!courseApi.course?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isComplianceCourse) {
|
||||
complianceApi.reset();
|
||||
lastRequestKey = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const courseId = courseApi.course.id;
|
||||
if (isStudent && currentMember?.profileId) {
|
||||
const requestKey = `${courseId}:${currentMember.profileId}:learner`;
|
||||
if (lastRequestKey === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestKey = requestKey;
|
||||
void complianceApi.ensureLearnerHistory(courseId, currentMember.profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestKey = `${courseId}:overview`;
|
||||
if (lastRequestKey === requestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestKey = requestKey;
|
||||
void complianceApi.ensureOverview(courseId);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const deadline = courseApi.course?.certificate?.deadline;
|
||||
if (!deadline || actionFields.dueDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const localDeadline = toLocalDateTimeInput(deadline);
|
||||
if (!localDeadline) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionFields.dueDate = localDeadline;
|
||||
});
|
||||
|
||||
function formatDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatPercent(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return `${value}%`;
|
||||
}
|
||||
|
||||
function formatCount(value: number | null | undefined) {
|
||||
if (typeof value !== 'number') {
|
||||
return t.get('course.navItem.compliance.not_available');
|
||||
}
|
||||
|
||||
return `${value}`;
|
||||
}
|
||||
|
||||
function toLocalDateTimeInput(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const timezoneOffsetMilliseconds = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - timezoneOffsetMilliseconds).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function toIsoDateTime(value: string) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: ComplianceStatus) {
|
||||
return `course.navItem.compliance.status.${status}`;
|
||||
}
|
||||
|
||||
function getStatusVariant(status: ComplianceStatus) {
|
||||
if (status === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'expiring_soon' || status === 'in_progress' || status === 'in_grace_period') {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (status === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (status === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function toggleLearner(profileId: string) {
|
||||
if (selectedLearnerIds.has(profileId)) {
|
||||
selectedLearnerIds.delete(profileId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedLearnerIds.add(profileId);
|
||||
}
|
||||
|
||||
function toggleAllVisibleLearners() {
|
||||
if (allVisibleSelected) {
|
||||
for (const learner of learnerRows) {
|
||||
if (!learner.profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedLearnerIds.delete(learner.profileId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (const learner of learnerRows) {
|
||||
if (!learner.profileId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
selectedLearnerIds.add(learner.profileId);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedLearnerIds.clear();
|
||||
}
|
||||
|
||||
function resetActionFields() {
|
||||
actionFields.dueDate = '';
|
||||
actionFields.waiverReason = '';
|
||||
actionFields.waiverExpiresAt = '';
|
||||
}
|
||||
|
||||
function openBulkActionModal(action: Exclude<StaffAction, null>) {
|
||||
bulkActionType = action;
|
||||
complianceApi.resetErrors();
|
||||
isBulkActionModalOpen = true;
|
||||
}
|
||||
|
||||
function handleBulkActionModalChange(isOpen: boolean) {
|
||||
isBulkActionModalOpen = isOpen;
|
||||
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
bulkActionType = null;
|
||||
complianceApi.resetErrors();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
function closeBulkActionModal() {
|
||||
handleBulkActionModalChange(false);
|
||||
}
|
||||
|
||||
function getBulkActionTitle() {
|
||||
if (bulkActionType === 'reset') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.reset_action');
|
||||
}
|
||||
|
||||
if (bulkActionType === 'extend') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.extend_action');
|
||||
}
|
||||
|
||||
if (bulkActionType === 'waive') {
|
||||
return t.get('course.navItem.compliance.bulk_actions.waive_action');
|
||||
}
|
||||
|
||||
return t.get('course.navItem.compliance.bulk_actions.title');
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
const courseId = courseApi.course?.id;
|
||||
const dueDate = toIsoDateTime(actionFields.dueDate);
|
||||
if (!courseId || selectedProfileIds.length === 0 || !dueDate) {
|
||||
snackbar.error('snackbar.course.compliance.reset_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'reset';
|
||||
const response = await complianceApi.resetCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
dueDate
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
async function handleExtend() {
|
||||
const courseId = courseApi.course?.id;
|
||||
const dueDate = toIsoDateTime(actionFields.dueDate);
|
||||
if (!courseId || selectedProfileIds.length === 0 || !dueDate) {
|
||||
snackbar.error('snackbar.course.compliance.extend_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'extend';
|
||||
const response = await complianceApi.extendCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
dueDate
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
|
||||
async function handleWaive() {
|
||||
const courseId = courseApi.course?.id;
|
||||
if (!courseId || selectedProfileIds.length === 0) {
|
||||
snackbar.error('snackbar.course.compliance.waive_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
activeAction = 'waive';
|
||||
const response = await complianceApi.waiveCourseCompliance(courseId, {
|
||||
profileIds: selectedProfileIds,
|
||||
waiverReason: actionFields.waiverReason.trim() || undefined,
|
||||
waiverExpiresAt: toIsoDateTime(actionFields.waiverExpiresAt) ?? undefined
|
||||
});
|
||||
activeAction = null;
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearSelection();
|
||||
closeBulkActionModal();
|
||||
resetActionFields();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !isComplianceCourse}
|
||||
<Empty
|
||||
title={$t('course.navItem.compliance.not_compliance_title')}
|
||||
description={$t('course.navItem.compliance.not_compliance_description')}
|
||||
icon={ShieldCheckIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else if complianceApi.isLoading && !overview && !learnerHistory}
|
||||
<Empty title={$t('course.navItem.compliance.loading')} icon={ShieldCheckIcon} variant="page" />
|
||||
{:else if isStudent}
|
||||
<Field.Group class="space-y-8">
|
||||
<Field.Set class="gap-4!">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<Field.Legend>{$t('course.navItem.compliance.current_cycle')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.compliance.current_cycle_description')}</Field.Description>
|
||||
</div>
|
||||
{#if currentRecord}
|
||||
<Badge variant={getStatusVariant(currentRecord.status)}>
|
||||
{$t(getStatusLabelKey(currentRecord.status))}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if currentRecord}
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.cycle')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{currentRecord.cycleNumber}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.due_date')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.dueDate)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.completed_at')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.completedAt)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.valid_until')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatDate(currentRecord.validUntil)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.score')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatPercent(currentRecord.score)}</p>
|
||||
</div>
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{$t('course.navItem.compliance.fields.attempts')}
|
||||
</p>
|
||||
<p class="mt-2 text-lg font-semibold">{formatCount(currentRecord.attempts)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('course.navItem.compliance.no_active_cycle')}
|
||||
description={$t('course.navItem.compliance.empty_history')}
|
||||
icon={ShieldCheckIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set class="gap-4!">
|
||||
<Field.Legend>{$t('course.navItem.compliance.history')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.compliance.history_description')}</Field.Description>
|
||||
|
||||
{#if historyRecords.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.cycle')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.status')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.due_date')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.completed_at')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.valid_until')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.score')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each historyRecords as record}
|
||||
<Table.Row>
|
||||
<Table.Cell>{record.cycleNumber}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getStatusVariant(record.status)}>
|
||||
{$t(getStatusLabelKey(record.status))}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.dueDate)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.completedAt)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(record.validUntil)}</Table.Cell>
|
||||
<Table.Cell>{formatPercent(record.score)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_history')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
{:else if overview}
|
||||
<Tabs.Root bind:value={staffTab} class="w-full">
|
||||
<Tabs.List class="inline-flex w-auto">
|
||||
<Tabs.Trigger value="overview">{$t('course.navItem.compliance.tabs.overview')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="learners">{$t('course.navItem.compliance.tabs.learners')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="overview" class="mt-4">
|
||||
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each summaryCards as card}
|
||||
<div class="ui:bg-muted/40 rounded-lg p-4">
|
||||
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">{$t(card.labelKey)}</p>
|
||||
<p class="mt-2 text-lg font-semibold">{card.value}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="learners" class="mt-4 space-y-4">
|
||||
<div class="ui:bg-muted/40 flex flex-wrap items-center justify-between gap-3 rounded-lg p-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
indeterminate={someVisibleSelected && !allVisibleSelected}
|
||||
onclick={toggleAllVisibleLearners}
|
||||
/>
|
||||
<span class="text-sm font-medium">{$t('course.navItem.compliance.bulk_actions.select_all')}</span>
|
||||
</div>
|
||||
|
||||
<Badge variant="outline">
|
||||
{$t('course.navItem.compliance.bulk_actions.selected_count', { count: selectedProfileIds.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" onclick={clearSelection} disabled={selectedProfileIds.length === 0}>
|
||||
{$t('course.navItem.compliance.bulk_actions.clear_selection')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('reset')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.reset_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('extend')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.extend_action')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onclick={() => openBulkActionModal('waive')}
|
||||
disabled={selectedProfileIds.length === 0}
|
||||
>
|
||||
{$t('course.navItem.compliance.bulk_actions.waive_action')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if learnerRows.length > 0}
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-12">
|
||||
<span class="sr-only">{$t('course.navItem.compliance.bulk_actions.select_all')}</span>
|
||||
</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.learner')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.email')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.status')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.due_date')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.valid_until')}</Table.Head>
|
||||
<Table.Head>{$t('course.navItem.compliance.fields.score')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each learnerRows as learner}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
{#if learner.profileId}
|
||||
<Checkbox
|
||||
checked={selectedLearnerIds.has(learner.profileId)}
|
||||
onclick={() => toggleLearner(learner.profileId!)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
>{learner.fullname || learner.username || $t('course.navItem.compliance.not_available')}</Table.Cell
|
||||
>
|
||||
<Table.Cell>{learner.email || $t('course.navItem.compliance.not_available')}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={getStatusVariant(learner.status ?? 'no_record')}>
|
||||
{$t(getStatusLabelKey(learner.status ?? 'no_record'))}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDate(learner.dueDate)}</Table.Cell>
|
||||
<Table.Cell>{formatDate(learner.validUntil)}</Table.Cell>
|
||||
<Table.Cell>{formatPercent(learner.score)}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{:else}
|
||||
<Empty title={$t('course.navItem.compliance.empty_overview')} icon={ShieldCheckIcon} variant="page" />
|
||||
{/if}
|
||||
|
||||
<Dialog.Root bind:open={isBulkActionModalOpen} onOpenChange={handleBulkActionModalChange}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{getBulkActionTitle()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.description', { count: selectedProfileIds.length })}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="py-2">
|
||||
{#if bulkActionType === 'reset' || bulkActionType === 'extend'}
|
||||
<InputField
|
||||
label={$t('course.navItem.compliance.bulk_actions.due_date_label')}
|
||||
type="datetime-local"
|
||||
bind:value={actionFields.dueDate}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.due_date_helper')}
|
||||
errorMessage={complianceApi.errors.dueDate}
|
||||
autoFocus
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if bulkActionType === 'waive'}
|
||||
<div class="space-y-4">
|
||||
<TextareaField
|
||||
label={$t('course.navItem.compliance.bulk_actions.waiver_reason_label')}
|
||||
bind:value={actionFields.waiverReason}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.waiver_reason_helper')}
|
||||
errorMessage={complianceApi.errors.waiverReason}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label={$t('course.navItem.compliance.bulk_actions.waiver_expires_at_label')}
|
||||
type="datetime-local"
|
||||
bind:value={actionFields.waiverExpiresAt}
|
||||
helperMessage={$t('course.navItem.compliance.bulk_actions.waiver_expires_at_helper')}
|
||||
errorMessage={complianceApi.errors.waiverExpiresAt}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeBulkActionModal}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.cancel')}
|
||||
</Button>
|
||||
|
||||
{#if bulkActionType === 'reset'}
|
||||
<Button onclick={handleReset} loading={activeAction === 'reset'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{:else if bulkActionType === 'extend'}
|
||||
<Button onclick={handleExtend} loading={activeAction === 'extend'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{:else if bulkActionType === 'waive'}
|
||||
<Button onclick={handleWaive} loading={activeAction === 'waive'} disabled={complianceApi.isLoading}>
|
||||
{$t('course.navItem.compliance.bulk_actions.modal.apply')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -37,7 +37,9 @@
|
||||
import UpdateDescription from '$features/course/components/exercise/update-description.svelte';
|
||||
import { ContentNavigationActions } from '$features/course/components/lesson';
|
||||
import { RefreshPageData, RoleBasedSecurity } from '$features/ui';
|
||||
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import {
|
||||
@@ -48,6 +50,7 @@
|
||||
import type { SubmissionListItem } from '$features/course/utils/types';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { isAutoGradableQuestionType } from '@cio/question-types';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
@@ -81,8 +84,23 @@
|
||||
let preview: boolean = $state(false);
|
||||
let shouldDeleteExercise = $state(false);
|
||||
let isSaving = $state(false);
|
||||
let isDeleting = $state(false);
|
||||
let selectedTab = $state<ExerciseTab>('questions');
|
||||
|
||||
async function handleDeleteExercise() {
|
||||
if (!courseApi.course?.id) return;
|
||||
isDeleting = true;
|
||||
|
||||
await exerciseApi.delete(courseApi.course.id, exerciseId);
|
||||
|
||||
if (exerciseApi.success) {
|
||||
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
|
||||
goBack();
|
||||
}
|
||||
isDeleting = false;
|
||||
shouldDeleteExercise = false;
|
||||
}
|
||||
|
||||
function patchExerciseListItemLocally() {
|
||||
const nonDeletedQuestionsCount = ($questionnaire.questions || []).filter((question) => !question.deletedAt).length;
|
||||
|
||||
@@ -231,7 +249,7 @@
|
||||
);
|
||||
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
|
||||
|
||||
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
|
||||
const isSelfPacedCourse = $derived(isSelfPacedLikeCourse(courseApi.course?.type));
|
||||
const activeQuestionTypeIds = $derived(
|
||||
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
|
||||
);
|
||||
@@ -255,7 +273,7 @@
|
||||
<span>{$questionnaire.title || 'Exercise'}</span>
|
||||
<RoleBasedSecurity allowedRoles={[1, 2]}>
|
||||
{#if teacherAutoGradeBadge === 'auto'}
|
||||
<Badge variant="success" class="font-normal">
|
||||
<Badge variant="outline" class="font-normal">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
|
||||
</Badge>
|
||||
{:else if teacherAutoGradeBadge === 'manual'}
|
||||
@@ -323,7 +341,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<RefreshPageData />
|
||||
<RefreshPageData onRefresh={() => refreshExercisePageData(courseApi.course?.id ?? '', exerciseId)} />
|
||||
</RoleBasedSecurity>
|
||||
</div>
|
||||
</Page.Action>
|
||||
@@ -356,14 +374,13 @@
|
||||
<UnderlineTabs.Trigger value="submissions">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
|
||||
|
||||
<Badge>{submissions.length}</Badge>
|
||||
<Badge variant="outline">{submissions.length}</Badge>
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
<UnderlineTabs.Content value="questions">
|
||||
<UpdateDescription {preview} />
|
||||
{#if !preview}
|
||||
<EditMode
|
||||
bind:shouldDeleteExercise
|
||||
{exerciseId}
|
||||
{goBack}
|
||||
{requiresPositivePointsForAutoGrade}
|
||||
@@ -380,6 +397,35 @@
|
||||
<Submissions bind:exerciseId {submissions} />
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={shouldDeleteExercise}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) shouldDeleteExercise = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/4">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.delete_modal')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={(e) => e.preventDefault()}>
|
||||
<h1 class="text-xl dark:text-white">
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.sure')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" type="submit" onclick={() => (shouldDeleteExercise = false)}>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.edit_mode.no')}
|
||||
</Button>
|
||||
<Button disabled={isDeleting} onclick={handleDeleteExercise} loading={isDeleting}>
|
||||
{isDeleting
|
||||
? $t('course.navItem.lessons.exercises.all_exercises.edit_mode.deleting')
|
||||
: $t('course.navItem.lessons.exercises.all_exercises.edit_mode.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { default as AnalyticsPage } from './analytics.svelte';
|
||||
export { default as AttendancePage } from './attendance.svelte';
|
||||
export { default as CertificatesPage } from './certificates.svelte';
|
||||
export { default as CompliancePage } from './compliance.svelte';
|
||||
export { default as CourseSettingsPage } from './settings.svelte';
|
||||
export { default as CoursesPage } from './courses.svelte';
|
||||
export { default as ExercisePage } from './exercise.svelte';
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
import { isObject } from '$lib/utils/functions/isObject';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||
import { DeleteModal } from '$features/ui';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { tagApi } from '$features/tag/api';
|
||||
@@ -147,6 +148,10 @@
|
||||
|
||||
if (!courseApi.course) return;
|
||||
|
||||
if ($settings.isPublished && !courseApi.course.slug) {
|
||||
courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true });
|
||||
}
|
||||
|
||||
const metadataPayload = {
|
||||
...(isObject(courseApi.course.metadata) ? courseApi.course.metadata : {}),
|
||||
lessonTabsOrder: $settings.tabs,
|
||||
@@ -163,7 +168,9 @@
|
||||
logo: logoUrl,
|
||||
isPublished: $settings.isPublished,
|
||||
metadata: metadataPayload,
|
||||
slug: courseApi.course.slug ?? undefined
|
||||
slug: courseApi.course.slug ?? undefined,
|
||||
compliance:
|
||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined
|
||||
};
|
||||
|
||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||
@@ -525,6 +532,10 @@
|
||||
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
|
||||
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
|
||||
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import type { TComplianceSettings } from '@cio/utils/validation/course';
|
||||
import type { UserEnrolledCourses } from '$features/course/types';
|
||||
|
||||
export const DEFAULT_COMPLIANCE_SETTINGS = {
|
||||
retakeIntervalMonths: 12,
|
||||
gracePeriodDays: 0,
|
||||
reminderDaysBefore: [30, 7, 1],
|
||||
isMandatory: true,
|
||||
framework: null,
|
||||
maxRetakeAttempts: null,
|
||||
passingScore: 80
|
||||
} satisfies TComplianceSettings;
|
||||
|
||||
export function isSelfPacedLikeCourse(courseType: TCourseType | null | undefined): boolean {
|
||||
return courseType === 'SELF_PACED' || courseType === 'COMPLIANCE';
|
||||
}
|
||||
|
||||
type StudentComplianceStatus =
|
||||
| 'not_started'
|
||||
| 'in_progress'
|
||||
| 'compliant'
|
||||
| 'expiring_soon'
|
||||
| 'in_grace_period'
|
||||
| 'non_compliant'
|
||||
| 'waived'
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
type StudentEnrolledCourse = UserEnrolledCourses[number];
|
||||
|
||||
function getCourseContentProgress(course: StudentEnrolledCourse) {
|
||||
const exercisesCompleted = typeof course.exercisesCompleted === 'number' ? course.exercisesCompleted : 0;
|
||||
const totalExercises = typeof course.exerciseCount === 'number' ? course.exerciseCount : 0;
|
||||
const totalItems = (course.lessonCount ?? 0) + totalExercises;
|
||||
|
||||
if (totalItems <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const completedItems = (course.progressRate ?? 0) + exercisesCompleted;
|
||||
return Math.round((completedItems / totalItems) * 100);
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatus(course: StudentEnrolledCourse): StudentComplianceStatus {
|
||||
if (course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('complianceStatus' in course)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return course.complianceStatus as StudentComplianceStatus;
|
||||
}
|
||||
|
||||
export function isStudentCourseComplete(course: StudentEnrolledCourse) {
|
||||
if (course.type === 'COMPLIANCE') {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
return complianceStatus === 'compliant' || complianceStatus === 'waived';
|
||||
}
|
||||
|
||||
return getCourseContentProgress(course) >= 100;
|
||||
}
|
||||
|
||||
export function getStudentCourseProgressPercent(course: StudentEnrolledCourse) {
|
||||
return getCourseContentProgress(course);
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceDate(course: StudentEnrolledCourse) {
|
||||
if (course.type !== 'COMPLIANCE') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
const validUntil = 'complianceValidUntil' in course ? (course.complianceValidUntil ?? null) : null;
|
||||
const dueDate = 'complianceDueDate' in course ? (course.complianceDueDate ?? null) : null;
|
||||
|
||||
if ((complianceStatus === 'compliant' || complianceStatus === 'waived') && validUntil) {
|
||||
return {
|
||||
labelKey: 'courses.course_card.valid_until',
|
||||
value: validUntil
|
||||
};
|
||||
}
|
||||
|
||||
if (dueDate) {
|
||||
return {
|
||||
labelKey: 'courses.course_card.due_date',
|
||||
value: dueDate
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatusKey(course: StudentEnrolledCourse) {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
return complianceStatus ? `course.navItem.compliance.status.${complianceStatus}` : null;
|
||||
}
|
||||
|
||||
export function getStudentCourseComplianceStatusVariant(course: StudentEnrolledCourse) {
|
||||
const complianceStatus = getStudentCourseComplianceStatus(course);
|
||||
if (!complianceStatus) {
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'compliant') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (
|
||||
complianceStatus === 'expiring_soon' ||
|
||||
complianceStatus === 'in_progress' ||
|
||||
complianceStatus === 'in_grace_period'
|
||||
) {
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'non_compliant') {
|
||||
return 'destructive';
|
||||
}
|
||||
|
||||
if (complianceStatus === 'waived') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import TableOfContentsIcon from '@lucide/svelte/icons/table-of-contents';
|
||||
export type ContentItem = CourseContentItem;
|
||||
export type ContentSection = CourseContentSection;
|
||||
|
||||
export type MentionableContent = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: CourseContentType;
|
||||
};
|
||||
|
||||
export const CONTENT_DEFINITIONS = {
|
||||
[CourseContentType.Section]: {
|
||||
label: 'Section',
|
||||
@@ -28,7 +34,7 @@ const EMPTY_CONTENT = {
|
||||
items: [] as ContentItem[]
|
||||
};
|
||||
|
||||
export function getContentRoute(courseId: string, item: ContentItem) {
|
||||
export function getContentRoute(courseId: string, item: { id: string; type: string }) {
|
||||
if (item.type === CourseContentType.Lesson) {
|
||||
return `/courses/${courseId}/lessons/${item.id}`;
|
||||
}
|
||||
@@ -37,6 +43,10 @@ export function getContentRoute(courseId: string, item: ContentItem) {
|
||||
return `/courses/${courseId}/exercises/${item.id}`;
|
||||
}
|
||||
|
||||
if (item.type === CourseContentType.Section) {
|
||||
return `/courses/${courseId}/lessons#section-${item.id}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -53,3 +63,33 @@ export function getOrderedNavigableContent(course: Course | null): ContentItem[]
|
||||
NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flat list of mentionable content for the AI chat @ menu.
|
||||
* Includes sections (when grouped) interleaved with their lessons/exercises in display order.
|
||||
*/
|
||||
export function getMentionableContent(course: Course | null): MentionableContent[] {
|
||||
const content = getCourseContent(course);
|
||||
|
||||
if (!content.grouped) {
|
||||
return content.items
|
||||
.filter((item) => NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number]))
|
||||
.map((item) => ({ id: item.id, title: item.title, type: item.type as CourseContentType }));
|
||||
}
|
||||
|
||||
const result: MentionableContent[] = [];
|
||||
|
||||
for (const section of content.sections) {
|
||||
if (section.id !== 'ungrouped' && section.title) {
|
||||
result.push({ id: section.id, title: section.title, type: CourseContentType.Section });
|
||||
}
|
||||
|
||||
for (const item of section.items) {
|
||||
if (NAVIGABLE_CONTENT_TYPES.includes(item.type as (typeof NAVIGABLE_CONTENT_TYPES)[number])) {
|
||||
result.push({ id: item.id, title: item.title, type: item.type as CourseContentType });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Exercise } from '$features/course/utils/types';
|
||||
import type { Question } from '$features/course/types';
|
||||
import { exerciseApi } from '$features/course/api';
|
||||
import {
|
||||
clearQuestionnaireValidation,
|
||||
questionnaire,
|
||||
questionnaireMetaData
|
||||
} from '$features/course/components/exercise/store';
|
||||
import { getQuestionTypeId, getQuestionTypeOptionById } from '$features/course/components/exercise/question-type-utils';
|
||||
import { normalizeQuestionOrder } from '$features/course/components/exercise/order-utils';
|
||||
|
||||
export function hydrateExercisePageData(exercise: Exercise, exerciseId: string) {
|
||||
let questions: Question[] = [];
|
||||
|
||||
if (exercise.questions && Array.isArray(exercise.questions)) {
|
||||
const mappedQuestions = exercise.questions.map((question) => {
|
||||
const questionType = getQuestionTypeOptionById(getQuestionTypeId(question));
|
||||
|
||||
return {
|
||||
...question,
|
||||
questionTypeId: questionType.id,
|
||||
questionType
|
||||
};
|
||||
});
|
||||
|
||||
questions = normalizeQuestionOrder(mappedQuestions);
|
||||
}
|
||||
|
||||
clearQuestionnaireValidation();
|
||||
|
||||
questionnaire.set({
|
||||
title: exercise.title,
|
||||
description: exercise.description,
|
||||
dueBy: exercise.dueBy,
|
||||
isTitleDirty: false,
|
||||
isDescriptionDirty: false,
|
||||
isDueByDirty: false,
|
||||
questions,
|
||||
totalSubmissions: 0,
|
||||
allowMultipleAttempts: !!exercise.allowMultipleAttempts
|
||||
});
|
||||
|
||||
questionnaireMetaData.update((metadata) => ({ ...metadata, exerciseId }));
|
||||
}
|
||||
|
||||
export async function refreshExercisePageData(courseId: string, exerciseId: string) {
|
||||
await exerciseApi.get(courseId, exerciseId);
|
||||
|
||||
if (!exerciseApi.exercise) return null;
|
||||
|
||||
hydrateExercisePageData(exerciseApi.exercise, exerciseId);
|
||||
|
||||
return exerciseApi.exercise;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ type CourseSettings = {
|
||||
courseTitle: string;
|
||||
courseDescription: string;
|
||||
grading: boolean;
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED';
|
||||
type: 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE';
|
||||
allowNewStudents: boolean;
|
||||
tabs: { id: number; name: string }[];
|
||||
lessonDownload: boolean;
|
||||
|
||||
@@ -376,6 +376,31 @@ export type GetCourseAnalyticsResponse = InferResponseType<GetCourseAnalyticsReq
|
||||
export type GetCourseAnalyticsSuccess = Extract<GetCourseAnalyticsResponse, { success: true }>;
|
||||
export type CourseAnalytics = GetCourseAnalyticsSuccess['data'];
|
||||
|
||||
export type GetCourseComplianceOverviewRequest = (typeof classroomio.course)[':courseId']['compliance']['$get'];
|
||||
export type GetCourseComplianceOverviewResponse = InferResponseType<GetCourseComplianceOverviewRequest>;
|
||||
export type GetCourseComplianceOverviewSuccess = Extract<GetCourseComplianceOverviewResponse, { success: true }>;
|
||||
export type CourseComplianceOverview = GetCourseComplianceOverviewSuccess['data'];
|
||||
export type CourseComplianceLearner = CourseComplianceOverview['learners'][number];
|
||||
|
||||
export type GetLearnerComplianceHistoryRequest =
|
||||
(typeof classroomio.course)[':courseId']['compliance']['learners'][':profileId']['$get'];
|
||||
export type GetLearnerComplianceHistoryResponse = InferResponseType<GetLearnerComplianceHistoryRequest>;
|
||||
export type GetLearnerComplianceHistorySuccess = Extract<GetLearnerComplianceHistoryResponse, { success: true }>;
|
||||
export type LearnerComplianceHistory = GetLearnerComplianceHistorySuccess['data'];
|
||||
export type LearnerComplianceRecord = LearnerComplianceHistory['history'][number];
|
||||
|
||||
export type ResetCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['reset']['$post'];
|
||||
export type ResetCourseComplianceResponse = InferResponseType<ResetCourseComplianceRequest>;
|
||||
export type ResetCourseComplianceSuccess = Extract<ResetCourseComplianceResponse, { success: true }>;
|
||||
|
||||
export type ExtendCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['extend']['$post'];
|
||||
export type ExtendCourseComplianceResponse = InferResponseType<ExtendCourseComplianceRequest>;
|
||||
export type ExtendCourseComplianceSuccess = Extract<ExtendCourseComplianceResponse, { success: true }>;
|
||||
|
||||
export type WaiveCourseComplianceRequest = (typeof classroomio.course)[':courseId']['compliance']['waive']['$post'];
|
||||
export type WaiveCourseComplianceResponse = InferResponseType<WaiveCourseComplianceRequest>;
|
||||
export type WaiveCourseComplianceSuccess = Extract<WaiveCourseComplianceResponse, { success: true }>;
|
||||
|
||||
// Derived types from Lesson
|
||||
export type LessonVideoType = NonNullable<Lesson['videos']>[number]['type'];
|
||||
export type LessonDocument = NonNullable<Lesson['documents']>[number];
|
||||
|
||||
Reference in New Issue
Block a user