feat(dashboard): moved course completion deadline to course settings page (#842)
This commit is contained in:
@@ -177,6 +177,43 @@ describe('evaluateCourseGoLiveReadiness', () => {
|
|||||||
expect(readiness.blockers).toEqual([]);
|
expect(readiness.blockers).toEqual([]);
|
||||||
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
|
expect(readiness.courseUrl).toBe('https://school.example.com/course/workplace-safety-essentials');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks publishing a compliance course that has no completion deadline', () => {
|
||||||
|
const readiness = evaluateCourseGoLiveReadiness({
|
||||||
|
course: buildCourse({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
certificate: { isDownloadable: true }
|
||||||
|
}) as never,
|
||||||
|
contentItems: [buildContentItem()],
|
||||||
|
organization: {
|
||||||
|
customDomain: 'school.example.com',
|
||||||
|
isCustomDomainVerified: true,
|
||||||
|
siteName: 'school'
|
||||||
|
} as never
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readiness.ready).toBe(false);
|
||||||
|
expect(readiness.blockers.map((blocker) => blocker.code)).toContain('COMPLIANCE_DEADLINE_MISSING');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows publishing a compliance course when a completion deadline is set', () => {
|
||||||
|
const readiness = evaluateCourseGoLiveReadiness({
|
||||||
|
course: buildCourse({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
certificate: { deadline: '2026-12-31T23:59:00.000Z' }
|
||||||
|
}) as never,
|
||||||
|
contentItems: [buildContentItem()],
|
||||||
|
organization: {
|
||||||
|
customDomain: 'school.example.com',
|
||||||
|
isCustomDomainVerified: true,
|
||||||
|
siteName: 'school'
|
||||||
|
} as never
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(readiness.ready).toBe(true);
|
||||||
|
expect(readiness.blockers).toEqual([]);
|
||||||
|
expect(readiness.warnings.map((warning) => warning.code)).toContain('COMPLIANCE_REVIEW_RECOMMENDED');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('course go-live service', () => {
|
describe('course go-live service', () => {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { resolve } from '$app/paths';
|
|||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { coursesApi } from './courses.svelte';
|
import { coursesApi } from './courses.svelte';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE, ErrorCodes } from '@cio/utils/constants';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
import type { CourseMembers } from '../utils/types';
|
import type { CourseMembers } from '../utils/types';
|
||||||
|
|
||||||
@@ -326,11 +326,11 @@ export class CourseApi extends BaseApiWithErrors {
|
|||||||
typeof result === 'object' &&
|
typeof result === 'object' &&
|
||||||
result !== null &&
|
result !== null &&
|
||||||
'code' in result &&
|
'code' in result &&
|
||||||
result.code === 'VALIDATION_ERROR' &&
|
result.code === ErrorCodes.VALIDATION_ERROR &&
|
||||||
'field' in result &&
|
'field' in result &&
|
||||||
result.field === 'certificate.deadline'
|
result.field === 'certificate.deadline'
|
||||||
) {
|
) {
|
||||||
snackbar.error('course.certification.deadline_required_enroll');
|
snackbar.error('course.certification.deadline_invalid');
|
||||||
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||||
snackbar.error((result as { error: string }).error);
|
snackbar.error((result as { error: string }).error);
|
||||||
}
|
}
|
||||||
@@ -497,6 +497,11 @@ export class CourseApi extends BaseApiWithErrors {
|
|||||||
snackbar.error('Failed to update course');
|
snackbar.error('Failed to update course');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ('code' in result && result.code === ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED) {
|
||||||
|
this.errors['certificate.deadline'] = t.get('course.certification.deadline_required');
|
||||||
|
snackbar.error('course.certification.deadline_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ('error' in result && 'field' in result && result.field) {
|
if ('error' in result && 'field' in result && result.field) {
|
||||||
this.errors[result.field] = result.error;
|
this.errors[result.field] = result.error;
|
||||||
snackbar.error(result.error);
|
snackbar.error(result.error);
|
||||||
|
|||||||
-147
@@ -1,15 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import { Input } from '@cio/ui/base/input';
|
|
||||||
import { Textarea } from '@cio/ui/base/textarea';
|
import { Textarea } from '@cio/ui/base/textarea';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { isFreePlan } from '$lib/utils/store/org';
|
import { isFreePlan } from '$lib/utils/store/org';
|
||||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
|
||||||
import type { CourseCertificate } from '$features/course/utils/types';
|
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
|
||||||
import { AttentionHighlight } from '$features/ui';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
@@ -17,14 +12,6 @@
|
|||||||
|
|
||||||
let { errors }: Props = $props();
|
let { errors }: Props = $props();
|
||||||
|
|
||||||
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0');
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
|
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
|
||||||
if (!courseApi.course) return;
|
if (!courseApi.course) return;
|
||||||
|
|
||||||
@@ -34,54 +21,9 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCertDeadlineChange(e: Event) {
|
|
||||||
const v = (e.currentTarget as HTMLInputElement).value;
|
|
||||||
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
const certExercises = $derived(
|
|
||||||
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
|
||||||
);
|
|
||||||
|
|
||||||
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
|
|
||||||
|
|
||||||
function onFinalExerciseChange(e: Event) {
|
|
||||||
const v = (e.currentTarget as HTMLSelectElement).value;
|
|
||||||
if (!v || v === 'none') {
|
|
||||||
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
|
|
||||||
updateCertificate({
|
|
||||||
requiredExerciseId: v,
|
|
||||||
exerciseMinScorePercent: currentMin ?? 100
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onThresholdInput(e: Event) {
|
|
||||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
|
||||||
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMinExerciseScoreInput(e: Event) {
|
|
||||||
const n = Number((e.currentTarget as HTMLInputElement).value);
|
|
||||||
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
|
|
||||||
}
|
|
||||||
|
|
||||||
function onEmailMessageInput(e: Event) {
|
function onEmailMessageInput(e: Event) {
|
||||||
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateCertificateSettings(certificate: CourseCertificate | null | undefined): string[] {
|
|
||||||
const errors: string[] = [];
|
|
||||||
if (certificate?.isDownloadable && !certificate?.deadline) {
|
|
||||||
errors.push('course.certification.deadline_required');
|
|
||||||
}
|
|
||||||
return errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
const certificateValidationErrors = $derived(validateCertificateSettings(courseApi.course?.certificate));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field.Group class="w-full max-w-md! px-2">
|
<Field.Group class="w-full max-w-md! px-2">
|
||||||
@@ -101,97 +43,8 @@
|
|||||||
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
||||||
{$t('course.navItem.certificates.allow')}
|
{$t('course.navItem.certificates.allow')}
|
||||||
</Field.Label>
|
</Field.Label>
|
||||||
{#if courseApi.course?.certificate?.isDownloadable && certificateValidationErrors.includes('course.certification.deadline_required')}
|
|
||||||
<Field.Error class="mt-1">{$t('course.certification.deadline_required')}</Field.Error>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<Field.Group>
|
|
||||||
<AttentionHighlight id="cert-deadline">
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="cert-deadline">
|
|
||||||
{$t('course.certification.deadline_label')}
|
|
||||||
{#if courseApi.course?.certificate?.isDownloadable}
|
|
||||||
<span class="ui:text-red-600">*</span>
|
|
||||||
{/if}
|
|
||||||
</Field.Label>
|
|
||||||
<Input
|
|
||||||
id="cert-deadline"
|
|
||||||
type="datetime-local"
|
|
||||||
class="w-full"
|
|
||||||
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
|
|
||||||
onchange={onCertDeadlineChange}
|
|
||||||
disabled={$isFreePlan}
|
|
||||||
/>
|
|
||||||
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
|
|
||||||
{#if errors['certificate.deadline']}
|
|
||||||
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
|
||||||
{/if}
|
|
||||||
{#if certificateValidationErrors.includes('course.certification.deadline_required')}
|
|
||||||
<Field.Error>{$t('course.certification.deadline_required')}</Field.Error>
|
|
||||||
{/if}
|
|
||||||
</Field.Field>
|
|
||||||
</AttentionHighlight>
|
|
||||||
|
|
||||||
<AttentionHighlight id="cert-threshold">
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
|
||||||
<Input
|
|
||||||
id="cert-threshold"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
class="w-full"
|
|
||||||
value={String(courseApi.course?.certificate?.threshold ?? 100)}
|
|
||||||
oninput={onThresholdInput}
|
|
||||||
disabled={$isFreePlan}
|
|
||||||
/>
|
|
||||||
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
|
||||||
{#if errors['certificate.threshold']}
|
|
||||||
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
|
||||||
{/if}
|
|
||||||
</Field.Field>
|
|
||||||
</AttentionHighlight>
|
|
||||||
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
|
||||||
<select
|
|
||||||
id="cert-final-exercise"
|
|
||||||
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
|
|
||||||
value={finalExerciseSelectValue}
|
|
||||||
onchange={onFinalExerciseChange}
|
|
||||||
disabled={$isFreePlan}
|
|
||||||
>
|
|
||||||
<option value="none">{$t('course.certification.final_exercise_none')}</option>
|
|
||||||
{#each certExercises as item (item.id)}
|
|
||||||
<option value={item.id}>{item.title}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
|
||||||
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
{#if courseApi.course?.certificate?.requiredExerciseId}
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
|
|
||||||
<Input
|
|
||||||
id="cert-min-exercise-score"
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
class="w-full"
|
|
||||||
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
|
|
||||||
oninput={onMinExerciseScoreInput}
|
|
||||||
disabled={$isFreePlan}
|
|
||||||
/>
|
|
||||||
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
|
||||||
{#if errors['certificate.exerciseMinScorePercent']}
|
|
||||||
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
|
||||||
{/if}
|
|
||||||
</Field.Field>
|
|
||||||
{/if}
|
|
||||||
</Field.Group>
|
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
|
||||||
<Field.Separator />
|
<Field.Separator />
|
||||||
|
|||||||
@@ -16,13 +16,13 @@
|
|||||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
|
import { Input } from '@cio/ui/base/input';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor, AttentionHighlight } from '$features/ui';
|
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor, AttentionHighlight } from '$features/ui';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
|
||||||
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
|
||||||
|
|
||||||
import { settings } from '$features/course/utils/settings-store';
|
import { settings } from '$features/course/utils/settings-store';
|
||||||
|
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||||
import Copy from '@lucide/svelte/icons/copy';
|
import Copy from '@lucide/svelte/icons/copy';
|
||||||
import * as Alert from '@cio/ui/base/alert';
|
import * as Alert from '@cio/ui/base/alert';
|
||||||
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
|
import LockOpenIcon from '@lucide/svelte/icons/lock-open';
|
||||||
@@ -31,8 +31,9 @@
|
|||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { isObject } from '$lib/utils/functions/isObject';
|
import { isObject } from '$lib/utils/functions/isObject';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions';
|
||||||
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
import { DEFAULT_COMPLIANCE_SETTINGS } from '../utils/compliance-utils';
|
||||||
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
import { DeleteModal } from '$features/ui';
|
import { DeleteModal } from '$features/ui';
|
||||||
import { contentApi, courseApi } from '$features/course/api';
|
import { contentApi, courseApi } from '$features/course/api';
|
||||||
import { collectLockedContentItems } from '$features/course/utils/content-lock-utils';
|
import { collectLockedContentItems } from '$features/course/utils/content-lock-utils';
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
let openCertificateDeadlineDialog = $state(false);
|
let openCertificateDeadlineDialog = $state(false);
|
||||||
|
let completionDeadlineTrigger = $state(0);
|
||||||
let errors: {
|
let errors: {
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
description: string | undefined;
|
description: string | undefined;
|
||||||
@@ -169,10 +171,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCourseCompliance = $settings.type === 'COMPLIANCE';
|
if (
|
||||||
const hasCertificateDeadline = !!courseApi.course?.certificate?.deadline;
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: $settings.type,
|
||||||
if (isCourseCompliance && !hasCertificateDeadline) {
|
isPublished: true,
|
||||||
|
deadline: $settings.certificate.deadline
|
||||||
|
})
|
||||||
|
) {
|
||||||
openCertificateDeadlineDialog = true;
|
openCertificateDeadlineDialog = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -183,15 +188,9 @@
|
|||||||
hasUnsavedChanges = true;
|
hasUnsavedChanges = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function goToCertificateSettings() {
|
function goToCompletionDeadline() {
|
||||||
openCertificateDeadlineDialog = false;
|
openCertificateDeadlineDialog = false;
|
||||||
const courseId = courseApi.course?.id;
|
completionDeadlineTrigger += 1;
|
||||||
if (!courseId) return;
|
|
||||||
|
|
||||||
goAndHighlight(ROUTE_NAME.COURSE_CERTIFICATE, ROUTE_SECTIONS[ROUTE_NAME.COURSE_CERTIFICATE].CERT_DEADLINE, {
|
|
||||||
id: courseId,
|
|
||||||
tab: 'settings'
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleSave() {
|
export async function handleSave() {
|
||||||
@@ -215,6 +214,17 @@
|
|||||||
|
|
||||||
if (!courseApi.course) return;
|
if (!courseApi.course) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: $settings.type,
|
||||||
|
isPublished: $settings.isPublished,
|
||||||
|
deadline: $settings.certificate.deadline
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
openCertificateDeadlineDialog = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($settings.isPublished && !courseApi.course.slug) {
|
if ($settings.isPublished && !courseApi.course.slug) {
|
||||||
courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true });
|
courseApi.course.slug = generateSlug($settings.courseTitle, { appendTimestamp: true });
|
||||||
}
|
}
|
||||||
@@ -240,7 +250,16 @@
|
|||||||
slug: courseApi.course.slug ?? undefined,
|
slug: courseApi.course.slug ?? undefined,
|
||||||
compliance:
|
compliance:
|
||||||
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
|
$settings.type === 'COMPLIANCE' ? (courseApi.course.compliance ?? DEFAULT_COMPLIANCE_SETTINGS) : undefined,
|
||||||
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null
|
callout: $settings.type === 'PUBLIC' ? sanitizeCalloutForSave($settings.callout) : null,
|
||||||
|
certificate: {
|
||||||
|
...(courseApi.course.certificate ?? {}),
|
||||||
|
deadline: $settings.certificate.deadline,
|
||||||
|
threshold: $settings.certificate.threshold,
|
||||||
|
requiredExerciseId: $settings.certificate.requiredExerciseId,
|
||||||
|
exerciseMinScorePercent: $settings.certificate.requiredExerciseId
|
||||||
|
? $settings.certificate.exerciseMinScorePercent
|
||||||
|
: null
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
const normalizedSelectedTagIds = normalizeTagIds(selectedTagIds);
|
||||||
@@ -293,7 +312,18 @@
|
|||||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||||
progressionMode: course.metadata?.progressionMode ?? 'free',
|
progressionMode: course.metadata?.progressionMode ?? 'free',
|
||||||
callout: normalizeCallout(course.callout),
|
callout: normalizeCallout(course.callout),
|
||||||
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? ''
|
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? '',
|
||||||
|
certificate: {
|
||||||
|
deadline: course.certificate?.deadline ?? null,
|
||||||
|
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||||
|
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||||
|
exerciseMinScorePercent:
|
||||||
|
typeof course.certificate?.exerciseMinScorePercent === 'number'
|
||||||
|
? course.certificate.exerciseMinScorePercent
|
||||||
|
: course.certificate?.requiredExerciseId
|
||||||
|
? 100
|
||||||
|
: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -416,6 +446,54 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
let courseLink = $derived(courseApi.course?.slug ? `${$currentOrgDomain}/course/${courseApi.course.slug}` : '#');
|
let courseLink = $derived(courseApi.course?.slug ? `${$currentOrgDomain}/course/${courseApi.course.slug}` : '#');
|
||||||
|
|
||||||
|
const certExercises = $derived(
|
||||||
|
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
|
||||||
|
);
|
||||||
|
|
||||||
|
const finalExerciseTitle = $derived(
|
||||||
|
certExercises.find((item) => item.id === $settings.certificate.requiredExerciseId)?.title
|
||||||
|
);
|
||||||
|
|
||||||
|
function isoToDatetimeLocal(iso: string | null | undefined): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCompletionDeadlineChange(e: Event) {
|
||||||
|
const value = (e.currentTarget as HTMLInputElement).value;
|
||||||
|
$settings.certificate.deadline = value ? new Date(value).toISOString() : null;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onThresholdInput(e: Event) {
|
||||||
|
const value = Number((e.currentTarget as HTMLInputElement).value);
|
||||||
|
$settings.certificate.threshold = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFinalExerciseChange(value: string) {
|
||||||
|
$settings.certificate.requiredExerciseId = value && value !== 'none' ? value : null;
|
||||||
|
|
||||||
|
if (!$settings.certificate.requiredExerciseId) {
|
||||||
|
$settings.certificate.exerciseMinScorePercent = null;
|
||||||
|
} else if (typeof $settings.certificate.exerciseMinScorePercent !== 'number') {
|
||||||
|
$settings.certificate.exerciseMinScorePercent = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMinExerciseScoreInput(e: Event) {
|
||||||
|
const value = Number((e.currentTarget as HTMLInputElement).value);
|
||||||
|
$settings.certificate.exerciseMinScorePercent = Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 100;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UnsavedChanges bind:hasUnsavedChanges />
|
<UnsavedChanges bind:hasUnsavedChanges />
|
||||||
@@ -434,8 +512,8 @@
|
|||||||
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
|
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
|
||||||
{$t('cancel')}
|
{$t('cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onclick={goToCertificateSettings}>
|
<Button onclick={goToCompletionDeadline}>
|
||||||
{$t('course.navItem.settings.go_to_certificate_settings')}
|
{$t('course.navItem.settings.go_to_completion_deadline')}
|
||||||
</Button>
|
</Button>
|
||||||
</Dialog.Footer>
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
@@ -595,6 +673,96 @@
|
|||||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<Field.Group class="mt-3">
|
||||||
|
<AttentionHighlight id="course-completion-deadline" trigger={completionDeadlineTrigger}>
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label>
|
||||||
|
{$t('course.navItem.settings.completion_deadline_label')}
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="course-completion-deadline"
|
||||||
|
type="datetime-local"
|
||||||
|
class="w-full"
|
||||||
|
value={isoToDatetimeLocal($settings.certificate.deadline)}
|
||||||
|
onchange={onCompletionDeadlineChange}
|
||||||
|
/>
|
||||||
|
<Field.Description>{$t('course.navItem.settings.completion_deadline_helper')}</Field.Description>
|
||||||
|
{#if courseApi.errors['certificate.deadline']}
|
||||||
|
<Field.Error>{courseApi.errors['certificate.deadline']}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
</AttentionHighlight>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="course-completion-threshold">
|
||||||
|
{$t('course.certification.threshold_label')}
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="course-completion-threshold"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
class="w-full"
|
||||||
|
value={String($settings.certificate.threshold)}
|
||||||
|
oninput={onThresholdInput}
|
||||||
|
/>
|
||||||
|
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
|
||||||
|
{#if courseApi.errors['certificate.threshold']}
|
||||||
|
<Field.Error>{courseApi.errors['certificate.threshold']}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="course-final-exercise">
|
||||||
|
{$t('course.certification.final_exercise_label')}
|
||||||
|
</Field.Label>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
value={$settings.certificate.requiredExerciseId ?? 'none'}
|
||||||
|
onValueChange={onFinalExerciseChange}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="w-full">
|
||||||
|
{finalExerciseTitle ?? $t('course.certification.final_exercise_none')}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
<Select.Group>
|
||||||
|
<Select.Item value="none" label={$t('course.certification.final_exercise_none')}>
|
||||||
|
{$t('course.certification.final_exercise_none')}
|
||||||
|
</Select.Item>
|
||||||
|
{#each certExercises as item (item.id)}
|
||||||
|
<Select.Item value={item.id} label={item.title}>
|
||||||
|
{item.title}
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||||
|
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
{#if $settings.certificate.requiredExerciseId}
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="course-min-exercise-score">
|
||||||
|
{$t('course.certification.min_exercise_score_label')}
|
||||||
|
</Field.Label>
|
||||||
|
<Input
|
||||||
|
id="course-min-exercise-score"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
class="w-full"
|
||||||
|
value={String($settings.certificate.exerciseMinScorePercent ?? 100)}
|
||||||
|
oninput={onMinExerciseScoreInput}
|
||||||
|
/>
|
||||||
|
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
|
||||||
|
{#if courseApi.errors['certificate.exerciseMinScorePercent']}
|
||||||
|
<Field.Error>{courseApi.errors['certificate.exerciseMinScorePercent']}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
|
{/if}
|
||||||
|
</Field.Group>
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
|
||||||
<Field.Separator />
|
<Field.Separator />
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ type CourseSettings = {
|
|||||||
progressionMode: 'free' | 'sequential';
|
progressionMode: 'free' | 'sequential';
|
||||||
callout: TCourseCallout | null;
|
callout: TCourseCallout | null;
|
||||||
welcomeEmailMessage: string;
|
welcomeEmailMessage: string;
|
||||||
|
certificate: {
|
||||||
|
deadline: string | null;
|
||||||
|
threshold: number;
|
||||||
|
requiredExerciseId: string | null;
|
||||||
|
exerciseMinScorePercent: number | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const settings = writable<CourseSettings>({
|
export const settings = writable<CourseSettings>({
|
||||||
@@ -36,5 +42,11 @@ export const settings = writable<CourseSettings>({
|
|||||||
isContentGroupingEnabled: true,
|
isContentGroupingEnabled: true,
|
||||||
progressionMode: 'free',
|
progressionMode: 'free',
|
||||||
callout: null,
|
callout: null,
|
||||||
welcomeEmailMessage: ''
|
welcomeEmailMessage: '',
|
||||||
|
certificate: {
|
||||||
|
deadline: null,
|
||||||
|
threshold: 100,
|
||||||
|
requiredExerciseId: null,
|
||||||
|
exerciseMinScorePercent: null
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,14 +7,16 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
|
trigger?: number;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { id, duration = 3, children }: Props = $props();
|
let { id, duration = 3, trigger = 0, children }: Props = $props();
|
||||||
|
|
||||||
let containerRef = $state<HTMLDivElement | null>(null);
|
let containerRef = $state<HTMLDivElement | null>(null);
|
||||||
let pulsing = $state(false);
|
let pulsing = $state(false);
|
||||||
let handledFor = $state<string | null>(null);
|
let handledFor = $state<string | null>(null);
|
||||||
|
let lastTrigger = trigger;
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function getActiveHighlightId(url: URL): string | null {
|
function getActiveHighlightId(url: URL): string | null {
|
||||||
@@ -25,17 +27,7 @@
|
|||||||
return hash || null;
|
return hash || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
function triggerPulse() {
|
||||||
const activeHighlightId = getActiveHighlightId(page.url);
|
|
||||||
|
|
||||||
if (activeHighlightId !== id) {
|
|
||||||
handledFor = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handledFor === activeHighlightId) return;
|
|
||||||
|
|
||||||
handledFor = activeHighlightId;
|
|
||||||
pulsing = true;
|
pulsing = true;
|
||||||
|
|
||||||
containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
@@ -56,6 +48,30 @@
|
|||||||
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
|
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
|
||||||
}
|
}
|
||||||
}, duration * 1000);
|
}, duration * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeHighlightId = getActiveHighlightId(page.url);
|
||||||
|
|
||||||
|
if (activeHighlightId !== id) {
|
||||||
|
handledFor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handledFor === activeHighlightId) return;
|
||||||
|
|
||||||
|
handledFor = activeHighlightId;
|
||||||
|
triggerPulse();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (trigger === lastTrigger) return;
|
||||||
|
|
||||||
|
lastTrigger = trigger;
|
||||||
|
|
||||||
|
if (trigger > 0) {
|
||||||
|
triggerPulse();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -794,8 +794,8 @@
|
|||||||
"progression_mode_sequential": "Sekventiel — fuldfør tidligere elementer for at låse næste op",
|
"progression_mode_sequential": "Sekventiel — fuldfør tidligere elementer for at låse næste op",
|
||||||
"lesson_download": "Hent lektioner",
|
"lesson_download": "Hent lektioner",
|
||||||
"available": "Gør lektionen tilgængelig til download i pdf for tilmeldte studerende",
|
"available": "Gør lektionen tilgængelig til download i pdf for tilmeldte studerende",
|
||||||
"course_download": "Kursus download",
|
"course_download": "Kursusdownload",
|
||||||
"course_avail": "Gør kurset tilgængeligt til download for registrerede studerende",
|
"course_avail": "Download en kopi af dette kursus.",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"allow": "Tillad nye studerende",
|
"allow": "Tillad nye studerende",
|
||||||
"access": "Tillad nye studerende at få adgang til dette kursus",
|
"access": "Tillad nye studerende at få adgang til dette kursus",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Lås alle op"
|
"unlock_all": "Lås alle op"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Indstil certifikatfrist først",
|
"certificate_deadline_required": "Indstil certifikatfrist først",
|
||||||
"certificate_deadline_required_description": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes. Indstil venligst en deadline i certifikatindstillingerne før udgivelse.",
|
"certificate_deadline_required_description": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes. Indstil venligst en deadline i kursusindstillingerne før udgivelse.",
|
||||||
"go_to_certificate_settings": "Gå til Certifikatindstillinger"
|
"go_to_completion_deadline": "Gå til fuldførelsesfristen",
|
||||||
|
"completion_deadline_label": "Deadline for kursusafslutning",
|
||||||
|
"completion_deadline_helper": "Studerende skal opfylde fuldførelsesprocenten inden denne dato (lokal tid). Lad være tomt uden deadline."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analyse"
|
"title": "Analyse"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "E-mail besked",
|
"email_message_label": "E-mail besked",
|
||||||
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!",
|
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!",
|
||||||
"final_exercise_multiple_attempts_note": "Elever får lov til ubegrænsede forsøg på den valgte øvelse.",
|
"final_exercise_multiple_attempts_note": "Elever får lov til ubegrænsede forsøg på den valgte øvelse.",
|
||||||
"deadline_required": "Certifikatfrist er påkrævet, når certifikater kan downloades.",
|
"deadline_required": "Overholdelseskurser kræver en fuldførelsesfrist, før de kan udgives.",
|
||||||
"deadline_required_enroll": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes."
|
"deadline_required_enroll": "Overholdelseskurser kræver en certifikatfrist, før elever kan tilmeldes.",
|
||||||
|
"deadline_invalid": "Kursusets fuldførelsesfrist er ugyldig."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Hvilket kursus vil du lave?",
|
"heading": "Hvilket kursus vil du lave?",
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
"lesson_download": "Lektionen herunterladen",
|
"lesson_download": "Lektionen herunterladen",
|
||||||
"available": "Stellen Sie die Lektion für registrierte Studierende zum Download im PDF-Format zur Verfügung",
|
"available": "Stellen Sie die Lektion für registrierte Studierende zum Download im PDF-Format zur Verfügung",
|
||||||
"course_download": "Kurs-Download",
|
"course_download": "Kurs-Download",
|
||||||
"course_avail": "Stellen Sie den Kurs für registrierte Studierende zum Download bereit",
|
"course_avail": "Laden Sie eine Kopie dieses Kurses herunter.",
|
||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"allow": "Erlauben Sie neue Studenten",
|
"allow": "Erlauben Sie neue Studenten",
|
||||||
"access": "Ermöglichen Sie neuen Studierenden den Zugang zu diesem Kurs",
|
"access": "Ermöglichen Sie neuen Studierenden den Zugang zu diesem Kurs",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Schalte alle frei"
|
"unlock_all": "Schalte alle frei"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Legen Sie zuerst die Frist für das Zertifikat fest",
|
"certificate_deadline_required": "Legen Sie zuerst die Frist für das Zertifikat fest",
|
||||||
"certificate_deadline_required_description": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können. Bitte legen Sie vor der Veröffentlichung eine Frist in den Zertifikatseinstellungen fest.",
|
"certificate_deadline_required_description": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können. Bitte legen Sie vor der Veröffentlichung eine Frist in den Kurseinstellungen fest.",
|
||||||
"go_to_certificate_settings": "Gehen Sie zu Zertifikatseinstellungen"
|
"go_to_completion_deadline": "Gehen Sie zur Abschlussfrist",
|
||||||
|
"completion_deadline_label": "Frist für den Abschluss des Kurses",
|
||||||
|
"completion_deadline_helper": "Die Schüler müssen den Abschlussprozentsatz vor diesem Datum/Uhrzeit (Ortszeit) erreichen. Für keine Frist leer lassen."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analysen"
|
"title": "Analysen"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "E-Mail-Nachricht",
|
"email_message_label": "E-Mail-Nachricht",
|
||||||
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!",
|
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!",
|
||||||
"final_exercise_multiple_attempts_note": "Den Schülern sind unbegrenzte Versuche für die ausgewählte Übung gestattet.",
|
"final_exercise_multiple_attempts_note": "Den Schülern sind unbegrenzte Versuche für die ausgewählte Übung gestattet.",
|
||||||
"deadline_required": "Wenn Zertifikate herunterladbar sind, ist eine Zertifikatsfrist erforderlich.",
|
"deadline_required": "Für Compliance-Kurse ist eine Abschlussfrist erforderlich, bevor sie veröffentlicht werden können.",
|
||||||
"deadline_required_enroll": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können."
|
"deadline_required_enroll": "Für Compliance-Kurse ist eine Zertifikatsfrist erforderlich, bevor Lernende eingeschrieben werden können.",
|
||||||
|
"deadline_invalid": "Die Abschlussfrist des Kurses ist ungültig."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Welchen Kurs möchten Sie erstellen?",
|
"heading": "Welchen Kurs möchten Sie erstellen?",
|
||||||
|
|||||||
@@ -2027,8 +2027,9 @@
|
|||||||
"rules_heading": "Rules",
|
"rules_heading": "Rules",
|
||||||
"deadline_label": "Certification deadline",
|
"deadline_label": "Certification deadline",
|
||||||
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
||||||
"deadline_required": "Certificate deadline is required when certificates are downloadable.",
|
"deadline_required": "Compliance courses require a completion deadline before they can be published.",
|
||||||
"deadline_required_enroll": "Compliance courses require a certificate deadline before learners can be enrolled.",
|
"deadline_required_enroll": "Compliance courses require a certificate deadline before learners can be enrolled.",
|
||||||
|
"deadline_invalid": "The course completion deadline is invalid.",
|
||||||
"threshold_label": "Minimum completion (%)",
|
"threshold_label": "Minimum completion (%)",
|
||||||
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
||||||
"final_exercise_label": "Final exercise (optional)",
|
"final_exercise_label": "Final exercise (optional)",
|
||||||
@@ -3639,10 +3640,10 @@
|
|||||||
"progression_mode_description": "Control whether students can access all unlocked content or must complete items in order.",
|
"progression_mode_description": "Control whether students can access all unlocked content or must complete items in order.",
|
||||||
"progression_mode_free": "Free — unlocked content is accessible",
|
"progression_mode_free": "Free — unlocked content is accessible",
|
||||||
"progression_mode_sequential": "Sequential — complete prior items to unlock next",
|
"progression_mode_sequential": "Sequential — complete prior items to unlock next",
|
||||||
"lesson_download": "Lessons DownLoad",
|
"lesson_download": "Lesson download",
|
||||||
"available": "Make the Lesson available for Download in pdf for registered students",
|
"available": "Let registered students download lessons as PDF.",
|
||||||
"course_download": "Course Downlaod",
|
"course_download": "Course download",
|
||||||
"course_avail": "Make the Course available for Download for registered students",
|
"course_avail": "Download a copy of this course.",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"allow": "Allow New Students",
|
"allow": "Allow New Students",
|
||||||
"access": "Allow new students to access this course",
|
"access": "Allow new students to access this course",
|
||||||
@@ -3651,8 +3652,10 @@
|
|||||||
"published": "Published",
|
"published": "Published",
|
||||||
"unpublished": "Unpublished",
|
"unpublished": "Unpublished",
|
||||||
"certificate_deadline_required": "Set Certificate Deadline First",
|
"certificate_deadline_required": "Set Certificate Deadline First",
|
||||||
"certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the certificate settings before publishing.",
|
"certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the course settings before publishing.",
|
||||||
"go_to_certificate_settings": "Go to Certificate Settings",
|
"go_to_completion_deadline": "Go to Completion Deadline",
|
||||||
|
"completion_deadline_label": "Course completion deadline",
|
||||||
|
"completion_deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
||||||
"locked_content": {
|
"locked_content": {
|
||||||
"title": "{count} course item(s) are locked",
|
"title": "{count} course item(s) are locked",
|
||||||
"description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.",
|
"description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.",
|
||||||
|
|||||||
@@ -792,10 +792,10 @@
|
|||||||
"progression_mode_description": "Controla si los estudiantes pueden acceder a todo el contenido desbloqueado o deben completar los elementos en orden.",
|
"progression_mode_description": "Controla si los estudiantes pueden acceder a todo el contenido desbloqueado o deben completar los elementos en orden.",
|
||||||
"progression_mode_free": "Libre — el contenido desbloqueado es accesible",
|
"progression_mode_free": "Libre — el contenido desbloqueado es accesible",
|
||||||
"progression_mode_sequential": "Secuencial — completar elementos previos para desbloquear el siguiente",
|
"progression_mode_sequential": "Secuencial — completar elementos previos para desbloquear el siguiente",
|
||||||
"lesson_download": "Lecciones DescargarCargar",
|
"lesson_download": "Descarga de lecciones",
|
||||||
"available": "Haga que la lección esté disponible para descargar en pdf para estudiantes registrados",
|
"available": "Haga que la lección esté disponible para descargar en pdf para estudiantes registrados",
|
||||||
"course_download": "Descarga del curso",
|
"course_download": "Descarga del curso",
|
||||||
"course_avail": "Hacer que el curso esté disponible para su descarga para los estudiantes registrados",
|
"course_avail": "Descarga una copia de este curso.",
|
||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"allow": "Permitir nuevos estudiantes",
|
"allow": "Permitir nuevos estudiantes",
|
||||||
"access": "Permitir que nuevos estudiantes accedan a este curso",
|
"access": "Permitir que nuevos estudiantes accedan a este curso",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Desbloquear todo"
|
"unlock_all": "Desbloquear todo"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Establecer la fecha límite del certificado primero",
|
"certificate_deadline_required": "Establecer la fecha límite del certificado primero",
|
||||||
"certificate_deadline_required_description": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse. Establezca una fecha límite en la configuración del certificado antes de publicarlo.",
|
"certificate_deadline_required_description": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse. Establezca una fecha límite en la configuración del curso antes de publicarlo.",
|
||||||
"go_to_certificate_settings": "Vaya a Configuración de certificado"
|
"go_to_completion_deadline": "Ir a la fecha límite de finalización",
|
||||||
|
"completion_deadline_label": "Fecha límite de finalización del curso",
|
||||||
|
"completion_deadline_helper": "Los estudiantes deben alcanzar el porcentaje de finalización antes de esta fecha (hora local). Déjelo vacío sin fecha límite."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analíticas"
|
"title": "Analíticas"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Mensaje de correo electrónico",
|
"email_message_label": "Mensaje de correo electrónico",
|
||||||
"email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!",
|
"email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!",
|
||||||
"final_exercise_multiple_attempts_note": "A los estudiantes se les permitirán intentos ilimitados en el ejercicio seleccionado.",
|
"final_exercise_multiple_attempts_note": "A los estudiantes se les permitirán intentos ilimitados en el ejercicio seleccionado.",
|
||||||
"deadline_required": "Se requiere una fecha límite para el certificado cuando los certificados se pueden descargar.",
|
"deadline_required": "Los cursos de cumplimiento requieren una fecha límite de finalización antes de poder publicarse.",
|
||||||
"deadline_required_enroll": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse."
|
"deadline_required_enroll": "Los cursos de cumplimiento requieren una fecha límite para el certificado antes de que los estudiantes puedan inscribirse.",
|
||||||
|
"deadline_invalid": "La fecha límite de finalización del curso no es válida."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "¿Qué curso quieres crear?",
|
"heading": "¿Qué curso quieres crear?",
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
"lesson_download": "Téléchargement des leçons",
|
"lesson_download": "Téléchargement des leçons",
|
||||||
"available": "Rendre la leçon disponible en téléchargement en pdf pour les étudiants inscrits",
|
"available": "Rendre la leçon disponible en téléchargement en pdf pour les étudiants inscrits",
|
||||||
"course_download": "Téléchargement du cours",
|
"course_download": "Téléchargement du cours",
|
||||||
"course_avail": "Rendre le cours disponible en téléchargement pour les étudiants inscrits",
|
"course_avail": "Téléchargez une copie de ce cours.",
|
||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"allow": "Autoriser les nouveaux étudiants",
|
"allow": "Autoriser les nouveaux étudiants",
|
||||||
"access": "Autoriser les nouveaux étudiants à accéder à ce cours",
|
"access": "Autoriser les nouveaux étudiants à accéder à ce cours",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Débloquez tout"
|
"unlock_all": "Débloquez tout"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Définir d'abord la date limite du certificat",
|
"certificate_deadline_required": "Définir d'abord la date limite du certificat",
|
||||||
"certificate_deadline_required_description": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits. Veuillez définir une date limite dans les paramètres du certificat avant de publier.",
|
"certificate_deadline_required_description": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits. Veuillez définir une date limite dans les paramètres du cours avant de publier.",
|
||||||
"go_to_certificate_settings": "Accédez aux paramètres du certificat"
|
"go_to_completion_deadline": "Accéder à la date limite d'achèvement",
|
||||||
|
"completion_deadline_label": "Date limite de fin de cours",
|
||||||
|
"completion_deadline_helper": "Les étudiants doivent atteindre le pourcentage d'achèvement avant cette date/heure (heure locale). Laisser vide sans date limite."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analytique"
|
"title": "Analytique"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Message électronique",
|
"email_message_label": "Message électronique",
|
||||||
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !",
|
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !",
|
||||||
"final_exercise_multiple_attempts_note": "Les étudiants auront droit à des tentatives illimitées sur l'exercice sélectionné.",
|
"final_exercise_multiple_attempts_note": "Les étudiants auront droit à des tentatives illimitées sur l'exercice sélectionné.",
|
||||||
"deadline_required": "La date limite du certificat est requise lorsque les certificats sont téléchargeables.",
|
"deadline_required": "Les cours de conformité nécessitent une date limite d'achèvement avant de pouvoir être publiés.",
|
||||||
"deadline_required_enroll": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits."
|
"deadline_required_enroll": "Les cours de conformité nécessitent une date limite de certificat avant que les apprenants puissent être inscrits.",
|
||||||
|
"deadline_invalid": "La date limite d'achèvement du cours n'est pas valide."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Quel cours souhaitez-vous créer ?",
|
"heading": "Quel cours souhaitez-vous créer ?",
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
"lesson_download": "पाठ डाउनलोड करें",
|
"lesson_download": "पाठ डाउनलोड करें",
|
||||||
"available": "पंजीकृत छात्रों के लिए पाठ को पीडीएफ में डाउनलोड के लिए उपलब्ध कराएं",
|
"available": "पंजीकृत छात्रों के लिए पाठ को पीडीएफ में डाउनलोड के लिए उपलब्ध कराएं",
|
||||||
"course_download": "कोर्स डाउनलोड करें",
|
"course_download": "कोर्स डाउनलोड करें",
|
||||||
"course_avail": "पंजीकृत छात्रों के लिए पाठ्यक्रम को डाउनलोड के लिए उपलब्ध कराएं",
|
"course_avail": "इस पाठ्यक्रम की एक प्रति डाउनलोड करें।",
|
||||||
"download": "डाउनलोड करें",
|
"download": "डाउनलोड करें",
|
||||||
"allow": "नए छात्रों को अनुमति दें",
|
"allow": "नए छात्रों को अनुमति दें",
|
||||||
"access": "नए छात्रों को इस पाठ्यक्रम तक पहुंचने की अनुमति दें",
|
"access": "नए छात्रों को इस पाठ्यक्रम तक पहुंचने की अनुमति दें",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "सबको अनलॉक करें"
|
"unlock_all": "सबको अनलॉक करें"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "पहले प्रमाणपत्र की समय सीमा निर्धारित करें",
|
"certificate_deadline_required": "पहले प्रमाणपत्र की समय सीमा निर्धारित करें",
|
||||||
"certificate_deadline_required_description": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है। कृपया प्रकाशन से पहले प्रमाणपत्र सेटिंग में एक समय सीमा निर्धारित करें।",
|
"certificate_deadline_required_description": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है। कृपया प्रकाशन से पहले पाठ्यक्रम सेटिंग में एक समय सीमा निर्धारित करें।",
|
||||||
"go_to_certificate_settings": "प्रमाणपत्र सेटिंग पर जाएं"
|
"go_to_completion_deadline": "समापन समय सीमा पर जाएं",
|
||||||
|
"completion_deadline_label": "कोर्स पूरा करने की समय सीमा",
|
||||||
|
"completion_deadline_helper": "छात्रों को इस दिनांक समय (स्थानीय समय) से पहले पूर्णता प्रतिशत पूरा करना होगा। बिना किसी समय सीमा के खाली छोड़ दें."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "विश्लेषिकी"
|
"title": "विश्लेषिकी"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "ईमेल संदेश",
|
"email_message_label": "ईमेल संदेश",
|
||||||
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!",
|
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!",
|
||||||
"final_exercise_multiple_attempts_note": "छात्रों को चयनित अभ्यास पर असीमित प्रयासों की अनुमति दी जाएगी।",
|
"final_exercise_multiple_attempts_note": "छात्रों को चयनित अभ्यास पर असीमित प्रयासों की अनुमति दी जाएगी।",
|
||||||
"deadline_required": "प्रमाणपत्र डाउनलोड करने योग्य होने पर प्रमाणपत्र की समय सीमा आवश्यक है।",
|
"deadline_required": "अनुपालन पाठ्यक्रमों को प्रकाशित करने से पहले पूरा करने की समय सीमा आवश्यक है।",
|
||||||
"deadline_required_enroll": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है।"
|
"deadline_required_enroll": "अनुपालन पाठ्यक्रमों में शिक्षार्थियों के नामांकन से पहले प्रमाणपत्र की समय सीमा की आवश्यकता होती है।",
|
||||||
|
"deadline_invalid": "पाठ्यक्रम पूरा करने की समय सीमा अमान्य है।"
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "आप कौन सा कोर्स बनाना चाहते हैं?",
|
"heading": "आप कौन सा कोर्स बनाना चाहते हैं?",
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
"lesson_download": "Lekcje do pobrania",
|
"lesson_download": "Lekcje do pobrania",
|
||||||
"available": "Udostępnij lekcję do pobrania w formacie PDF zarejestrowanym studentom",
|
"available": "Udostępnij lekcję do pobrania w formacie PDF zarejestrowanym studentom",
|
||||||
"course_download": "Pobieranie kursu",
|
"course_download": "Pobieranie kursu",
|
||||||
"course_avail": "Udostępnij kurs do pobrania zarejestrowanym studentom",
|
"course_avail": "Pobierz kopię tego kursu.",
|
||||||
"download": "Pobierz",
|
"download": "Pobierz",
|
||||||
"allow": "Zezwalaj na nowych uczniów",
|
"allow": "Zezwalaj na nowych uczniów",
|
||||||
"access": "Zezwól nowym studentom na dostęp do tego kursu",
|
"access": "Zezwól nowym studentom na dostęp do tego kursu",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Odblokuj wszystko"
|
"unlock_all": "Odblokuj wszystko"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Najpierw ustal termin wydania certyfikatu",
|
"certificate_deadline_required": "Najpierw ustal termin wydania certyfikatu",
|
||||||
"certificate_deadline_required_description": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników. Przed publikacją ustaw termin w ustawieniach certyfikatu.",
|
"certificate_deadline_required_description": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników. Przed publikacją ustaw termin w ustawieniach kursu.",
|
||||||
"go_to_certificate_settings": "Przejdź do Ustawień certyfikatu"
|
"go_to_completion_deadline": "Przejdź do terminu ukończenia",
|
||||||
|
"completion_deadline_label": "Termin zakończenia kursu",
|
||||||
|
"completion_deadline_helper": "Studenci muszą osiągnąć procent ukończenia przed tą datą (czasu lokalnego). Pozostaw puste bez terminu."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Analityka"
|
"title": "Analityka"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Wiadomość e-mail",
|
"email_message_label": "Wiadomość e-mail",
|
||||||
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!",
|
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!",
|
||||||
"final_exercise_multiple_attempts_note": "Studenci będą mieli nieograniczoną liczbę prób wykonania wybranego ćwiczenia.",
|
"final_exercise_multiple_attempts_note": "Studenci będą mieli nieograniczoną liczbę prób wykonania wybranego ćwiczenia.",
|
||||||
"deadline_required": "W przypadku możliwości pobrania certyfikatów wymagany jest termin wydania certyfikatu.",
|
"deadline_required": "Kursy zgodności wymagają terminu ukończenia przed publikacją.",
|
||||||
"deadline_required_enroll": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników."
|
"deadline_required_enroll": "Kursy zgodności wymagają wystawienia certyfikatu przed zapisaniem się na nie uczestników.",
|
||||||
|
"deadline_invalid": "Termin ukończenia kursu jest nieprawidłowy."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Jaki kurs chcesz stworzyć?",
|
"heading": "Jaki kurs chcesz stworzyć?",
|
||||||
|
|||||||
@@ -792,10 +792,10 @@
|
|||||||
"progression_mode_description": "Controle se os alunos podem acessar todo o conteúdo desbloqueado ou devem concluir os itens em ordem.",
|
"progression_mode_description": "Controle se os alunos podem acessar todo o conteúdo desbloqueado ou devem concluir os itens em ordem.",
|
||||||
"progression_mode_free": "Livre — conteúdo desbloqueado é acessível",
|
"progression_mode_free": "Livre — conteúdo desbloqueado é acessível",
|
||||||
"progression_mode_sequential": "Sequencial — concluir itens anteriores para desbloquear o próximo",
|
"progression_mode_sequential": "Sequencial — concluir itens anteriores para desbloquear o próximo",
|
||||||
"lesson_download": "Lições baixadas",
|
"lesson_download": "Download das lições",
|
||||||
"available": "Disponibilizar a Lição para Download em PDF para alunos matriculados",
|
"available": "Disponibilizar a Lição para Download em PDF para alunos matriculados",
|
||||||
"course_download": "Download do curso",
|
"course_download": "Download do curso",
|
||||||
"course_avail": "Disponibilizar o Curso para Download para alunos cadastrados",
|
"course_avail": "Baixe uma cópia deste curso.",
|
||||||
"download": "Baixar",
|
"download": "Baixar",
|
||||||
"allow": "Permitir novos alunos",
|
"allow": "Permitir novos alunos",
|
||||||
"access": "Permitir que novos alunos acessem este curso",
|
"access": "Permitir que novos alunos acessem este curso",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Desbloquear tudo"
|
"unlock_all": "Desbloquear tudo"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Defina o prazo do certificado primeiro",
|
"certificate_deadline_required": "Defina o prazo do certificado primeiro",
|
||||||
"certificate_deadline_required_description": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados. Defina um prazo nas configurações do certificado antes de publicar.",
|
"certificate_deadline_required_description": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados. Defina um prazo nas configurações do curso antes de publicar.",
|
||||||
"go_to_certificate_settings": "Vá para configurações de certificado"
|
"go_to_completion_deadline": "Ir para o prazo de conclusão",
|
||||||
|
"completion_deadline_label": "Prazo para conclusão do curso",
|
||||||
|
"completion_deadline_helper": "Os alunos devem atingir o percentual de conclusão antes desta data e hora (horário local). Deixe em branco para não ter prazo."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Análises"
|
"title": "Análises"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Mensagem de e-mail",
|
"email_message_label": "Mensagem de e-mail",
|
||||||
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!",
|
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!",
|
||||||
"final_exercise_multiple_attempts_note": "Os alunos terão permissão para tentativas ilimitadas no exercício selecionado.",
|
"final_exercise_multiple_attempts_note": "Os alunos terão permissão para tentativas ilimitadas no exercício selecionado.",
|
||||||
"deadline_required": "O prazo do certificado é necessário quando os certificados podem ser baixados.",
|
"deadline_required": "Os cursos de conformidade exigem um prazo de conclusão antes de serem publicados.",
|
||||||
"deadline_required_enroll": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados."
|
"deadline_required_enroll": "Os cursos de conformidade exigem um prazo de certificação antes que os alunos possam ser matriculados.",
|
||||||
|
"deadline_invalid": "O prazo de conclusão do curso é inválido."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Qual curso você deseja criar?",
|
"heading": "Qual curso você deseja criar?",
|
||||||
|
|||||||
@@ -792,10 +792,10 @@
|
|||||||
"progression_mode_description": "Управляйте доступом студентов ко всему разблокированному контенту или требуйте прохождения по порядку.",
|
"progression_mode_description": "Управляйте доступом студентов ко всему разблокированному контенту или требуйте прохождения по порядку.",
|
||||||
"progression_mode_free": "Свободный — разблокированный контент доступен",
|
"progression_mode_free": "Свободный — разблокированный контент доступен",
|
||||||
"progression_mode_sequential": "Последовательный — завершите предыдущие элементы, чтобы открыть следующий",
|
"progression_mode_sequential": "Последовательный — завершите предыдущие элементы, чтобы открыть следующий",
|
||||||
"lesson_download": "Уроки СкачатьСкачать",
|
"lesson_download": "Скачивание уроков",
|
||||||
"available": "Сделайте урок доступным для скачивания в формате pdf для зарегистрированных студентов.",
|
"available": "Сделайте урок доступным для скачивания в формате pdf для зарегистрированных студентов.",
|
||||||
"course_download": "Загрузка курса",
|
"course_download": "Загрузка курса",
|
||||||
"course_avail": "Сделать курс доступным для скачивания зарегистрированным студентам",
|
"course_avail": "Скачайте копию этого курса.",
|
||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"allow": "Разрешить новым студентам",
|
"allow": "Разрешить новым студентам",
|
||||||
"access": "Разрешить новым студентам доступ к этому курсу",
|
"access": "Разрешить новым студентам доступ к этому курсу",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Разблокировать все"
|
"unlock_all": "Разблокировать все"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Сначала установите срок действия сертификата",
|
"certificate_deadline_required": "Сначала установите срок действия сертификата",
|
||||||
"certificate_deadline_required_description": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены. Перед публикацией установите крайний срок в настройках сертификата.",
|
"certificate_deadline_required_description": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены. Перед публикацией установите крайний срок в настройках курса.",
|
||||||
"go_to_certificate_settings": "Зайдите в настройки сертификата"
|
"go_to_completion_deadline": "Перейти к сроку завершения",
|
||||||
|
"completion_deadline_label": "Срок завершения курса",
|
||||||
|
"completion_deadline_helper": "Учащиеся должны достичь процента завершения до этой даты и времени (по местному времени). Оставьте пустым, если срок не установлен."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Аналитика"
|
"title": "Аналитика"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Сообщение электронной почты",
|
"email_message_label": "Сообщение электронной почты",
|
||||||
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!",
|
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!",
|
||||||
"final_exercise_multiple_attempts_note": "Студентам будет разрешено неограниченное количество попыток выполнения выбранного упражнения.",
|
"final_exercise_multiple_attempts_note": "Студентам будет разрешено неограниченное количество попыток выполнения выбранного упражнения.",
|
||||||
"deadline_required": "Крайний срок сертификата требуется, если сертификаты можно загрузить.",
|
"deadline_required": "Для публикации курсов по соблюдению требований необходимо указать срок завершения.",
|
||||||
"deadline_required_enroll": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены."
|
"deadline_required_enroll": "Для прохождения курсов по соблюдению требований требуется крайний срок получения сертификата, прежде чем учащиеся смогут быть зачислены.",
|
||||||
|
"deadline_invalid": "Срок завершения курса указан неверно."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Какой курс вы хотите создать?",
|
"heading": "Какой курс вы хотите создать?",
|
||||||
|
|||||||
@@ -795,7 +795,7 @@
|
|||||||
"lesson_download": "Bài học Tải về",
|
"lesson_download": "Bài học Tải về",
|
||||||
"available": "Cung cấp Bài học để tải xuống dưới dạng pdf cho học viên đã đăng ký",
|
"available": "Cung cấp Bài học để tải xuống dưới dạng pdf cho học viên đã đăng ký",
|
||||||
"course_download": "Tải xuống khóa học",
|
"course_download": "Tải xuống khóa học",
|
||||||
"course_avail": "Cung cấp Khóa học để tải xuống cho sinh viên đã đăng ký",
|
"course_avail": "Tải xuống một bản sao của khóa học này.",
|
||||||
"download": "Tải xuống",
|
"download": "Tải xuống",
|
||||||
"allow": "Cho phép sinh viên mới",
|
"allow": "Cho phép sinh viên mới",
|
||||||
"access": "Cho phép sinh viên mới truy cập khóa học này",
|
"access": "Cho phép sinh viên mới truy cập khóa học này",
|
||||||
@@ -860,8 +860,10 @@
|
|||||||
"unlock_all": "Mở khóa tất cả"
|
"unlock_all": "Mở khóa tất cả"
|
||||||
},
|
},
|
||||||
"certificate_deadline_required": "Đặt thời hạn chứng chỉ đầu tiên",
|
"certificate_deadline_required": "Đặt thời hạn chứng chỉ đầu tiên",
|
||||||
"certificate_deadline_required_description": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký. Vui lòng đặt thời hạn trong cài đặt chứng chỉ trước khi xuất bản.",
|
"certificate_deadline_required_description": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký. Vui lòng đặt thời hạn trong cài đặt khóa học trước khi xuất bản.",
|
||||||
"go_to_certificate_settings": "Đi tới Cài đặt chứng chỉ"
|
"go_to_completion_deadline": "Đi tới thời hạn hoàn thành",
|
||||||
|
"completion_deadline_label": "Thời hạn hoàn thành khóa học",
|
||||||
|
"completion_deadline_helper": "Học sinh phải đáp ứng tỷ lệ hoàn thành trước ngày này (giờ địa phương). Để trống không có thời hạn."
|
||||||
},
|
},
|
||||||
"analytics": {
|
"analytics": {
|
||||||
"title": "Phân tích"
|
"title": "Phân tích"
|
||||||
@@ -2479,8 +2481,9 @@
|
|||||||
"email_message_label": "Tin nhắn email",
|
"email_message_label": "Tin nhắn email",
|
||||||
"email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!",
|
"email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!",
|
||||||
"final_exercise_multiple_attempts_note": "Học sinh sẽ được phép thử không giới hạn bài tập đã chọn.",
|
"final_exercise_multiple_attempts_note": "Học sinh sẽ được phép thử không giới hạn bài tập đã chọn.",
|
||||||
"deadline_required": "Thời hạn cấp chứng chỉ là bắt buộc khi có thể tải xuống chứng chỉ.",
|
"deadline_required": "Các khóa học tuân thủ yêu cầu thời hạn hoàn thành trước khi có thể xuất bản.",
|
||||||
"deadline_required_enroll": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký."
|
"deadline_required_enroll": "Các khóa học tuân thủ yêu cầu thời hạn cấp chứng chỉ trước khi người học có thể đăng ký.",
|
||||||
|
"deadline_invalid": "Thời hạn hoàn thành khóa học không hợp lệ."
|
||||||
},
|
},
|
||||||
"creator": {
|
"creator": {
|
||||||
"heading": "Bạn muốn tạo khóa học nào?",
|
"heading": "Bạn muốn tạo khóa học nào?",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
createCourseSections,
|
createCourseSections,
|
||||||
deleteCourse as deleteCourseQuery,
|
deleteCourse as deleteCourseQuery,
|
||||||
getCourseProgress as getCourseProgressQuery,
|
getCourseProgress as getCourseProgressQuery,
|
||||||
getCourseTypeById,
|
getCourseById,
|
||||||
getCourseWithRelations,
|
getCourseWithRelations,
|
||||||
updateCourse as updateCourseQuery,
|
updateCourse as updateCourseQuery,
|
||||||
updateLessonsSectionId
|
updateLessonsSectionId
|
||||||
@@ -28,6 +28,7 @@ import { getStudentCourseProgressImpactCounts } from '@cio/db/queries/course/res
|
|||||||
import { getCourseGroupId } from '@cio/db/queries/course/people';
|
import { getCourseGroupId } from '@cio/db/queries/course/people';
|
||||||
|
|
||||||
import { ContentType, ROLE } from '@cio/utils/constants';
|
import { ContentType, ROLE } from '@cio/utils/constants';
|
||||||
|
import { isPublishedComplianceMissingDeadline, resolveCourseCertificateDeadline } from '@cio/utils/functions';
|
||||||
import type { TCourse } from '@cio/db/types';
|
import type { TCourse } from '@cio/db/types';
|
||||||
import type { TCourseCreate } from '@cio/utils/validation/course';
|
import type { TCourseCreate } from '@cio/utils/validation/course';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
@@ -338,16 +339,38 @@ export async function updateCourse(courseId: string, data: Partial<TCourse>) {
|
|||||||
slug: data.slug
|
slug: data.slug
|
||||||
};
|
};
|
||||||
|
|
||||||
if (data.type !== undefined) {
|
const [currentCourse] = await getCourseById(courseId);
|
||||||
const currentType = await getCourseTypeById(courseId);
|
if (!currentCourse) {
|
||||||
|
throw new AppError('Course not found', ErrorCodes.COURSE_NOT_FOUND, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type !== undefined) {
|
||||||
await guardCourseTypeTransition({
|
await guardCourseTypeTransition({
|
||||||
courseId,
|
courseId,
|
||||||
currentType: currentType ?? null,
|
currentType: currentCourse.type ?? null,
|
||||||
nextType: data.type
|
nextType: data.type
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextType = data.type ?? currentCourse.type;
|
||||||
|
const nextIsPublished = data.isPublished ?? currentCourse.isPublished;
|
||||||
|
const nextDeadline = resolveCourseCertificateDeadline(currentCourse.certificate?.deadline, data.certificate);
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: nextType,
|
||||||
|
isPublished: nextIsPublished,
|
||||||
|
deadline: nextDeadline
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new AppError(
|
||||||
|
'Compliance courses require a completion deadline before they can be published',
|
||||||
|
ErrorCodes.COMPLIANCE_DEADLINE_REQUIRED,
|
||||||
|
400,
|
||||||
|
'certificate.deadline'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.certificate?.requiredExerciseId) {
|
if (data.certificate?.requiredExerciseId) {
|
||||||
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
const ok = await exerciseBelongsToCourse(data.certificate.requiredExerciseId, courseId);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { getCourseContentItems, type CourseContentItemRow } from '@cio/db/querie
|
|||||||
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
import { getCourseOrganizationId } from '@cio/db/queries/tag';
|
||||||
import { getOrganizationById } from '@cio/db/queries/organization';
|
import { getOrganizationById } from '@cio/db/queries/organization';
|
||||||
import type { TCourse, TOrganization } from '@cio/db/types';
|
import type { TCourse, TOrganization } from '@cio/db/types';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug, isPublishedComplianceMissingDeadline } from '@cio/utils/functions';
|
||||||
import { ContentType } from '@cio/utils/constants';
|
import { ContentType } from '@cio/utils/constants';
|
||||||
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
import type { TCourseLandingPageUpdate } from '@cio/utils/validation/course';
|
||||||
|
|
||||||
@@ -32,7 +32,17 @@ export type CourseGoLiveReadiness = {
|
|||||||
type CourseReadinessInput = {
|
type CourseReadinessInput = {
|
||||||
course: Pick<
|
course: Pick<
|
||||||
TCourse,
|
TCourse,
|
||||||
'id' | 'title' | 'description' | 'overview' | 'slug' | 'logo' | 'bannerImage' | 'metadata' | 'type' | 'cost'
|
| 'id'
|
||||||
|
| 'title'
|
||||||
|
| 'description'
|
||||||
|
| 'overview'
|
||||||
|
| 'slug'
|
||||||
|
| 'logo'
|
||||||
|
| 'bannerImage'
|
||||||
|
| 'metadata'
|
||||||
|
| 'type'
|
||||||
|
| 'cost'
|
||||||
|
| 'certificate'
|
||||||
>;
|
>;
|
||||||
contentItems: CourseContentItemRow[];
|
contentItems: CourseContentItemRow[];
|
||||||
suggestedSlug?: string;
|
suggestedSlug?: string;
|
||||||
@@ -190,6 +200,22 @@ export function evaluateCourseGoLiveReadiness(input: CourseReadinessInput): Cour
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (course.type === 'COMPLIANCE') {
|
if (course.type === 'COMPLIANCE') {
|
||||||
|
if (
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: course.type,
|
||||||
|
isPublished: true,
|
||||||
|
deadline: course.certificate?.deadline
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
blockers.push(
|
||||||
|
buildIssue(
|
||||||
|
'COMPLIANCE_DEADLINE_MISSING',
|
||||||
|
'Compliance courses require a completion deadline before they can be published.',
|
||||||
|
'course.certificate.deadline'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
warnings.push(
|
warnings.push(
|
||||||
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
buildIssue('COMPLIANCE_REVIEW_RECOMMENDED', 'Review compliance settings before publishing.', 'course.compliance')
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export const ErrorCodes = {
|
|||||||
SLUG_IN_USE: 'SLUG_IN_USE',
|
SLUG_IN_USE: 'SLUG_IN_USE',
|
||||||
QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE: 'QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE',
|
QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE: 'QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE',
|
||||||
PUBLIC_COURSE_CONVERSION_BLOCKED: 'PUBLIC_COURSE_CONVERSION_BLOCKED',
|
PUBLIC_COURSE_CONVERSION_BLOCKED: 'PUBLIC_COURSE_CONVERSION_BLOCKED',
|
||||||
|
COMPLIANCE_DEADLINE_REQUIRED: 'COMPLIANCE_DEADLINE_REQUIRED',
|
||||||
|
|
||||||
// Lesson errors
|
// Lesson errors
|
||||||
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
|
LESSON_NOT_FOUND: 'LESSON_NOT_FOUND',
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* A published COMPLIANCE course must have a completion deadline.
|
||||||
|
* Draft courses, non-compliance types, and downloadable certificates do not require one.
|
||||||
|
*/
|
||||||
|
export function isPublishedComplianceMissingDeadline(params: {
|
||||||
|
type: string | null | undefined;
|
||||||
|
isPublished: boolean | null | undefined;
|
||||||
|
deadline: string | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (params.isPublished !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.type !== 'COMPLIANCE') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !params.deadline?.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Course updates replace the certificate JSON value when `certificate` is sent.
|
||||||
|
* If the patch omits `deadline`, the stored deadline is cleared.
|
||||||
|
*/
|
||||||
|
export function resolveCourseCertificateDeadline(
|
||||||
|
currentDeadline: string | null | undefined,
|
||||||
|
certificatePatch: { deadline?: string | null } | null | undefined
|
||||||
|
): string | null | undefined {
|
||||||
|
if (certificatePatch === undefined) {
|
||||||
|
return currentDeadline;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (certificatePatch === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return certificatePatch.deadline ?? null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './array';
|
export * from './array';
|
||||||
|
export * from './compliance-deadline';
|
||||||
export * from './course-progression';
|
export * from './course-progression';
|
||||||
export * from './currency';
|
export * from './currency';
|
||||||
export * from './fileValidation';
|
export * from './fileValidation';
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isPublishedComplianceMissingDeadline,
|
||||||
|
resolveCourseCertificateDeadline
|
||||||
|
} from '../src/functions/compliance-deadline';
|
||||||
|
|
||||||
|
describe('isPublishedComplianceMissingDeadline', () => {
|
||||||
|
it('is false for unpublished compliance courses', () => {
|
||||||
|
expect(
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
isPublished: false,
|
||||||
|
deadline: null
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for published non-compliance courses without a deadline', () => {
|
||||||
|
expect(
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: 'SELF_PACED',
|
||||||
|
isPublished: true,
|
||||||
|
deadline: null
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true for published compliance courses without a deadline', () => {
|
||||||
|
expect(
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
isPublished: true,
|
||||||
|
deadline: null
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats blank deadlines as missing', () => {
|
||||||
|
expect(
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
isPublished: true,
|
||||||
|
deadline: ' '
|
||||||
|
})
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false when a published compliance course has a deadline', () => {
|
||||||
|
expect(
|
||||||
|
isPublishedComplianceMissingDeadline({
|
||||||
|
type: 'COMPLIANCE',
|
||||||
|
isPublished: true,
|
||||||
|
deadline: '2026-12-31T23:59:00.000Z'
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('resolveCourseCertificateDeadline', () => {
|
||||||
|
it('keeps the stored deadline when certificate is not in the patch', () => {
|
||||||
|
expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', undefined)).toBe('2026-12-31T23:59:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the deadline when the certificate patch omits it', () => {
|
||||||
|
expect(resolveCourseCertificateDeadline('2026-12-31T23:59:00.000Z', {})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the patched deadline when provided', () => {
|
||||||
|
expect(resolveCourseCertificateDeadline('2026-01-01T00:00:00.000Z', { deadline: '2026-12-31T23:59:00.000Z' })).toBe(
|
||||||
|
'2026-12-31T23:59:00.000Z'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user