feat(cohorts): rename Programs to Cohorts with data migration (#694)
* feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best <[email protected]> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <[email protected]> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <[email protected]> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <[email protected]> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
+1
-1
@@ -1,5 +1,5 @@
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
programId: params.id
|
||||
cohortId: params.id
|
||||
};
|
||||
};
|
||||
+16
-16
@@ -3,51 +3,51 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { ProgramSidebar } from '$features/program/components/sidebar';
|
||||
import ProgramHeader from '$features/program/components/program-header.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { CohortSidebar } from '$features/cohort/components/sidebar';
|
||||
import CohortHeader from '$features/cohort/components/cohort-header.svelte';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!data.programId || !$profile.id) return;
|
||||
programApi.ensureProgramShell(data.programId);
|
||||
if (!data.cohortId || !$profile.id) return;
|
||||
cohortApi.ensureCohortShell(data.cohortId);
|
||||
});
|
||||
|
||||
const isProgramReady = $derived.by(() => {
|
||||
const isCohortReady = $derived.by(() => {
|
||||
return (
|
||||
programApi.program?.id === data.programId &&
|
||||
programApi.loadedMembersProgramId === data.programId &&
|
||||
programApi.loadedCoursesProgramId === data.programId
|
||||
cohortApi.cohort?.id === data.cohortId &&
|
||||
cohortApi.loadedMembersCohortId === data.cohortId &&
|
||||
cohortApi.loadedCoursesCohortId === data.cohortId
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{programApi.program?.name || 'Program'} - ClassroomIO</title>
|
||||
<title>{cohortApi.cohort?.name || 'Cohort'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Sidebar.Provider data-sveltekit-preload-data="off">
|
||||
<ProgramSidebar path={page.url.pathname} id={data.programId} {isProgramReady} />
|
||||
<CohortSidebar path={page.url.pathname} id={data.cohortId} {isCohortReady} />
|
||||
|
||||
<Sidebar.Inset
|
||||
class="w-[calc(100vw-var(--sidebar-width))] group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
>
|
||||
<ProgramHeader />
|
||||
<CohortHeader />
|
||||
|
||||
{#if !isProgramReady}
|
||||
{#if !isCohortReady}
|
||||
<div class="mx-auto flex h-[calc(100vh-56px)] w-full items-center justify-center">
|
||||
<Empty
|
||||
title="Loading program..."
|
||||
description="Please wait while we load your program data."
|
||||
title="Loading cohort..."
|
||||
description="Please wait while we load your cohort data."
|
||||
icon={Spinner}
|
||||
iconClass="h-8 w-8"
|
||||
variant="page"
|
||||
+14
-14
@@ -18,9 +18,9 @@
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import type { OrgCourses } from '$features/course/types';
|
||||
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
|
||||
import AddCourseToCohortModal from '$features/cohort/components/add-course-to-cohort-modal.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
let courseToDeleteId = $state<string | null>(null);
|
||||
let searchValue = $state('');
|
||||
|
||||
const courses = $derived(programApi.courses);
|
||||
const courses = $derived(cohortApi.courses);
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
@@ -72,7 +72,7 @@
|
||||
async function handleRemoveCourse() {
|
||||
if (!courseToDeleteId) return;
|
||||
|
||||
await programApi.removeCourse(data.programId, courseToDeleteId);
|
||||
await cohortApi.removeCourse(data.cohortId, courseToDeleteId);
|
||||
showDeleteModal = false;
|
||||
courseToDeleteId = null;
|
||||
}
|
||||
@@ -93,18 +93,18 @@
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-5xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent class="min-w-0 flex-1">
|
||||
<Page.Title>{$t('programs.sidebar.courses') || 'Courses'}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.courses') || 'Courses'}</Page.Title>
|
||||
<Alert.Callout
|
||||
variant="information"
|
||||
title={$t('programs.courses.published_access_callout_title')}
|
||||
description={$t('programs.courses.published_access_callout_description')}
|
||||
title={$t('cohorts.courses.published_access_callout_title')}
|
||||
description={$t('cohorts.courses.published_access_callout_description')}
|
||||
class="mt-3 w-full"
|
||||
/>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
@@ -126,17 +126,17 @@
|
||||
|
||||
{#if filteredCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.courses.empty_title') || 'No courses yet'}
|
||||
title={$t('cohorts.courses.empty_title') || 'No courses yet'}
|
||||
description={searchValue.trim()
|
||||
? $t('programs.courses.no_matching_program_courses') || 'No program courses match your search.'
|
||||
: $t('programs.courses.empty_description') || 'Add courses to this program.'}
|
||||
? $t('cohorts.courses.no_matching_program_courses') || 'No cohort courses match your search.'
|
||||
: $t('cohorts.courses.empty_description') || 'Add courses to this cohort.'}
|
||||
icon={BookOpenIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if !searchValue.trim()}
|
||||
<Button onclick={() => (showAddCourseModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
@@ -206,5 +206,5 @@
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<AddCourseToProgramModal bind:open={showAddCourseModal} programId={data.programId} />
|
||||
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={programApi.isLoading} />
|
||||
<AddCourseToCohortModal bind:open={showAddCourseModal} cohortId={data.cohortId} />
|
||||
<DeleteModal bind:open={showDeleteModal} onDelete={handleRemoveCourse} isLoading={cohortApi.isLoading} />
|
||||
+32
-32
@@ -5,14 +5,14 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { NewsFeedLoader } from '$features/course/components/newsfeed';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/program/components/goals/my-goals-widget.svelte';
|
||||
import { programApi, programNewsfeedApi } from '$features/program/api';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/cohort/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/cohort/components/goals/my-goals-widget.svelte';
|
||||
import { cohortApi, cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
@@ -20,29 +20,29 @@
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
};
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const programId = data.programId;
|
||||
const cohortId = data.cohortId;
|
||||
|
||||
let edit = $state(false);
|
||||
let editFeed: ProgramNewsfeedItem | null = $state(null);
|
||||
let editFeed: CohortNewsfeedItem | null = $state(null);
|
||||
let isListing = $state(true);
|
||||
let isReactingByFeedId = $state<Record<string, boolean>>({});
|
||||
|
||||
const query = new URLSearchParams(page.url.search);
|
||||
const activeFeedId = query.get('feedId') || '';
|
||||
|
||||
const currentProgramMember = $derived.by(
|
||||
() => programApi.members.find((member) => member.profile?.id === $profile.id) ?? null
|
||||
const currentCohortMember = $derived.by(
|
||||
() => cohortApi.members.find((member) => member.profile?.id === $profile.id) ?? null
|
||||
);
|
||||
|
||||
const reactionAuthorId = $derived(currentProgramMember?.id || '');
|
||||
const reactionAuthorId = $derived(currentCohortMember?.id || '');
|
||||
|
||||
const currentProgramRoleId = $derived(currentProgramMember?.roleId || null);
|
||||
const currentCohortRoleId = $derived(currentCohortMember?.roleId || null);
|
||||
|
||||
const author = $derived({
|
||||
id: $profile.id || '',
|
||||
@@ -52,13 +52,13 @@
|
||||
});
|
||||
|
||||
const canCreateNewsfeed = $derived(
|
||||
Boolean($isOrgAdmin) || currentProgramRoleId === ROLE.ADMIN || currentProgramRoleId === ROLE.TUTOR
|
||||
Boolean($isOrgAdmin) || currentCohortRoleId === ROLE.ADMIN || currentCohortRoleId === ROLE.TUTOR
|
||||
);
|
||||
const canManageNewsfeed = $derived(Boolean($isOrgAdmin) || canCreateNewsfeed);
|
||||
const canComment = $derived(Boolean(reactionAuthorId));
|
||||
|
||||
const sortedFeeds = $derived.by(() => {
|
||||
return [...programNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
|
||||
return [...cohortNewsfeedApi.feeds].sort((firstFeed, secondFeed) => {
|
||||
return Number(Boolean(secondFeed.isPinned)) - Number(Boolean(firstFeed.isPinned));
|
||||
});
|
||||
});
|
||||
@@ -67,20 +67,20 @@
|
||||
isListing = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.list(programId);
|
||||
await cohortNewsfeedApi.list(cohortId);
|
||||
} finally {
|
||||
isListing = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteComment = async (feedId: string, commentId: string) => {
|
||||
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
|
||||
await cohortNewsfeedApi.deleteComment(cohortId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
const reactedFeed = cohortNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
|
||||
@@ -88,22 +88,22 @@
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.react(programId, feedId, updatedReaction);
|
||||
await cohortNewsfeedApi.react(cohortId, feedId, updatedReaction);
|
||||
} finally {
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: false };
|
||||
}
|
||||
};
|
||||
|
||||
const addNewComment = async (comment: string, feedId: string) => {
|
||||
await programNewsfeedApi.createComment(programId, feedId, comment, author);
|
||||
await cohortNewsfeedApi.createComment(cohortId, feedId, comment, author);
|
||||
};
|
||||
|
||||
const onPin = async (feedId: string, isPinned: boolean | null) => {
|
||||
await programNewsfeedApi.update(programId, feedId, { isPinned: !isPinned });
|
||||
await cohortNewsfeedApi.update(cohortId, feedId, { isPinned: !isPinned });
|
||||
};
|
||||
|
||||
const deleteFeed = async (feedId: string) => {
|
||||
await programNewsfeedApi.delete(programId, feedId);
|
||||
await cohortNewsfeedApi.delete(cohortId, feedId);
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
@@ -115,21 +115,21 @@
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.newsfeed')}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.newsfeed')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('cohorts.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<NewFeedModal {programId} bind:edit bind:editFeed />
|
||||
<NewFeedModal {cohortId} bind:edit bind:editFeed />
|
||||
|
||||
<MyGoalsWidget {programId} />
|
||||
<MyGoalsWidget {cohortId} />
|
||||
|
||||
{#if isListing}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
@@ -139,14 +139,14 @@
|
||||
</div>
|
||||
{:else if !sortedFeeds.length}
|
||||
<Empty
|
||||
title={$t('programs.newsfeed.empty_title')}
|
||||
description={$t('programs.newsfeed.empty_description')}
|
||||
title={$t('cohorts.newsfeed.empty_title')}
|
||||
description={$t('cohorts.newsfeed.empty_description')}
|
||||
icon={BookIcon}
|
||||
variant="page"
|
||||
>
|
||||
{#if canCreateNewsfeed}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => programNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('programs.newsfeed.post')}
|
||||
<Button onmouseenter={() => preloadTextEditor()} onclick={() => cohortNewsfeedApi.openNewFeedModal()}>
|
||||
{$t('cohorts.newsfeed.post')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Empty>
|
||||
@@ -155,14 +155,14 @@
|
||||
{#if feed.isPinned}
|
||||
<div class="flex items-center gap-2">
|
||||
<PinIcon size={16} class="filled" />
|
||||
<p class="text-sm">{$t('programs.newsfeed.pinned')}</p>
|
||||
<p class="text-sm">{$t('cohorts.newsfeed.pinned')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<NewsFeedCard
|
||||
{feed}
|
||||
comments={programNewsfeedApi.commentsByFeedId[feed.id]}
|
||||
{programId}
|
||||
comments={cohortNewsfeedApi.commentsByFeedId[feed.id]}
|
||||
{cohortId}
|
||||
{author}
|
||||
{reactionAuthorId}
|
||||
{canComment}
|
||||
+17
-17
@@ -23,21 +23,21 @@
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import InviteMembersModal from '$features/program/components/invite-members-modal.svelte';
|
||||
import GoalsOverviewTiles from '$features/program/components/goals/goals-overview-tiles.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
|
||||
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let memberToDelete = $state<(typeof programApi.members)[number] | null>(null);
|
||||
let memberToDelete = $state<(typeof cohortApi.members)[number] | null>(null);
|
||||
let isDeleteModalOpen = $state(false);
|
||||
let filterBy = $state('all');
|
||||
let searchValue = $state('');
|
||||
let copiedEmail = $state<string | null>(null);
|
||||
|
||||
const members = $derived(sortAndFilterPeople(programApi.members, filterBy));
|
||||
const members = $derived(sortAndFilterPeople(cohortApi.members, filterBy));
|
||||
const currentUserRole = $derived.by(() => {
|
||||
const currentMember = programApi.members.find((member) => member.profileId === $profile.id);
|
||||
const currentMember = cohortApi.members.find((member) => member.profileId === $profile.id);
|
||||
return currentMember ? Number(currentMember.roleId) : null;
|
||||
});
|
||||
const canManageMembers = $derived.by(
|
||||
@@ -49,7 +49,7 @@
|
||||
{ label: $t(ROLE_LABEL[ROLE.STUDENT]), value: `${ROLE.STUDENT}` }
|
||||
]);
|
||||
|
||||
function sortAndFilterPeople(people: typeof programApi.members, activeFilter: string) {
|
||||
function sortAndFilterPeople(people: typeof cohortApi.members, activeFilter: string) {
|
||||
return [...(people || [])]
|
||||
.filter((person) => {
|
||||
if (activeFilter === 'all') {
|
||||
@@ -62,7 +62,7 @@
|
||||
.sort((a, b) => Number(a.roleId) - Number(b.roleId));
|
||||
}
|
||||
|
||||
function filterPeople(query: string, people: typeof programApi.members) {
|
||||
function filterPeople(query: string, people: typeof cohortApi.members) {
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
|
||||
return people.filter((person) => {
|
||||
@@ -72,7 +72,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getEmail(person: (typeof programApi.members)[number]) {
|
||||
function getEmail(person: (typeof cohortApi.members)[number]) {
|
||||
return person.profile?.email ?? person.email ?? '';
|
||||
}
|
||||
|
||||
@@ -117,10 +117,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
cohortApi.success = false;
|
||||
|
||||
await programApi.removeMember(data.programId, memberToDelete.id);
|
||||
if (programApi.success) {
|
||||
await cohortApi.removeMember(data.cohortId, memberToDelete.id);
|
||||
if (cohortApi.success) {
|
||||
isDeleteModalOpen = false;
|
||||
memberToDelete = null;
|
||||
}
|
||||
@@ -134,13 +134,13 @@
|
||||
<Page.Root class="mx-auto w-[90%] md:max-w-3xl">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.people')}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.people')}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
{#if canManageMembers}
|
||||
<Button onclick={openInviteModal}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('programs.people.invite')}
|
||||
{$t('cohorts.people.invite')}
|
||||
</Button>
|
||||
{/if}
|
||||
</Page.Action>
|
||||
@@ -148,7 +148,7 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<GoalsOverviewTiles programId={data.programId} />
|
||||
<GoalsOverviewTiles cohortId={data.cohortId} />
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex flex-col items-center justify-end gap-2 md:flex-row">
|
||||
@@ -283,7 +283,7 @@
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<InviteMembersModal programId={data.programId} />
|
||||
<InviteMembersModal cohortId={data.cohortId} />
|
||||
|
||||
<Dialog.Root bind:open={isDeleteModalOpen}>
|
||||
<Dialog.Content class="w-96 pt-3">
|
||||
@@ -300,7 +300,7 @@
|
||||
<Button variant="outline" onclick={() => (isDeleteModalOpen = false)}>
|
||||
{$t('course.navItem.people.delete_confirmation.no')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={deletePerson} loading={programApi.isLoading}>
|
||||
<Button variant="outline" onclick={deletePerson} loading={cohortApi.isLoading}>
|
||||
{$t('course.navItem.people.delete_confirmation.yes')}
|
||||
</Button>
|
||||
</div>
|
||||
+29
-29
@@ -7,16 +7,16 @@
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import GoalsSection from '$features/program/components/goals/goals-section.svelte';
|
||||
import GoalsSection from '$features/cohort/components/goals/goals-section.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const programId = data.programId as string;
|
||||
const cohortId = data.cohortId as string;
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
@@ -24,27 +24,27 @@
|
||||
let isSaving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const p = programApi.program;
|
||||
if (p) {
|
||||
name = p.name;
|
||||
description = p.description ?? '';
|
||||
status = (p.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||
const item = cohortApi.cohort;
|
||||
if (item) {
|
||||
name = item.name;
|
||||
description = item.description ?? '';
|
||||
status = (item.status as 'ACTIVE' | 'INACTIVE' | 'ARCHIVED') ?? 'ACTIVE';
|
||||
}
|
||||
});
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const currentMemberRole = $derived(cohortApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const canManageGoals = $derived(
|
||||
Boolean($isOrgAdmin) || currentMemberRole === ROLE.ADMIN || currentMemberRole === ROLE.TUTOR
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED', label: $t('programs.status.archived') || 'Archived' }
|
||||
{ value: 'ACTIVE', label: $t('cohorts.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('cohorts.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED', label: $t('cohorts.status.archived') || 'Archived' }
|
||||
];
|
||||
|
||||
const hasUnsavedChanges = $derived.by(() => {
|
||||
const currentProgram = programApi.program;
|
||||
const currentProgram = cohortApi.program;
|
||||
if (!currentProgram) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,7 +60,7 @@
|
||||
isSaving = true;
|
||||
|
||||
try {
|
||||
await programApi.updateProgram(programId, {
|
||||
await cohortApi.updateCohort(cohortId, {
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || null,
|
||||
status
|
||||
@@ -73,24 +73,24 @@
|
||||
async function handleDelete() {
|
||||
if (
|
||||
!confirm(
|
||||
$t('programs.settings.confirm_delete') || 'Are you sure you want to delete this program? This cannot be undone.'
|
||||
$t('cohorts.settings.confirm_delete') || 'Are you sure you want to delete this cohort? This cannot be undone.'
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await programApi.deleteProgram(programId);
|
||||
goto(resolve(`${$currentOrgPath}/programs`, {}));
|
||||
await cohortApi.deleteCohort(cohortId);
|
||||
goto(resolve(`${$currentOrgPath}/cohorts`, {}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Header class="sticky top-13 z-10 bg-white">
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.sidebar.settings') || 'Settings'}</Page.Title>
|
||||
<Page.Title>{$t('cohorts.sidebar.settings') || 'Settings'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="secondary" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('programs.settings.save') || 'Save Changes'}
|
||||
{$t('cohorts.settings.save') || 'Save Changes'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
@@ -99,20 +99,20 @@
|
||||
<div class="w-full px-4 py-6">
|
||||
<div class="flex flex-col gap-6">
|
||||
<section>
|
||||
<h2 class="mb-4 text-sm font-semibold">{$t('programs.settings.general') || 'General'}</h2>
|
||||
<h2 class="mb-4 text-sm font-semibold">{$t('cohorts.settings.general') || 'General'}</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-name">{$t('programs.settings.name_label') || 'Name'} *</Label>
|
||||
<Label for="prog-name">{$t('cohorts.settings.name_label') || 'Name'} *</Label>
|
||||
<Input id="prog-name" bind:value={name} maxlength={255} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="prog-desc">{$t('programs.settings.description_label') || 'Description'}</Label>
|
||||
<Label for="prog-desc">{$t('cohorts.settings.description_label') || 'Description'}</Label>
|
||||
<Textarea id="prog-desc" bind:value={description} rows={4} maxlength={2000} />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.settings.status_label') || 'Status'}</Label>
|
||||
<Label>{$t('cohorts.settings.status_label') || 'Status'}</Label>
|
||||
<Select.Root type="single" bind:value={status}>
|
||||
<Select.Trigger class="w-48">
|
||||
{statusOptions.find((o) => o.value === status)?.label}
|
||||
@@ -129,20 +129,20 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<GoalsSection {programId} canManage={canManageGoals} />
|
||||
<GoalsSection {cohortId} canManage={canManageGoals} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h2 class="mb-1 text-sm font-semibold text-red-600">
|
||||
{$t('programs.settings.danger_zone') || 'Danger Zone'}
|
||||
{$t('cohorts.settings.danger_zone') || 'Danger Zone'}
|
||||
</h2>
|
||||
<p class="text-muted-foreground mb-4 text-sm">
|
||||
{$t('programs.settings.delete_description') ||
|
||||
'Once deleted, this program and all its data cannot be recovered.'}
|
||||
{$t('cohorts.settings.delete_description') ||
|
||||
'Once deleted, this cohort and all its data cannot be recovered.'}
|
||||
</p>
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={programApi.isLoading}>
|
||||
{$t('programs.settings.delete') || 'Delete Program'}
|
||||
<Button variant="destructive" onclick={handleDelete} disabled={cohortApi.isLoading}>
|
||||
{$t('cohorts.settings.delete') || 'Delete Program'}
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { CohortsPage } from '$features/cohort/pages';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id) {
|
||||
cohortApi.listEnrolledCohorts();
|
||||
}
|
||||
});
|
||||
|
||||
const cohorts = $derived(cohortApi.cohorts);
|
||||
const filteredCohorts = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
|
||||
return cohorts.filter((cohort) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [cohort.name, cohort.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('lms_navigation.cohorts') || 'Cohorts'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('lms_navigation.cohorts') || 'Cohorts'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<CohortsPage
|
||||
cohorts={filteredCohorts}
|
||||
isLMS={true}
|
||||
isLoading={cohortApi.isLoading && cohorts.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('cohorts.lms.empty_title') || 'No cohorts yet'}
|
||||
emptyDescription={$t('cohorts.lms.empty_description')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -1,52 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
|
||||
let searchValue = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if ($profile.id) {
|
||||
programApi.listEnrolledPrograms();
|
||||
}
|
||||
});
|
||||
|
||||
const programs = $derived(programApi.programs);
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
|
||||
return programs.filter((program) => {
|
||||
if (!normalizedSearch) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return [program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('lms_navigation.programs') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('lms_navigation.programs') || 'Programs'}</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLMS={true}
|
||||
isLoading={programApi.isLoading && programs.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.lms.empty_title') || 'No programs yet'}
|
||||
emptyDescription={$t('programs.lms.empty_description')}
|
||||
/>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -4,8 +4,8 @@ import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
type GetOrganizationCoursesRequest = typeof classroomio.organization.courses.$get;
|
||||
type GetOrganizationCoursesSuccess = Extract<InferResponseType<GetOrganizationCoursesRequest>, { success: true }>;
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
type GetCohortsRequest = typeof classroomio.cohort.$get;
|
||||
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
@@ -13,21 +13,21 @@ export const load = async ({ parent, cookies }) => {
|
||||
if (!orgId) {
|
||||
return {
|
||||
courses: [],
|
||||
programs: []
|
||||
cohorts: []
|
||||
};
|
||||
}
|
||||
|
||||
const [coursesResult, programsResult] = await Promise.all([
|
||||
const [coursesResult, cohortsResult] = await Promise.all([
|
||||
safeServerApi<GetOrganizationCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.$get({ query: {} }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
safeServerApi<GetCohortsSuccess>(() =>
|
||||
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
courses: coursesResult.ok ? coursesResult.body.data : [],
|
||||
programs: programsResult.ok ? programsResult.body.data : []
|
||||
cohorts: cohortsResult.ok ? cohortsResult.body.data : []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<ImportAudiencePage courses={data.courses} programs={data.programs} />
|
||||
<ImportAudiencePage courses={data.courses} cohorts={data.cohorts} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetCohortsRequest = typeof classroomio.cohort.$get;
|
||||
type GetCohortsSuccess = Extract<InferResponseType<GetCohortsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return { cohorts: [] };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetCohortsSuccess>(() =>
|
||||
classroomio.cohort.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
cohorts: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { CohortsPage } from '$features/cohort/pages';
|
||||
import type { Cohort } from '$features/cohort/utils/types';
|
||||
import CreateCohortModal from '$features/cohort/components/create-cohort-modal.svelte';
|
||||
import {
|
||||
DEFAULT_COHORT_SORT,
|
||||
DEFAULT_COHORT_SORT_ORDER,
|
||||
type CohortSortBy,
|
||||
type CohortSortOrder
|
||||
} from '$features/cohort/utils/constants';
|
||||
import CohortFilterPopover from '$features/cohort/components/cohort-filter-popover.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<CohortSortBy>(DEFAULT_COHORT_SORT);
|
||||
let selectedOrder = $state<CohortSortOrder>(DEFAULT_COHORT_SORT_ORDER);
|
||||
let selectedStatuses = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (data.cohorts) {
|
||||
cohortApi.cohorts = data.cohorts;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: 'ACTIVE' as const, label: $t('cohorts.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE' as const, label: $t('cohorts.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED' as const, label: $t('cohorts.status.archived') || 'Archived' }
|
||||
]);
|
||||
|
||||
const sortOptions = $derived([
|
||||
{ value: 'name' as const, label: $t('cohorts.filters.sort_name') || 'Name' },
|
||||
{ value: 'courses' as const, label: $t('cohorts.filters.sort_courses') || 'Courses' },
|
||||
{ value: 'students' as const, label: $t('cohorts.filters.sort_students') || 'Students' },
|
||||
{ value: 'status' as const, label: $t('cohorts.filters.sort_status') || 'Status' }
|
||||
]);
|
||||
|
||||
const filteredCohorts = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
const sourceCohorts = cohortApi.cohorts.filter((item: Cohort) => {
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
[item.name, item.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(item.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const sortedCohorts = [...sourceCohorts];
|
||||
|
||||
return sortedCohorts.sort((firstCohort, secondCohort) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
comparison = firstCohort.name.localeCompare(secondCohort.name);
|
||||
} else if (sortKey === 'courses') {
|
||||
comparison = firstCohort.courseCount - secondCohort.courseCount;
|
||||
} else if (sortKey === 'students') {
|
||||
comparison = firstCohort.studentCount - secondCohort.studentCount;
|
||||
} else if (sortKey === 'status') {
|
||||
comparison = firstCohort.status.localeCompare(secondCohort.status);
|
||||
}
|
||||
|
||||
return selectedOrder === 'asc' ? comparison : comparison * -1;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const nextStatuses = new SvelteSet(selectedStatuses);
|
||||
if (checked) {
|
||||
nextStatuses.add(status);
|
||||
} else {
|
||||
nextStatuses.delete(status);
|
||||
}
|
||||
|
||||
selectedStatuses = Array.from(nextStatuses);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
sortKey = DEFAULT_COHORT_SORT;
|
||||
selectedOrder = DEFAULT_COHORT_SORT_ORDER;
|
||||
selectedStatuses = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('cohorts.page_title') || 'Cohorts'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('cohorts.heading') || 'Cohorts'}</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{$t('cohorts.description') || 'Give a group of people access to multiple courses, like a class'}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('cohorts.create') || 'New Program'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !cohortApi.isLoading && filteredCohorts.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
|
||||
<Empty
|
||||
title={$t('cohorts.empty_title') || 'No cohorts yet'}
|
||||
description={$t('cohorts.empty_description') || 'Create your first cohort to get started.'}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('cohorts.create') || 'New Program'}
|
||||
</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<CohortsPage
|
||||
cohorts={filteredCohorts}
|
||||
isLoading={cohortApi.isLoading && filteredCohorts.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('cohorts.empty_title') || 'No cohorts yet'}
|
||||
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
|
||||
? $t('cohorts.no_matching_cohorts') || 'No cohorts match your filters.'
|
||||
: $t('cohorts.empty_description') || 'Create your first cohort to get started.'}
|
||||
>
|
||||
{#snippet filterControls()}
|
||||
<CohortFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedStatuses}
|
||||
{statusOptions}
|
||||
{sortOptions}
|
||||
onToggleStatus={toggleStatus}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</CohortsPage>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<CreateCohortModal bind:open={showCreateModal} />
|
||||
@@ -1,21 +0,0 @@
|
||||
import { classroomio, getApiHeaders, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type GetProgramsRequest = typeof classroomio.program.$get;
|
||||
type GetProgramsSuccess = Extract<InferResponseType<GetProgramsRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ parent, locals, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
|
||||
if (!orgId || !locals.user?.id) {
|
||||
return { programs: [] };
|
||||
}
|
||||
|
||||
const result = await safeServerApi<GetProgramsSuccess>(() =>
|
||||
classroomio.program.$get({ query: { organizationId: orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
programs: result.ok ? result.body.data : []
|
||||
};
|
||||
};
|
||||
@@ -1,156 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { ProgramsPage } from '$features/program/pages';
|
||||
import type { Program } from '$features/program/utils/types';
|
||||
import CreateProgramModal from '$features/program/components/create-program-modal.svelte';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
} from '$features/program/utils/constants';
|
||||
import ProgramFilterPopover from '$features/program/components/program-filter-popover.svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let showCreateModal = $state(false);
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<ProgramSortBy>(DEFAULT_PROGRAM_SORT);
|
||||
let selectedOrder = $state<ProgramSortOrder>(DEFAULT_PROGRAM_SORT_ORDER);
|
||||
let selectedStatuses = $state<string[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (data.programs) {
|
||||
programApi.programs = data.programs;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = $derived([
|
||||
{ value: 'ACTIVE' as const, label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE' as const, label: $t('programs.status.inactive') || 'Inactive' },
|
||||
{ value: 'ARCHIVED' as const, label: $t('programs.status.archived') || 'Archived' }
|
||||
]);
|
||||
|
||||
const sortOptions = $derived([
|
||||
{ value: 'name' as const, label: $t('programs.filters.sort_name') || 'Name' },
|
||||
{ value: 'courses' as const, label: $t('programs.filters.sort_courses') || 'Courses' },
|
||||
{ value: 'students' as const, label: $t('programs.filters.sort_students') || 'Students' },
|
||||
{ value: 'status' as const, label: $t('programs.filters.sort_status') || 'Status' }
|
||||
]);
|
||||
|
||||
const filteredPrograms = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
const sourcePrograms = programApi.programs.filter((program: Program) => {
|
||||
const matchesSearch =
|
||||
!normalizedSearch ||
|
||||
[program.name, program.description].some((value) => value?.toLowerCase().includes(normalizedSearch));
|
||||
const matchesStatus = selectedStatuses.length === 0 || selectedStatuses.includes(program.status);
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const sortedPrograms = [...sourcePrograms];
|
||||
|
||||
return sortedPrograms.sort((firstProgram, secondProgram) => {
|
||||
let comparison = 0;
|
||||
|
||||
if (sortKey === 'name') {
|
||||
comparison = firstProgram.name.localeCompare(secondProgram.name);
|
||||
} else if (sortKey === 'courses') {
|
||||
comparison = firstProgram.courseCount - secondProgram.courseCount;
|
||||
} else if (sortKey === 'students') {
|
||||
comparison = firstProgram.studentCount - secondProgram.studentCount;
|
||||
} else if (sortKey === 'status') {
|
||||
comparison = firstProgram.status.localeCompare(secondProgram.status);
|
||||
}
|
||||
|
||||
return selectedOrder === 'asc' ? comparison : comparison * -1;
|
||||
});
|
||||
});
|
||||
|
||||
function toggleStatus(status: string, checked: boolean) {
|
||||
const nextStatuses = new SvelteSet(selectedStatuses);
|
||||
if (checked) {
|
||||
nextStatuses.add(status);
|
||||
} else {
|
||||
nextStatuses.delete(status);
|
||||
}
|
||||
|
||||
selectedStatuses = Array.from(nextStatuses);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
sortKey = DEFAULT_PROGRAM_SORT;
|
||||
selectedOrder = DEFAULT_PROGRAM_SORT_ORDER;
|
||||
selectedStatuses = [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.page_title') || 'Programs'} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.heading') || 'Programs'}</Page.Title>
|
||||
<Page.Subtitle>
|
||||
{$t('programs.description') || 'Give a group of people access to multiple courses, like a class'}
|
||||
</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programApi.isLoading && filteredPrograms.length === 0 && !searchValue.trim() && selectedStatuses.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.empty_title') || 'No programs yet'}
|
||||
description={$t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
>
|
||||
<Button onclick={() => (showCreateModal = true)}>
|
||||
<Plus size={16} />
|
||||
{$t('programs.create') || 'New Program'}
|
||||
</Button>
|
||||
</Empty>
|
||||
{:else}
|
||||
<ProgramsPage
|
||||
programs={filteredPrograms}
|
||||
isLoading={programApi.isLoading && filteredPrograms.length === 0}
|
||||
bind:searchValue
|
||||
emptyTitle={$t('programs.empty_title') || 'No programs yet'}
|
||||
emptyDescription={searchValue.trim() || selectedStatuses.length > 0
|
||||
? $t('programs.no_matching_programs') || 'No programs match your filters.'
|
||||
: $t('programs.empty_description') || 'Create your first program to get started.'}
|
||||
>
|
||||
{#snippet filterControls()}
|
||||
<ProgramFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
{selectedStatuses}
|
||||
{statusOptions}
|
||||
{sortOptions}
|
||||
onToggleStatus={toggleStatus}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
</ProgramsPage>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
<CreateProgramModal bind:open={showCreateModal} />
|
||||
@@ -7,29 +7,29 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { GoalsOverviewRow } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { GoalsOverviewRow } from '$features/cohort/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listOverview();
|
||||
cohortGoalApi.listOverview();
|
||||
});
|
||||
|
||||
const overviewRows = $derived(programGoalApi.overviewRows);
|
||||
const overviewRows = $derived(cohortGoalApi.overviewRows);
|
||||
|
||||
const programs = $derived.by(() => {
|
||||
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
|
||||
const cohorts = $derived.by(() => {
|
||||
const map = new Map<string, { cohortId: string; cohortName: string; goals: GoalsOverviewRow[] }>();
|
||||
|
||||
for (const row of overviewRows) {
|
||||
const bucket = map.get(row.programId) ?? {
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
const bucket = map.get(row.cohortId) ?? {
|
||||
cohortId: row.cohortId,
|
||||
cohortName: row.cohortName,
|
||||
goals: []
|
||||
};
|
||||
bucket.goals.push(row);
|
||||
map.set(row.programId, bucket);
|
||||
map.set(row.cohortId, bucket);
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
|
||||
return [...map.values()].sort((a, b) => a.cohortName.localeCompare(b.cohortName));
|
||||
});
|
||||
|
||||
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
|
||||
@@ -46,45 +46,45 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
|
||||
<title>{$t('cohorts.goals.heading')} · ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
|
||||
<Page.Title>{$t('cohorts.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('cohorts.goals.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programGoalApi.isLoading && overviewRows.length === 0}
|
||||
{#if !cohortGoalApi.isLoading && overviewRows.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.goals.empty_title')}
|
||||
description={$t('programs.goals.empty_description')}
|
||||
title={$t('cohorts.goals.empty_title')}
|
||||
description={$t('cohorts.goals.empty_description')}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-6 p-4">
|
||||
{#each programs as program (program.programId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
|
||||
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
|
||||
{#each cohorts as cohort (cohort.cohortId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(cohort.goals)}
|
||||
{@const totalLearners = aggregateCount(cohort.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(cohort.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(cohort.goals, 'atRiskCount')}
|
||||
<section class="flex flex-col gap-3 rounded-md border p-4">
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<a
|
||||
href={resolve(`/programs/${program.programId}/settings`, {})}
|
||||
href={resolve(`/cohorts/${cohort.cohortId}/settings`, {})}
|
||||
class="text-base font-semibold hover:underline"
|
||||
>
|
||||
{program.programName}
|
||||
{cohort.cohortName}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
|
||||
{$t('programs.goals.tile.on_track')}
|
||||
{$t('cohorts.goals.tile.on_track')}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="font-medium">{totalLearners}</span> learners
|
||||
@@ -92,13 +92,13 @@
|
||||
{#if atRiskLearners > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRiskLearners}</span>
|
||||
{$t('programs.goals.tile.at_risk_short')}
|
||||
{$t('cohorts.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdueLearners > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdueLearners}</span>
|
||||
{$t('programs.goals.tile.overdue_short')}
|
||||
{$t('cohorts.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -110,19 +110,19 @@
|
||||
<Table.Row>
|
||||
<Table.Head>Goal</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('cohorts.goals.tile.on_track')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each program.goals as goal (goal.goalId)}
|
||||
{#each cohort.goals as goal (goal.goalId)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
|
||||
<Table.Cell>{goal.inProgressCount}</Table.Cell>
|
||||
|
||||
Reference in New Issue
Block a user