2026-03-22 18:42:23 +01:00
|
|
|
<script lang="ts">
|
2026-03-30 08:24:55 +01:00
|
|
|
import { onDestroy } from 'svelte';
|
2026-03-22 18:42:23 +01:00
|
|
|
import { SvelteSet } from 'svelte/reactivity';
|
|
|
|
|
import { Button } from '@cio/ui/base/button';
|
|
|
|
|
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
|
2026-03-30 08:24:55 +01:00
|
|
|
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
2026-03-22 18:42:23 +01:00
|
|
|
import { t } from '$lib/utils/functions/translations';
|
|
|
|
|
import { snackbar } from '$features/ui/snackbar/store';
|
|
|
|
|
import { orgApi } from '$features/org/api/org.svelte';
|
2026-03-30 08:24:55 +01:00
|
|
|
import { currentOrg } from '$lib/utils/store/org';
|
|
|
|
|
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
2026-03-22 18:42:23 +01:00
|
|
|
import { courseApi } from '$features/course/api';
|
|
|
|
|
import { profile } from '$lib/utils/store/user';
|
|
|
|
|
import type { OrgStudent } from './types';
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
courseId: string;
|
|
|
|
|
students: OrgStudent[];
|
|
|
|
|
isLoading?: boolean;
|
|
|
|
|
onAssigned?: () => void | Promise<void>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
|
|
|
|
|
|
|
|
|
|
let searchValue = $state('');
|
2026-03-30 08:24:55 +01:00
|
|
|
let isSearching = $state(false);
|
|
|
|
|
let lastSearchValue = $state('');
|
|
|
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
let activeSearchRequestId = $state(0);
|
2026-03-22 18:42:23 +01:00
|
|
|
const selectedProfileIds = new SvelteSet<string>();
|
|
|
|
|
let sendEmail = $state(true);
|
|
|
|
|
let isSubmitting = $state(false);
|
2026-03-30 08:24:55 +01:00
|
|
|
const SEARCH_DEBOUNCE_MS = 350;
|
2026-03-22 18:42:23 +01:00
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
const sortedStudents = $derived.by(() =>
|
|
|
|
|
[...students].sort((a, b) =>
|
2026-03-22 18:42:23 +01:00
|
|
|
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
|
2026-03-30 08:24:55 +01:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
const showLoadingState = $derived(isLoading || isSearching);
|
|
|
|
|
const emptyMessage = $derived(
|
|
|
|
|
searchValue.trim()
|
|
|
|
|
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
|
|
|
|
|
: $t('course.navItem.people.invite_modal.no_existing_students')
|
|
|
|
|
);
|
2026-03-22 18:42:23 +01:00
|
|
|
|
|
|
|
|
const selectedCount = $derived(selectedProfileIds.size);
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
const multiSelectItems = $derived(
|
|
|
|
|
sortedStudents
|
|
|
|
|
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
|
|
|
|
|
.map((s) => ({
|
|
|
|
|
id: s.profileId,
|
|
|
|
|
label: s.name || s.email,
|
|
|
|
|
description: s.email
|
|
|
|
|
}))
|
|
|
|
|
);
|
|
|
|
|
|
2026-03-22 18:42:23 +01:00
|
|
|
function toggleStudent(profileId: string) {
|
|
|
|
|
if (selectedProfileIds.has(profileId)) {
|
|
|
|
|
selectedProfileIds.delete(profileId);
|
|
|
|
|
} else {
|
|
|
|
|
selectedProfileIds.add(profileId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
async function searchStudents(value: string) {
|
|
|
|
|
const normalizedValue = value.trim();
|
|
|
|
|
if (normalizedValue === lastSearchValue) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastSearchValue = normalizedValue;
|
|
|
|
|
const orgId = $currentOrg.id;
|
|
|
|
|
if (!orgId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const requestId = activeSearchRequestId + 1;
|
|
|
|
|
activeSearchRequestId = requestId;
|
|
|
|
|
isSearching = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await orgApi.getOrgAudience(
|
|
|
|
|
orgId,
|
|
|
|
|
{
|
|
|
|
|
...DEFAULT_ORG_AUDIENCE_QUERY,
|
|
|
|
|
search: normalizedValue || undefined
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
abortPrevious: true
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} finally {
|
|
|
|
|
if (requestId === activeSearchRequestId) {
|
|
|
|
|
isSearching = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleSearchValueChange(value: string) {
|
|
|
|
|
searchValue = value;
|
|
|
|
|
|
|
|
|
|
if (searchTimeout) {
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!value.trim()) {
|
|
|
|
|
void searchStudents(value);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
searchTimeout = setTimeout(() => {
|
|
|
|
|
void searchStudents(value);
|
|
|
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
orgApi.cancelAudienceRequest();
|
|
|
|
|
|
|
|
|
|
if (!searchTimeout) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
clearTimeout(searchTimeout);
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-22 18:42:23 +01:00
|
|
|
async function assignStudents() {
|
|
|
|
|
if (!courseId) return;
|
2026-03-30 08:24:55 +01:00
|
|
|
|
2026-03-22 18:42:23 +01:00
|
|
|
if (selectedProfileIds.size === 0) {
|
|
|
|
|
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
isSubmitting = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await orgApi.assignAudienceToCourses({
|
|
|
|
|
profileIds: [...selectedProfileIds],
|
|
|
|
|
courseIds: [courseId],
|
|
|
|
|
sendEmail
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (result) {
|
|
|
|
|
await courseApi.refreshCourse(courseId, $profile.id);
|
|
|
|
|
selectedProfileIds.clear();
|
|
|
|
|
searchValue = '';
|
|
|
|
|
await onAssigned?.();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Failed to assign existing students to course', error);
|
|
|
|
|
snackbar.error('audience.assign.snackbar_failed');
|
|
|
|
|
} finally {
|
|
|
|
|
isSubmitting = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
|
2026-03-30 08:24:55 +01:00
|
|
|
<div class="">
|
|
|
|
|
<div class="space-y-2">
|
|
|
|
|
<div class="max-h-72 overflow-y-auto">
|
|
|
|
|
<MultiSelectList
|
|
|
|
|
class="border-0"
|
|
|
|
|
{emptyMessage}
|
|
|
|
|
items={multiSelectItems}
|
|
|
|
|
isLoading={showLoadingState}
|
|
|
|
|
isSelected={(id) => selectedProfileIds.has(id)}
|
|
|
|
|
onSearchValueChange={handleSearchValueChange}
|
|
|
|
|
onToggle={(id) => toggleStudent(id)}
|
|
|
|
|
namePrefix="existing-student"
|
|
|
|
|
listClass="max-h-60"
|
|
|
|
|
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
|
|
|
|
|
bind:searchValue
|
|
|
|
|
>
|
|
|
|
|
{#snippet headingSnippet()}
|
|
|
|
|
<p class="ui:text-sm ui:font-medium">
|
|
|
|
|
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
|
|
|
|
|
{#if selectedCount > 0}
|
|
|
|
|
<span class="ui:text-muted-foreground text-xs"
|
|
|
|
|
>{$t('audience.selected_count', { count: selectedCount })}</span
|
|
|
|
|
>
|
|
|
|
|
{/if}
|
|
|
|
|
</p>
|
|
|
|
|
{/snippet}
|
|
|
|
|
</MultiSelectList>
|
2026-03-22 18:42:23 +01:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<CheckboxField
|
|
|
|
|
name="notify-existing-students"
|
|
|
|
|
label={$t('course.navItem.people.invite_modal.notify_existing_students')}
|
|
|
|
|
bind:checked={sendEmail}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<div class="flex justify-end">
|
|
|
|
|
<Button variant="secondary" onclick={assignStudents} loading={isSubmitting} disabled={isLoading || isSubmitting}>
|
|
|
|
|
{$t('course.navItem.people.invite_modal.grant_access')}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|