Enhance audience management features with new components and API integrations

- Added `AssignCoursesModal`, `AudienceMemberRow`, `AudienceTable`, and related components for improved audience management UI.
- Implemented audience invite actions including resend and revoke functionality.
- Updated audience API to support importing members and assigning courses.
- Refactored audience data handling to include status and invite management.
- Improved overall user experience with pagination and selection features in the audience table.
This commit is contained in:
Rotimi Best
2026-03-22 18:42:23 +01:00
parent e9c4082f89
commit 3919ff6c2f
157 changed files with 13183 additions and 872 deletions
@@ -3,10 +3,10 @@
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { t } from '$lib/utils/functions/translations';
import { inviteSettingsStore, studentInviteLinkStore } from './store';
import { getInvitePayload } from './invite-utils';
import { snackbar } from '$features/ui/snackbar/store';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
const INVITE_MODAL = 'course.navItem.people.invite_modal';
@@ -19,72 +19,58 @@
let recipientCsv = $state('');
let sendInviteEmails = $state(true);
let isSubmitting = $state(false);
async function createEmailInvites() {
async function inviteNewStudents() {
if (!courseId) return;
if (!recipientCsv.trim()) {
snackbar.error(`${INVITE_MODAL}.snackbar_add_one_recipient`);
snackbar.error('audience.import.snackbar_no_emails');
return;
}
studentInviteLinkStore.update((s) => ({ ...s, isCreating: true }));
isSubmitting = true;
try {
const settings = $inviteSettingsStore;
const payload = getInvitePayload(settings);
const response = await peopleApi.createStudentInvite(courseId, {
...payload,
const response = await orgApi.importAudienceMembers({
recipientCsv,
courseIds: [courseId],
sendEmail: sendInviteEmails
});
const createdCount = response?.invites?.length || 0;
const sent = response?.delivery?.sent || 0;
const failed = response?.delivery?.failed || 0;
const duplicates = response?.duplicatesSkipped?.length || 0;
snackbar.success(
$t(`${INVITE_MODAL}.snackbar_email_invites_success`, {
count: createdCount,
sent,
failed,
duplicates
})
);
recipientCsv = '';
onInviteCreated?.();
if (response) {
recipientCsv = '';
await courseApi.refreshCourse(courseId, $profile.id);
await onInviteCreated?.();
}
} catch (error) {
console.error('Failed to create email invites', error);
snackbar.error(`${INVITE_MODAL}.snackbar_failed_create_email_invites`);
console.error('Failed to invite new students into organization', error);
snackbar.error('audience.import.snackbar_failed');
} finally {
studentInviteLinkStore.update((s) => ({ ...s, isCreating: false }));
isSubmitting = false;
}
}
const isCreating = $derived($studentInviteLinkStore.isCreating);
</script>
<div class="rounded-md border p-4">
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.direct_email_bulk`)}</p>
<p class="mb-2 text-base font-semibold">{$t(`${INVITE_MODAL}.invite_new_students_title`)}</p>
<p class="ui:text-muted-foreground mb-2 text-sm">
{$t(`${INVITE_MODAL}.direct_email_description`)}
{$t(`${INVITE_MODAL}.invite_new_students_description`)}
</p>
<TextareaField
label={$t(`${INVITE_MODAL}.recipient_emails`)}
label={$t('audience.import.emails_label')}
bind:value={recipientCsv}
rows={4}
className="w-full"
placeholder={$t(`${INVITE_MODAL}.recipient_emails_placeholder`)}
placeholder={$t('audience.import.emails_placeholder')}
/>
<CheckboxField
className="mt-2"
name="send-invite-emails"
label={$t(`${INVITE_MODAL}.send_invite_emails`)}
label={$t('audience.import.send_email')}
bind:checked={sendInviteEmails}
/>
<div class="mt-3">
<Button variant="secondary" onclick={createEmailInvites} loading={isCreating}>
{$t(`${INVITE_MODAL}.create_email_invites`)}
<Button variant="secondary" onclick={inviteNewStudents} loading={isSubmitting}>
{$t(`${INVITE_MODAL}.invite_new_students`)}
</Button>
</div>
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
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('');
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
const selectedCount = $derived(selectedProfileIds.size);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
} else {
selectedProfileIds.add(profileId);
}
}
async function assignStudents() {
if (!courseId) return;
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>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
</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>
@@ -13,19 +13,11 @@
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import type { OrgTeamMember } from '$lib/utils/types/org';
import {
DEFAULT_INVITE_SETTINGS_STATE,
DEFAULT_STUDENT_INVITE_LINK_STATE,
inviteSettingsStore,
studentInviteLinkStore
} from './store';
import type { Tutor } from './types';
import type { OrgStudent, Tutor } from './types';
import TutorSelectSection from './tutor-select-section.svelte';
import InviteSettingsSection from './invite-settings-section.svelte';
import ExistingStudentsSection from './existing-students-section.svelte';
import BulkEmailSection from './bulk-email-section.svelte';
import InviteListSection from './invite-list-section.svelte';
import type { InviteListItem } from './types';
let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]);
@@ -36,21 +28,9 @@
const selectedTutors = $derived(tutors.filter((t) => selectedIds.includes(t.id.toString())));
const INVITE_MODAL = 'course.navItem.people.invite_modal';
let existingInvites = $state<InviteListItem[]>([]);
let isLoadingInvites = $state(false);
let activeTab = $state<'tutors' | 'students'>('tutors');
let activeStudentTab = $state<'email' | 'history'>('email');
async function loadInvites() {
if (!courseId) return;
isLoadingInvites = true;
try {
const invites = await peopleApi.listStudentInvites(courseId);
existingInvites = (invites || []) as InviteListItem[];
} finally {
isLoadingInvites = false;
}
}
let isLoadingStudents = $state(false);
let activeTab = $state<'tutors' | 'students'>('students');
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, courseApi.group.people));
function getTutors(team: OrgTeamMember[]) {
const existingTutors = courseApi?.group?.tutors || [];
@@ -65,6 +45,22 @@
}));
}
function getAvailableStudents(students: OrgStudent[], courseMembers: typeof courseApi.group.people) {
const enrolledStudentIds = new Set(
courseMembers
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
.map((member) => member.profileId)
.filter((profileId): profileId is string => Boolean(profileId))
);
return students.filter((student) => {
if (!student.profileId) {
return false;
}
return !enrolledStudentIds.has(student.profileId);
});
}
function setTutors(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
@@ -77,6 +73,18 @@
});
}
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
} finally {
isLoadingStudents = false;
}
});
}
async function onSubmit() {
if (!selectedTutors.length) {
goto(resolve(page.url.pathname, {}));
@@ -105,11 +113,8 @@
$effect(() => {
if (!isOpen || !courseId) return;
untrack(() => {
activeTab = 'tutors';
activeStudentTab = 'email';
inviteSettingsStore.set({ ...DEFAULT_INVITE_SETTINGS_STATE });
studentInviteLinkStore.set({ ...DEFAULT_STUDENT_INVITE_LINK_STATE });
void loadInvites();
activeTab = 'students';
void loadStudents($currentOrg.id);
});
});
</script>
@@ -149,35 +154,8 @@
<UnderlineTabs.Content value="students">
<div class="mt-6 space-y-6">
<InviteSettingsSection />
<UnderlineTabs.Root bind:value={activeStudentTab}>
<UnderlineTabs.List class="flex flex-wrap">
<UnderlineTabs.Trigger value="email">
{$t(`${INVITE_MODAL}.direct_email_bulk`)}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="history">
{$t(`${INVITE_MODAL}.existing_invites`)}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="email">
<div class="mt-6">
<BulkEmailSection {courseId} onInviteCreated={loadInvites} />
</div>
</UnderlineTabs.Content>
<UnderlineTabs.Content value="history">
<div class="mt-6">
<InviteListSection
{courseId}
invites={existingInvites}
isLoading={isLoadingInvites}
onRefresh={loadInvites}
/>
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
<ExistingStudentsSection {courseId} students={availableStudents} isLoading={isLoadingStudents} />
<BulkEmailSection {courseId} />
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
@@ -1,5 +1,6 @@
import type { StudentInviteListItem, StudentInviteAuditItem } from '../../utils/types';
import type { OrganizationTeamMembers } from '$features/org/utils/types';
import type { OrganizationAudience } from '$features/org/utils/types';
import type { TCourseInvitePreset } from '@cio/utils/validation/course/invite';
export interface Profile {
@@ -30,6 +31,9 @@ export type Tutor = Pick<OrgTeamMemberItem, 'id' | 'profileId' | 'email'> & {
text: OrgTeamMemberItem['fullname'];
};
type OrgAudienceMemberItem = OrganizationAudience[number];
export type OrgStudent = OrgAudienceMemberItem;
/** Re-export API-driven invite types from course utils */
export type InviteListItem = StudentInviteListItem;
export type InviteAuditItem = StudentInviteAuditItem;