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:
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CheckboxField, MultiSelectList } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
selectedProfileIds: string[];
|
||||
courses: Course[];
|
||||
onclose: () => void;
|
||||
onassigned: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), selectedProfileIds, courses, onclose, onassigned }: Props = $props();
|
||||
|
||||
let fields = $state({
|
||||
selectedCourseIds: new SvelteSet<string>(),
|
||||
sendEmail: true
|
||||
});
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (fields.selectedCourseIds.has(courseId)) {
|
||||
fields.selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
fields.selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onclose();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (fields.selectedCourseIds.size === 0) {
|
||||
snackbar.error('audience.assign.snackbar_no_courses');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await orgApi.assignAudienceToCourses({
|
||||
profileIds: selectedProfileIds,
|
||||
courseIds: [...fields.selectedCourseIds],
|
||||
sendEmail: fields.sendEmail
|
||||
});
|
||||
|
||||
fields.selectedCourseIds.clear();
|
||||
fields.sendEmail = true;
|
||||
onassigned();
|
||||
} catch {
|
||||
snackbar.error('audience.assign.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(v) => !v && handleClose()}>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('audience.assign.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('audience.assign.description')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<MultiSelectList
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.assign.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => fields.selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="assign-course"
|
||||
/>
|
||||
|
||||
<CheckboxField name="assign-send-email" label={$t('audience.assign.send_email')} bind:checked={fields.sendEmail} />
|
||||
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button variant="outline" onclick={handleClose}>
|
||||
{$t('audience.assign.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onclick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
disabled={isSubmitting || fields.selectedCourseIds.size === 0}
|
||||
>
|
||||
{$t('audience.assign.submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UserIcon from '@lucide/svelte/icons/user';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
canResendAudienceInvite,
|
||||
canRevokeAudienceInvite,
|
||||
statusBadgeVariant,
|
||||
statusLabelKey
|
||||
} from '$features/audience/utils/audience-utils';
|
||||
|
||||
interface Props {
|
||||
row: OrganizationAudienceMember;
|
||||
memberDetailHref: string | null;
|
||||
selected: boolean;
|
||||
onToggleSelect: () => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
|
||||
$props();
|
||||
|
||||
const showInviteActions = $derived(
|
||||
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
|
||||
);
|
||||
</script>
|
||||
|
||||
<Table.Row>
|
||||
<Table.Cell class="w-10">
|
||||
<Checkbox disabled={!row.profileId} checked={selected} onCheckedChange={onToggleSelect} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if memberDetailHref}
|
||||
<a href={memberDetailHref} class="ui:text-primary flex items-center gap-2 hover:underline">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="flex items-center gap-2">
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
{#if row.avatarUrl}
|
||||
<Avatar.Image src={row.avatarUrl} alt={row.name ? row.name : 'User'} />
|
||||
{/if}
|
||||
<Avatar.Fallback>
|
||||
<UserIcon class="ui:size-3 ui:text-muted-foreground" />
|
||||
</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant={statusBadgeVariant(row.status)}>{$t(statusLabelKey(row.status))}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{#if showInviteActions}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
|
||||
aria-label={$t('audience.invite.row_actions_aria')}
|
||||
disabled={inviteActionEmail !== null}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
{#if canResendAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
|
||||
{$t('audience.invite.resend')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
{#if canRevokeAudienceInvite(row.status)}
|
||||
<DropdownMenu.Item
|
||||
class="ui:text-destructive focus:ui:text-destructive"
|
||||
disabled={inviteActionEmail === row.email}
|
||||
onclick={() => onRevokeInvite(row.email)}
|
||||
>
|
||||
{$t('audience.invite.revoke')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
interface Props {
|
||||
count: number;
|
||||
perPage: number;
|
||||
page: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
let { count, perPage, page, onPageChange }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root {count} {perPage} {page} {onPageChange}>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as pageItem (pageItem.key)}
|
||||
{#if pageItem.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
|
||||
{pageItem.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
interface Props {
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
let { rowCount = 20 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each Array(rowCount) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
hasSelection: boolean;
|
||||
selectedCount: number;
|
||||
searchValue?: string;
|
||||
onOpenAssign: () => void;
|
||||
}
|
||||
|
||||
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.BodyHeader>
|
||||
{#if hasSelection}
|
||||
<div class="flex items-center gap-3 rounded-md border px-4 py-2">
|
||||
<span class="ui:text-muted-foreground text-sm">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" onclick={onOpenAssign}>
|
||||
{$t('audience.assign_courses')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import type { OrganizationAudienceMember } from '$features/org/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import AudienceMemberRow from './audience-member-row.svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
interface Header {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
headers: Header[];
|
||||
rows: OrganizationAudienceMember[];
|
||||
allPageSelected: boolean;
|
||||
somePageSelected: boolean;
|
||||
onToggleSelectAll: () => void;
|
||||
isRowSelected: (rowId: string) => boolean;
|
||||
onToggleRow: (rowId: string) => void;
|
||||
inviteActionEmail: string | null;
|
||||
onResendInvite: (email: string) => void;
|
||||
onRevokeInvite: (email: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
headers,
|
||||
rows,
|
||||
allPageSelected,
|
||||
somePageSelected,
|
||||
onToggleSelectAll,
|
||||
isRowSelected,
|
||||
onToggleRow,
|
||||
inviteActionEmail,
|
||||
onResendInvite,
|
||||
onRevokeInvite
|
||||
}: Props = $props();
|
||||
|
||||
function memberHref(row: OrganizationAudienceMember): string | null {
|
||||
if (!row.profileId) return null;
|
||||
return resolve(`${window.location.pathname}/${row.profileId}`, {});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-10">
|
||||
<Checkbox
|
||||
checked={allPageSelected}
|
||||
indeterminate={somePageSelected}
|
||||
onCheckedChange={onToggleSelectAll}
|
||||
aria-label={$t('audience.select_all')}
|
||||
/>
|
||||
</Table.Head>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
<Table.Head class="w-12 text-right">
|
||||
<span class="sr-only">{$t('audience.invite.row_actions_aria')}</span>
|
||||
</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row (String(row.id))}
|
||||
<AudienceMemberRow
|
||||
{row}
|
||||
memberDetailHref={memberHref(row)}
|
||||
selected={isRowSelected(String(row.id))}
|
||||
onToggleSelect={() => onToggleRow(String(row.id))}
|
||||
{inviteActionEmail}
|
||||
{onResendInvite}
|
||||
{onRevokeInvite}
|
||||
/>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
@@ -1 +1,6 @@
|
||||
// Components exports will be added here when needed
|
||||
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
|
||||
export { default as AudienceMemberRow } from './audience-member-row.svelte';
|
||||
export { default as AudiencePagination } from './audience-pagination.svelte';
|
||||
export { default as AudienceTable } from './audience-table.svelte';
|
||||
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
|
||||
export { default as AudienceTableToolbar } from './audience-table-toolbar.svelte';
|
||||
|
||||
@@ -1,41 +1,68 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page } from '$app/state';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import { currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import type { OrganizationAudience } from '$features/org/utils/types';
|
||||
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
|
||||
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
|
||||
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
|
||||
import AudienceTable from '$features/audience/components/audience-table.svelte';
|
||||
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
audience?: OrganizationAudience;
|
||||
courses?: Course[];
|
||||
}
|
||||
|
||||
let { audience }: Props = $props();
|
||||
let { audience, courses = [] }: Props = $props();
|
||||
|
||||
// Initialize audience from prop if provided
|
||||
$effect(() => {
|
||||
if (audience) {
|
||||
orgApi.audience = audience;
|
||||
}
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ key: 'name', value: $t('audience.name') },
|
||||
{ key: 'email', value: $t('audience.email') },
|
||||
{ key: 'date_joined', value: $t('audience.date_joined') }
|
||||
];
|
||||
let pageSize = $state(5);
|
||||
const headers = $derived([
|
||||
{ key: 'name', value: t.get('audience.name') },
|
||||
{ key: 'email', value: t.get('audience.email') },
|
||||
{ key: 'status', value: t.get('audience.status') },
|
||||
{ key: 'date_joined', value: t.get('audience.date_joined') }
|
||||
]);
|
||||
|
||||
let inviteActionEmail = $state<string | null>(null);
|
||||
|
||||
async function handleResendInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.resendAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeInvite(email: string) {
|
||||
inviteActionEmail = email;
|
||||
try {
|
||||
await orgApi.revokeAudienceInvite({ email });
|
||||
} finally {
|
||||
inviteActionEmail = null;
|
||||
}
|
||||
}
|
||||
|
||||
let pageSize = $state(20);
|
||||
let currentPage = $state(1);
|
||||
let searchValue = $state('');
|
||||
let selectedIds = new SvelteSet<string>();
|
||||
let assignModalOpen = $state(false);
|
||||
|
||||
const filteredRows = $derived(
|
||||
searchValue
|
||||
@@ -50,6 +77,45 @@
|
||||
const endIndex = $derived(startIndex + pageSize);
|
||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
||||
|
||||
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
|
||||
|
||||
const allPageSelected = $derived(
|
||||
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
|
||||
);
|
||||
const somePageSelected = $derived(
|
||||
selectablePageRows.some((row) => selectedIds.has(String(row.id))) && !allPageSelected
|
||||
);
|
||||
const hasSelection = $derived(selectedIds.size > 0);
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allPageSelected) {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.delete(String(row.id));
|
||||
}
|
||||
} else {
|
||||
for (const row of selectablePageRows) {
|
||||
selectedIds.add(String(row.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRow(id: string) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProfileIds = $derived(
|
||||
orgApi.audience.filter((r) => selectedIds.has(String(r.id)) && r.profileId).map((r) => r.profileId!)
|
||||
);
|
||||
|
||||
function handleAssigned() {
|
||||
assignModalOpen = false;
|
||||
selectedIds.clear();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
@@ -61,90 +127,47 @@
|
||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder="Search..." bind:value={searchValue} class="" />
|
||||
</Page.BodyHeader>
|
||||
<AudienceTableToolbar
|
||||
{hasSelection}
|
||||
selectedCount={selectedIds.size}
|
||||
bind:searchValue
|
||||
onOpenAssign={() => (assignModalOpen = true)}
|
||||
/>
|
||||
|
||||
{#if orgApi.audience.length || orgApi.isLoading}
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Table -->
|
||||
{#if orgApi.isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(pageSize) as _, i (i)}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
<AudienceTableSkeleton rowCount={pageSize} />
|
||||
{:else}
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each headers as header (header)}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRows as row (row.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={`${page.url.href}/${row.id}`}
|
||||
class="ui:text-primary flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
<Avatar.Image
|
||||
src={row.avatarUrl ? row.avatarUrl : '/logo-192.png'}
|
||||
alt={row.name ? row.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>{row.createdAt}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
<AudienceTable
|
||||
{headers}
|
||||
rows={paginatedRows}
|
||||
{allPageSelected}
|
||||
{somePageSelected}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
isRowSelected={(id) => selectedIds.has(id)}
|
||||
onToggleRow={toggleRow}
|
||||
{inviteActionEmail}
|
||||
onResendInvite={handleResendInvite}
|
||||
onRevokeInvite={handleRevokeInvite}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
<AudiencePagination
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(p) => (currentPage = p)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||
{/if}
|
||||
|
||||
<AssignCoursesModal
|
||||
bind:open={assignModalOpen}
|
||||
{selectedProfileIds}
|
||||
{courses}
|
||||
onclose={() => (assignModalOpen = false)}
|
||||
onassigned={handleAssigned}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
}
|
||||
|
||||
let { courses }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (selectedCourseIds.has(courseId)) {
|
||||
selectedCourseIds.delete(courseId);
|
||||
} else {
|
||||
selectedCourseIds.add(courseId);
|
||||
}
|
||||
}
|
||||
|
||||
const audiencePath = $derived(page.url.pathname.replace(/\/import$/, ''));
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!recipientCsv.trim()) {
|
||||
snackbar.error('audience.import.snackbar_no_emails');
|
||||
return;
|
||||
}
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await goto(resolve(audiencePath, {}));
|
||||
}
|
||||
} catch {
|
||||
snackbar.error('audience.import.snackbar_failed');
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('audience.import.description')}</p>
|
||||
|
||||
<TextareaField
|
||||
label={$t('audience.import.emails_label')}
|
||||
bind:value={recipientCsv}
|
||||
rows={6}
|
||||
className="w-full"
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.course_access')}</Label>
|
||||
<RadioGroup.Root bind:value={courseAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="course-none" />
|
||||
<Label for="course-none" class="font-normal">{$t('audience.import.no_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="course-all" />
|
||||
<Label for="course-all" class="font-normal">{$t('audience.import.all_courses')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="course-select" />
|
||||
<Label for="course-select" class="font-normal">{$t('audience.import.select_courses')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if courseAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.assign.select_courses')}
|
||||
emptyMessage={$t('audience.import.select_courses_placeholder')}
|
||||
items={courses.map((c) => ({ id: c.id, label: c.title || c.id }))}
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onToggle={toggleCourse}
|
||||
namePrefix="import-course"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <CheckboxField name="send-email" label={$t('audience.import.send_email')} bind:checked={sendEmail} /> -->
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button onclick={handleSubmit} loading={isSubmitting} disabled={isSubmitting}>
|
||||
{$t('audience.import.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as AudiencePage } from './audience.svelte';
|
||||
export { default as ImportAudiencePage } from './import.svelte';
|
||||
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$features/ui';
|
||||
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
@@ -61,10 +61,7 @@
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
{$t('community.ask.go_back')}
|
||||
</a>
|
||||
<BackButton href={`${$currentOrgPath}/audience`} label={$t('community.ask.go_back')} />
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
@@ -116,7 +113,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course, index}
|
||||
{#each filteredCourses as course (course.id)}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BadgeVariant } from '@cio/ui/base/badge';
|
||||
|
||||
export function statusBadgeVariant(status: string): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'secondary';
|
||||
case 'pending':
|
||||
return 'outline';
|
||||
case 'expired':
|
||||
case 'revoked':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
}
|
||||
|
||||
export function statusLabelKey(status: string): string {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'audience.status_active';
|
||||
case 'pending':
|
||||
return 'audience.status_pending';
|
||||
case 'expired':
|
||||
return 'audience.status_expired';
|
||||
case 'revoked':
|
||||
return 'audience.status_revoked';
|
||||
default:
|
||||
return 'audience.status_pending';
|
||||
}
|
||||
}
|
||||
|
||||
export function canResendAudienceInvite(status: string): boolean {
|
||||
return status === 'pending' || status === 'expired' || status === 'revoked';
|
||||
}
|
||||
|
||||
export function canRevokeAudienceInvite(status: string): boolean {
|
||||
return status === 'pending';
|
||||
}
|
||||
Reference in New Issue
Block a user