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 <rotimi-best@users.noreply.github.com> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
@@ -16,23 +16,23 @@
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface Program {
|
||||
interface Cohort {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
courses: Course[];
|
||||
programs: Program[];
|
||||
cohorts: Cohort[];
|
||||
}
|
||||
|
||||
let { courses, programs }: Props = $props();
|
||||
let { courses, cohorts }: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
let courseAccessMode = $state('none');
|
||||
let programAccessMode = $state('none');
|
||||
let cohortAccessMode = $state('none');
|
||||
let selectedCourseIds = new SvelteSet<string>();
|
||||
let selectedProgramIds = new SvelteSet<string>();
|
||||
let selectedCohortIds = new SvelteSet<string>();
|
||||
let sendEmail = $state(true);
|
||||
let isSubmitting = $state(false);
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleProgram(programId: string) {
|
||||
if (selectedProgramIds.has(programId)) {
|
||||
selectedProgramIds.delete(programId);
|
||||
function toggleCohort(cohortId: string) {
|
||||
if (selectedCohortIds.has(cohortId)) {
|
||||
selectedCohortIds.delete(cohortId);
|
||||
} else {
|
||||
selectedProgramIds.add(programId);
|
||||
selectedCohortIds.add(cohortId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,9 +68,9 @@
|
||||
const result = await orgApi.importAudienceMembers({
|
||||
recipientCsv,
|
||||
allCourses: courseAccessMode === 'all',
|
||||
allPrograms: programAccessMode === 'all',
|
||||
allCohorts: cohortAccessMode === 'all',
|
||||
courseIds: courseAccessMode === 'select' ? [...selectedCourseIds] : undefined,
|
||||
programIds: programAccessMode === 'select' ? [...selectedProgramIds] : undefined,
|
||||
cohortIds: cohortAccessMode === 'select' ? [...selectedCohortIds] : undefined,
|
||||
sendEmail
|
||||
});
|
||||
|
||||
@@ -128,32 +128,32 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.program_access')}</Label>
|
||||
<RadioGroup.Root bind:value={programAccessMode} class="space-y-2">
|
||||
<Label class="text-sm font-medium">{$t('audience.import.cohort_access')}</Label>
|
||||
<RadioGroup.Root bind:value={cohortAccessMode} class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="none" id="program-none" />
|
||||
<Label for="program-none" class="font-normal">{$t('audience.import.no_programs')}</Label>
|
||||
<RadioGroup.Item value="none" id="cohort-none" />
|
||||
<Label for="cohort-none" class="font-normal">{$t('audience.import.no_cohorts')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="all" id="program-all" />
|
||||
<Label for="program-all" class="font-normal">{$t('audience.import.all_programs')}</Label>
|
||||
<RadioGroup.Item value="all" id="cohort-all" />
|
||||
<Label for="cohort-all" class="font-normal">{$t('audience.import.all_cohorts')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroup.Item value="select" id="program-select" />
|
||||
<Label for="program-select" class="font-normal">{$t('audience.import.select_programs')}</Label>
|
||||
<RadioGroup.Item value="select" id="cohort-select" />
|
||||
<Label for="cohort-select" class="font-normal">{$t('audience.import.select_cohorts')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
|
||||
{#if programAccessMode === 'select'}
|
||||
{#if cohortAccessMode === 'select'}
|
||||
<MultiSelectList
|
||||
class="ml-6"
|
||||
listClass="max-h-40"
|
||||
heading={$t('audience.import.select_programs')}
|
||||
emptyMessage={$t('audience.import.select_programs_placeholder')}
|
||||
items={programs.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedProgramIds.has(id)}
|
||||
onToggle={toggleProgram}
|
||||
namePrefix="import-program"
|
||||
heading={$t('audience.import.select_cohorts')}
|
||||
emptyMessage={$t('audience.import.select_cohorts_placeholder')}
|
||||
items={cohorts.map((p) => ({ id: p.id, label: p.name || p.id }))}
|
||||
isSelected={(id) => selectedCohortIds.has(id)}
|
||||
onToggle={toggleCohort}
|
||||
namePrefix="import-cohort"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { Cohort, CohortDetail, CohortMember, CohortCourse, CohortNewsfeed } from '../utils/types';
|
||||
import type {
|
||||
TCreateCohort,
|
||||
TUpdateCohort,
|
||||
TAddCohortMembers,
|
||||
TUpdateCohortMember,
|
||||
TAddCourseToCohort,
|
||||
TInviteStudentsToCohort,
|
||||
TAssignExistingStudentsToCohort
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
class CohortApi extends BaseApiWithErrors {
|
||||
cohorts = $state<Cohort[]>([]);
|
||||
cohort = $state<CohortDetail | null>(null);
|
||||
members = $state<CohortMember[]>([]);
|
||||
courses = $state<CohortCourse[]>([]);
|
||||
newsfeed = $state<CohortNewsfeed | null>(null);
|
||||
isCohortShellLoading = $state(false);
|
||||
currentCohortId = $state<string | null>(null);
|
||||
|
||||
loadedCohortId = $state<string | null>(null);
|
||||
loadedMembersCohortId = $state<string | null>(null);
|
||||
loadedCoursesCohortId = $state<string | null>(null);
|
||||
loadedCoursesStudentExperience = $state<boolean | null>(null);
|
||||
loadedNewsfeedCohortId = $state<string | null>(null);
|
||||
|
||||
syncCohortCourseCount(cohortId: string, courseCount: number) {
|
||||
this.cohorts = this.cohorts.map((item) => (item.id === cohortId ? { ...item, courseCount } : item));
|
||||
}
|
||||
|
||||
async listCohorts() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.$get>({
|
||||
requestFn: () => classroomio.cohort.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (data) => {
|
||||
this.cohorts = data.data;
|
||||
},
|
||||
logContext: 'listCohorts'
|
||||
});
|
||||
}
|
||||
|
||||
async getCohort(cohortId: string, force = false) {
|
||||
if (!force && this.loadedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.cohort = data.data;
|
||||
this.loadedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'getCohort'
|
||||
});
|
||||
}
|
||||
|
||||
resetCohortShell(cohortId?: string) {
|
||||
this.currentCohortId = cohortId ?? null;
|
||||
this.cohort = null;
|
||||
this.members = [];
|
||||
this.courses = [];
|
||||
this.newsfeed = null;
|
||||
this.loadedCohortId = null;
|
||||
this.loadedMembersCohortId = null;
|
||||
this.loadedCoursesCohortId = null;
|
||||
this.loadedCoursesStudentExperience = null;
|
||||
this.loadedNewsfeedCohortId = null;
|
||||
}
|
||||
|
||||
async ensureCohortShell(cohortId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
const isShellReady =
|
||||
this.loadedCohortId === cohortId &&
|
||||
this.loadedMembersCohortId === cohortId &&
|
||||
this.loadedCoursesCohortId === cohortId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience;
|
||||
|
||||
if (!force && isShellReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isCohortChanged = this.currentCohortId !== cohortId;
|
||||
if (force || isCohortChanged) {
|
||||
this.resetCohortShell(cohortId);
|
||||
}
|
||||
|
||||
this.currentCohortId = cohortId;
|
||||
this.isCohortShellLoading = true;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.getCohort(cohortId, force),
|
||||
this.listMembers(cohortId, force),
|
||||
this.listCourses(cohortId, force)
|
||||
]);
|
||||
} finally {
|
||||
this.isCohortShellLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createCohort(data: TCreateCohort) {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort.$post({
|
||||
json: { ...data, organizationId: org.id }
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.cohorts = [res.data, ...this.cohorts];
|
||||
snackbar.success(t.get('cohorts.create_success'));
|
||||
},
|
||||
logContext: 'createCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async updateCohort(cohortId: string, data: TUpdateCohort) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$put']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$put({ param: { cohortId }, json: data }),
|
||||
onSuccess: (res) => {
|
||||
this.cohort = res.data;
|
||||
this.cohorts = this.cohorts.map((p) => (p.id === cohortId ? { ...p, ...res.data } : p));
|
||||
snackbar.success(t.get('cohorts.update_success'));
|
||||
},
|
||||
onError: (result) => {
|
||||
const message = typeof result === 'string' ? result : result.error;
|
||||
snackbar.error(message || 'Failed to update cohort');
|
||||
},
|
||||
logContext: 'updateCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCohort(cohortId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['$delete']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].$delete({ param: { cohortId } }),
|
||||
onSuccess: () => {
|
||||
this.cohorts = this.cohorts.filter((p) => p.id !== cohortId);
|
||||
snackbar.success(t.get('cohorts.delete_success'));
|
||||
},
|
||||
logContext: 'deleteCohort'
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
|
||||
async listMembers(cohortId: string, force = false) {
|
||||
if (!force && this.loadedMembersCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].members.$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.members = data.data;
|
||||
this.loadedMembersCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async addMembers(cohortId: string, data: TAddCohortMembers) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].members.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.members_added') || 'Members added');
|
||||
},
|
||||
logContext: 'addCohortMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(cohortId: string, memberId: string, data: TUpdateCohortMember) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].members[':memberId'].$put({
|
||||
param: { cohortId, memberId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
|
||||
},
|
||||
logContext: 'updateCohortMember'
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(cohortId: string, memberId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['members'][':memberId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].members[':memberId'].$delete({
|
||||
param: { cohortId, memberId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.members = this.members.filter((m) => m.id !== memberId);
|
||||
snackbar.success(t.get('cohorts.member_removed') || 'Member removed');
|
||||
},
|
||||
logContext: 'removeCohortMember'
|
||||
});
|
||||
}
|
||||
|
||||
// Courses
|
||||
|
||||
async listCourses(cohortId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
this.loadedCoursesCohortId === cohortId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].courses.$get({ param: { cohortId } }),
|
||||
onSuccess: (data) => {
|
||||
this.courses = data.data;
|
||||
this.syncCohortCourseCount(cohortId, data.data.length);
|
||||
this.loadedCoursesCohortId = cohortId;
|
||||
this.loadedCoursesStudentExperience = studentExperience;
|
||||
},
|
||||
logContext: 'listCohortCourses'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourse(cohortId: string, data: TAddCourseToCohort) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].courses.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listCourses(cohortId, true);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
snackbar.success(t.get('cohorts.course_added') || 'Course added');
|
||||
},
|
||||
logContext: 'addCourseToCohort'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourses(cohortId: string, courseIds: string[]) {
|
||||
if (courseIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
|
||||
try {
|
||||
for (const courseId of courseIds) {
|
||||
const response = await classroomio.cohort[':cohortId'].courses.$post({
|
||||
param: { cohortId },
|
||||
json: { courseId }
|
||||
});
|
||||
const result = (await response.json()) as { success?: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
this.error = result.error ?? 'Failed to add course to cohort';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.listCourses(cohortId, true);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
|
||||
const successMessage =
|
||||
courseIds.length === 1
|
||||
? t.get('cohorts.course_added') || 'Course added'
|
||||
: t.get('cohorts.multiple_added') || 'Courses added';
|
||||
|
||||
snackbar.success(successMessage);
|
||||
this.success = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in addCoursesToCohort:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add courses to cohort';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeCourse(cohortId: string, courseId: string) {
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['courses'][':courseId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].courses[':courseId'].$delete({
|
||||
param: { cohortId, courseId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.courses = this.courses.filter((c) => c.courseId !== courseId);
|
||||
this.syncCohortCourseCount(cohortId, this.courses.length);
|
||||
snackbar.success(t.get('cohorts.course_removed') || 'Course removed');
|
||||
},
|
||||
logContext: 'removeCourseFromCohort'
|
||||
});
|
||||
}
|
||||
|
||||
// Invitations (program-scoped)
|
||||
|
||||
/**
|
||||
* Invite new students to a cohort by CSV. Goes through the cohort-scoped
|
||||
* endpoint so a Cohort ADMIN/TUTOR can invite without org-admin rights.
|
||||
* Returns whether the call succeeded so callers can refresh their lists.
|
||||
*/
|
||||
async inviteStudentsToCohort(cohortId: string, data: TInviteStudentsToCohort): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].invite.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.invite_sent') || 'Invites sent');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'inviteStudentsToCohort'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing org audience student profiles to this cohort.
|
||||
* Same scoping as `inviteStudentsToCohort` — caller only needs program-team
|
||||
* membership for this cohort.
|
||||
*/
|
||||
async assignExistingStudentsToCohort(cohortId: string, data: TAssignExistingStudentsToCohort): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['invite']['assign']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].invite.assign.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.members_added') || 'Members added');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'assignExistingStudentsToCohort'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Newsfeed
|
||||
|
||||
async listNewsfeed(cohortId: string, force = false, cursor?: string) {
|
||||
if (!force && this.loadedNewsfeedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['newsfeed']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].newsfeed.$get({
|
||||
param: { cohortId },
|
||||
query: { limit: '10', ...(cursor ? { cursor } : {}) }
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
this.newsfeed = data.data;
|
||||
this.loadedNewsfeedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortNewsfeed'
|
||||
});
|
||||
}
|
||||
|
||||
// LMS — enrolled cohorts
|
||||
|
||||
async listEnrolledCohorts() {
|
||||
await this.execute<typeof classroomio.cohort.enrolled.$get>({
|
||||
requestFn: () => classroomio.cohort.enrolled.$get({}),
|
||||
onSuccess: (data) => {
|
||||
this.cohorts = data.data;
|
||||
},
|
||||
logContext: 'listEnrolledCohorts'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cohortApi = new CohortApi();
|
||||
@@ -0,0 +1,129 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { TCreateCohortGoal, TUpdateCohortGoal } from '@cio/utils/validation/cohort';
|
||||
import type { GoalsOverviewRow, MyGoalAssignment, CohortGoal } from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
class CohortGoalApi extends BaseApiWithErrors {
|
||||
goalsByCohortId = $state<Record<string, CohortGoal[]>>({});
|
||||
myGoals = $state<MyGoalAssignment[]>([]);
|
||||
overviewRows = $state<GoalsOverviewRow[]>([]);
|
||||
loadedCohortId = $state<string | null>(null);
|
||||
|
||||
goalsFor(cohortId: string): CohortGoal[] {
|
||||
return this.goalsByCohortId[cohortId] ?? [];
|
||||
}
|
||||
|
||||
async listGoals(cohortId: string, force = false) {
|
||||
if (!force && this.goalsByCohortId[cohortId] && this.loadedCohortId === cohortId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$get']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].goals.$get({ param: { cohortId } }),
|
||||
onSuccess: (res) => {
|
||||
this.goalsByCohortId = { ...this.goalsByCohortId, [cohortId]: res.data };
|
||||
this.loadedCohortId = cohortId;
|
||||
},
|
||||
logContext: 'listCohortGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async createGoal(cohortId: string, data: TCreateCohortGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals']['$post']>({
|
||||
requestFn: () => classroomio.cohort[':cohortId'].goals.$post({ param: { cohortId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.created') || 'Goal created');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'createCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async updateGoal(cohortId: string, goalId: string, data: TUpdateCohortGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].$put({
|
||||
param: { cohortId, goalId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.updated') || 'Goal updated');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'updateCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async deleteGoal(cohortId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].$delete({
|
||||
param: { cohortId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
snackbar.success(t.get('cohorts.goals.deleted') || 'Goal deleted');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'deleteCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async evaluateGoal(cohortId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['evaluate']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.cohort[':cohortId'].goals[':goalId'].evaluate.$post({
|
||||
param: { cohortId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(cohortId, true);
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'evaluateCohortGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async listMyGoals() {
|
||||
await this.execute<typeof classroomio.cohort.my.goals.$get>({
|
||||
requestFn: () => classroomio.cohort.my.goals.$get({}),
|
||||
onSuccess: (res) => {
|
||||
this.myGoals = res.data;
|
||||
},
|
||||
logContext: 'listMyGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async listOverview() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.cohort.goals.overview.$get>({
|
||||
requestFn: () => classroomio.cohort.goals.overview.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (res) => {
|
||||
this.overviewRows = res.data.goals;
|
||||
},
|
||||
logContext: 'listGoalsOverview'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const cohortGoalApi = new CohortGoalApi();
|
||||
@@ -0,0 +1,3 @@
|
||||
export { cohortApi } from './cohort.svelte';
|
||||
export { cohortGoalApi } from './goal.svelte';
|
||||
export { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from './newsfeed.svelte';
|
||||
+78
-78
@@ -1,35 +1,35 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CreateProgramNewsfeedCommentRequest,
|
||||
CreateProgramNewsfeedRequest,
|
||||
DeleteProgramNewsfeedCommentRequest,
|
||||
DeleteProgramNewsfeedRequest,
|
||||
GetProgramNewsfeedCommentsRequest,
|
||||
ListProgramNewsfeed,
|
||||
ListProgramNewsfeedRequest,
|
||||
CreateCohortNewsfeedCommentRequest,
|
||||
CreateCohortNewsfeedRequest,
|
||||
DeleteCohortNewsfeedCommentRequest,
|
||||
DeleteCohortNewsfeedRequest,
|
||||
GetCohortNewsfeedCommentsRequest,
|
||||
ListCohortNewsfeed,
|
||||
ListCohortNewsfeedRequest,
|
||||
ProgramNewsfeedComment,
|
||||
ReactToProgramNewsfeedRequest,
|
||||
UpdateProgramNewsfeedRequest
|
||||
ReactToCohortNewsfeedRequest,
|
||||
UpdateCohortNewsfeedRequest
|
||||
} from '../utils/types';
|
||||
import type {
|
||||
TCreateProgramNewsfeed,
|
||||
TCreateProgramNewsfeedComment,
|
||||
TUpdateProgramNewsfeed,
|
||||
TUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
TCreateCohortNewsfeed,
|
||||
TCreateCohortNewsfeedComment,
|
||||
TUpdateCohortNewsfeed,
|
||||
TUpdateCohortReaction
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import {
|
||||
ZCreateProgramNewsfeed,
|
||||
ZCreateProgramNewsfeedComment,
|
||||
ZUpdateProgramNewsfeed,
|
||||
ZUpdateProgramReaction
|
||||
} from '@cio/utils/validation/program';
|
||||
ZCreateCohortNewsfeed,
|
||||
ZCreateCohortNewsfeedComment,
|
||||
ZUpdateCohortNewsfeed,
|
||||
ZUpdateCohortReaction
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { get } from 'svelte/store';
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
|
||||
export type ProgramNewsfeedCommentsByFeedId = {
|
||||
export type CohortNewsfeedCommentsByFeedId = {
|
||||
items: ProgramNewsfeedComment[];
|
||||
totalCount: number;
|
||||
hasMore: boolean;
|
||||
@@ -37,10 +37,10 @@ export type ProgramNewsfeedCommentsByFeedId = {
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListProgramNewsfeed>([]);
|
||||
export class CohortNewsfeedApi extends BaseApiWithErrors {
|
||||
feeds = $state<ListCohortNewsfeed>([]);
|
||||
|
||||
commentsByFeedId = $state<Record<string, ProgramNewsfeedCommentsByFeedId>>({});
|
||||
commentsByFeedId = $state<Record<string, CohortNewsfeedCommentsByFeedId>>({});
|
||||
|
||||
isNewFeedModalOpen = $state(false);
|
||||
|
||||
@@ -52,14 +52,14 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
this.isNewFeedModalOpen = false;
|
||||
}
|
||||
|
||||
async list(programId: string) {
|
||||
await this.execute<ListProgramNewsfeedRequest>({
|
||||
async list(cohortId: string) {
|
||||
await this.execute<ListCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
classroomio.cohort[':cohortId'].newsfeed.$get({
|
||||
param: { cohortId },
|
||||
query: {}
|
||||
}),
|
||||
logContext: 'listing program newsfeed',
|
||||
logContext: 'listing cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
this.feeds = response.data.items || [];
|
||||
this.commentsByFeedId = {};
|
||||
@@ -69,36 +69,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.news_feed_fail');
|
||||
snackbar.error('snackbar.cohort.error.news_feed_fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async create(programId: string, fields: TCreateProgramNewsfeed) {
|
||||
async create(cohortId: string, fields: TCreateCohortNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
if (isHtmlValueEmpty(fields.content)) {
|
||||
this.errors = mapZodErrorsToTranslations(
|
||||
ZCreateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
ZCreateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
'newsfeed'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = ZCreateProgramNewsfeed.safeParse(fields);
|
||||
const validationResult = ZCreateCohortNewsfeed.safeParse(fields);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedRequest>({
|
||||
await this.execute<CreateCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$post({
|
||||
param: { programId },
|
||||
classroomio.cohort[':cohortId'].newsfeed.$post({
|
||||
param: { cohortId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'creating program newsfeed',
|
||||
logContext: 'creating cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const author = get(profile);
|
||||
const newFeed = {
|
||||
@@ -112,36 +112,36 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
|
||||
this.feeds = [newFeed, ...this.feeds];
|
||||
this.initComments(newFeed.id, 0);
|
||||
snackbar.success('snackbar.program.success.post_created');
|
||||
snackbar.success('snackbar.cohort.success.post_created');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async update(programId: string, feedId: string, fields: TUpdateProgramNewsfeed) {
|
||||
async update(cohortId: string, feedId: string, fields: TUpdateCohortNewsfeed) {
|
||||
this.success = false;
|
||||
|
||||
if (fields.content !== undefined && isHtmlValueEmpty(fields.content)) {
|
||||
this.errors = mapZodErrorsToTranslations(
|
||||
ZUpdateProgramNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
ZUpdateCohortNewsfeed.safeParse({ ...fields, content: '' }).error!,
|
||||
'newsfeed'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const validationResult = ZUpdateProgramNewsfeed.safeParse(fields);
|
||||
const validationResult = ZUpdateCohortNewsfeed.safeParse(fields);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<UpdateProgramNewsfeedRequest>({
|
||||
await this.execute<UpdateCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$put({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$put({
|
||||
param: { cohortId, feedId },
|
||||
json: fields
|
||||
}),
|
||||
logContext: 'updating program newsfeed',
|
||||
logContext: 'updating cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
@@ -159,26 +159,26 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
commentCount: existingFeed.commentCount
|
||||
};
|
||||
|
||||
snackbar.success('snackbar.program.success.post_updated');
|
||||
snackbar.success('snackbar.cohort.success.post_updated');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async react(programId: string, feedId: string, reaction: TUpdateProgramReaction['reaction']) {
|
||||
const validationResult = ZUpdateProgramReaction.safeParse({ reaction });
|
||||
async react(cohortId: string, feedId: string, reaction: TUpdateCohortReaction['reaction']) {
|
||||
const validationResult = ZUpdateCohortReaction.safeParse({ reaction });
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<ReactToProgramNewsfeedRequest>({
|
||||
await this.execute<ReactToCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].react.$put({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].react.$put({
|
||||
param: { cohortId, feedId },
|
||||
json: validationResult.data
|
||||
}),
|
||||
logContext: 'reacting to program newsfeed',
|
||||
logContext: 'reacting to cohort newsfeed',
|
||||
onSuccess: (response) => {
|
||||
const index = this.feeds.findIndex((feed) => feed.id === feedId);
|
||||
if (index === -1) {
|
||||
@@ -197,25 +197,25 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
snackbar.error('snackbar.program.error.reaction_error');
|
||||
snackbar.error('snackbar.cohort.error.reaction_error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(programId: string, feedId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedRequest>({
|
||||
async delete(cohortId: string, feedId: string) {
|
||||
await this.execute<DeleteCohortNewsfeedRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].$delete({
|
||||
param: { programId, feedId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].$delete({
|
||||
param: { cohortId, feedId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed',
|
||||
logContext: 'deleting cohort newsfeed',
|
||||
onSuccess: () => {
|
||||
this.feeds = this.feeds.filter((feed) => feed.id !== feedId);
|
||||
|
||||
const { [feedId]: _removedComments, ...remainingComments } = this.commentsByFeedId;
|
||||
this.commentsByFeedId = remainingComments;
|
||||
|
||||
snackbar.success('snackbar.program.success.feed_delete_success');
|
||||
snackbar.success('snackbar.cohort.success.feed_delete_success');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -235,19 +235,19 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
this.commentsByFeedId[feedId].totalCount = totalCount;
|
||||
}
|
||||
|
||||
async getComments(programId: string, feedId: string) {
|
||||
async getComments(cohortId: string, feedId: string) {
|
||||
if (!this.commentsByFeedId[feedId]) {
|
||||
this.initComments(feedId, 0);
|
||||
}
|
||||
|
||||
this.commentsByFeedId[feedId].isLoading = true;
|
||||
|
||||
await this.execute<GetProgramNewsfeedCommentsRequest>({
|
||||
await this.execute<GetCohortNewsfeedCommentsRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { programId, feedId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comments.$get({
|
||||
param: { cohortId, feedId }
|
||||
}),
|
||||
logContext: 'fetching program newsfeed comments',
|
||||
logContext: 'fetching cohort newsfeed comments',
|
||||
onSuccess: (response) => {
|
||||
this.commentsByFeedId[feedId] = {
|
||||
items: response.data,
|
||||
@@ -264,27 +264,27 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
}
|
||||
|
||||
async createComment(
|
||||
programId: string,
|
||||
cohortId: string,
|
||||
feedId: string,
|
||||
content: string,
|
||||
author: { id: string; username: string; fullname: string; avatarUrl: string }
|
||||
) {
|
||||
const validationResult = ZCreateProgramNewsfeedComment.safeParse({
|
||||
const validationResult = ZCreateCohortNewsfeedComment.safeParse({
|
||||
content: getTextFromHTML(content)
|
||||
} satisfies TCreateProgramNewsfeedComment);
|
||||
} satisfies TCreateCohortNewsfeedComment);
|
||||
|
||||
if (!validationResult.success) {
|
||||
this.errors = mapZodErrorsToTranslations(validationResult.error, 'newsfeed');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<CreateProgramNewsfeedCommentRequest>({
|
||||
await this.execute<CreateCohortNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { programId, feedId },
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment.$post({
|
||||
param: { cohortId, feedId },
|
||||
json: { content }
|
||||
}),
|
||||
logContext: 'creating program newsfeed comment',
|
||||
logContext: 'creating cohort newsfeed comment',
|
||||
onSuccess: (response) => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
@@ -313,18 +313,18 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_added');
|
||||
snackbar.success('snackbar.cohort.success.comment_added');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(programId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteProgramNewsfeedCommentRequest>({
|
||||
async deleteComment(cohortId: string, feedId: string, commentId: string) {
|
||||
await this.execute<DeleteCohortNewsfeedCommentRequest>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed[':feedId'].comment[':commentId'].$delete({
|
||||
param: { programId, feedId, commentId }
|
||||
classroomio.cohort[':cohortId'].newsfeed[':feedId'].comment[':commentId'].$delete({
|
||||
param: { cohortId, feedId, commentId }
|
||||
}),
|
||||
logContext: 'deleting program newsfeed comment',
|
||||
logContext: 'deleting cohort newsfeed comment',
|
||||
onSuccess: () => {
|
||||
const existingCommentState = this.commentsByFeedId[feedId];
|
||||
if (!existingCommentState) {
|
||||
@@ -345,10 +345,10 @@ export class ProgramNewsfeedApi extends BaseApiWithErrors {
|
||||
};
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.program.success.comment_deleted');
|
||||
snackbar.success('snackbar.cohort.success.comment_deleted');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programNewsfeedApi = new ProgramNewsfeedApi();
|
||||
export const cohortNewsfeedApi = new CohortNewsfeedApi();
|
||||
+15
-15
@@ -6,24 +6,24 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
const COURSE_PAGE_SIZE = 20;
|
||||
|
||||
let { open = $bindable(false), programId }: Props = $props();
|
||||
let { open = $bindable(false), cohortId }: Props = $props();
|
||||
|
||||
let searchValue = $state('');
|
||||
let currentPage = $state(1);
|
||||
const selectedCourseIds = new SvelteSet<string>();
|
||||
|
||||
const availableCourses = $derived.by(() => {
|
||||
const existingCourseIds = new Set(programApi.courses.map((course) => course.courseId));
|
||||
const existingCourseIds = new Set(cohortApi.courses.map((course) => course.courseId));
|
||||
|
||||
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
|
||||
});
|
||||
@@ -47,8 +47,8 @@
|
||||
const canGoToNextPage = $derived(currentPage < totalPages);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $t('programs.courses.no_matching_courses') || 'No courses match your search.'
|
||||
: $t('programs.courses.no_available_courses') || 'No available courses to add.'
|
||||
? $t('cohorts.courses.no_matching_courses') || 'No courses match your search.'
|
||||
: $t('cohorts.courses.no_available_courses') || 'No available courses to add.'
|
||||
);
|
||||
const multiSelectItems = $derived(
|
||||
paginatedCourses.map((course) => ({
|
||||
@@ -97,7 +97,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const didAddCourses = await programApi.addCourses(programId, courseIds);
|
||||
const didAddCourses = await cohortApi.addCourses(cohortId, courseIds);
|
||||
if (!didAddCourses) {
|
||||
return;
|
||||
}
|
||||
@@ -127,7 +127,7 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.courses.add_modal_title') || 'Add Course to Program'}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.courses.add_modal_title') || 'Add Course to Cohort'}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
@@ -139,14 +139,14 @@
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleCourse(id)}
|
||||
namePrefix="program-course"
|
||||
namePrefix="cohort-course"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('programs.courses.search_placeholder') || 'Search courses'}
|
||||
searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$t('programs.courses.select_courses') || $t('programs.courses.select_course') || 'Select course(s)'}
|
||||
{$t('cohorts.courses.select_courses') || $t('cohorts.courses.select_course') || 'Select course(s)'}
|
||||
{#if selectedCount > 0}
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('audience.selected_count', { count: selectedCount })}
|
||||
@@ -159,7 +159,7 @@
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{$t('programs.courses.pagination_status', {
|
||||
{$t('cohorts.courses.pagination_status', {
|
||||
page: currentPage,
|
||||
totalPages
|
||||
}) || `Page ${currentPage} of ${totalPages}`}
|
||||
@@ -188,11 +188,11 @@
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeModal} disabled={programApi.isLoading}>
|
||||
<Button variant="outline" onclick={closeModal} disabled={cohortApi.isLoading}>
|
||||
{$t('app.cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button onclick={handleAdd} loading={programApi.isLoading} disabled={selectedCount === 0 || programApi.isLoading}>
|
||||
{$t('programs.courses.add') || 'Add Course'}
|
||||
<Button onclick={handleAdd} loading={cohortApi.isLoading} disabled={selectedCount === 0 || cohortApi.isLoading}>
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+8
-8
@@ -3,17 +3,17 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
cohort: Cohort;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { program, onclick }: Props = $props();
|
||||
let { cohort, onclick }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
const courseCount = $derived(cohort.courseCount ?? 0);
|
||||
const studentCount = $derived(cohort.studentCount ?? 0);
|
||||
</script>
|
||||
|
||||
<Item.Root variant="outline" class="group relative max-w-[320px] cursor-pointer p-4!" {onclick}>
|
||||
@@ -21,7 +21,7 @@
|
||||
<div class="flex h-full flex-col justify-between gap-5" {...props}>
|
||||
<Item.Content>
|
||||
<Item.Title class="line-clamp-2 min-h-14 text-base!">
|
||||
{program.name}
|
||||
{cohort.name}
|
||||
</Item.Title>
|
||||
|
||||
<Separator class="my-3" />
|
||||
@@ -29,13 +29,13 @@
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="w-[60%]">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.courses'), courseCount, true)}
|
||||
{pluralize($t('cohorts.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{pluralize($t('programs.card.students'), studentCount, true)}
|
||||
{pluralize($t('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
+18
-18
@@ -7,10 +7,10 @@
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
DEFAULT_PROGRAM_SORT,
|
||||
DEFAULT_PROGRAM_SORT_ORDER,
|
||||
type ProgramSortBy,
|
||||
type ProgramSortOrder
|
||||
DEFAULT_COHORT_SORT,
|
||||
DEFAULT_COHORT_SORT_ORDER,
|
||||
type CohortSortBy,
|
||||
type CohortSortOrder
|
||||
} from '../utils/constants';
|
||||
|
||||
interface StatusOption {
|
||||
@@ -19,13 +19,13 @@
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
value: ProgramSortBy;
|
||||
value: CohortSortBy;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sortKey?: ProgramSortBy;
|
||||
selectedOrder?: ProgramSortOrder;
|
||||
sortKey?: CohortSortBy;
|
||||
selectedOrder?: CohortSortOrder;
|
||||
selectedStatuses?: string[];
|
||||
statusOptions?: StatusOption[];
|
||||
sortOptions?: SortOption[];
|
||||
@@ -34,8 +34,8 @@
|
||||
}
|
||||
|
||||
let {
|
||||
sortKey = $bindable(DEFAULT_PROGRAM_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_PROGRAM_SORT_ORDER),
|
||||
sortKey = $bindable(DEFAULT_COHORT_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER),
|
||||
selectedStatuses = [],
|
||||
statusOptions = [],
|
||||
sortOptions = [],
|
||||
@@ -46,7 +46,7 @@
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_PROGRAM_SORT || selectedOrder !== DEFAULT_PROGRAM_SORT_ORDER || selectedStatuses.length > 0
|
||||
sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0
|
||||
);
|
||||
|
||||
function isStatusSelected(status: string) {
|
||||
@@ -58,7 +58,7 @@
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('programs.filters.filter')} tooltip={$t('programs.filters.filter')}>
|
||||
<IconButton {...props} aria-label={$t('cohorts.filters.filter')} tooltip={$t('cohorts.filters.filter')}>
|
||||
<FilterIcon size={16} />
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
@@ -74,15 +74,15 @@
|
||||
<Popover.Content align="end" class="w-[360px] p-3">
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm font-semibold">{$t('programs.filters.popover_title')}</p>
|
||||
<p class="text-sm font-semibold">{$t('cohorts.filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('programs.filters.clear_all')}
|
||||
{$t('cohorts.filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.sort')}
|
||||
{$t('cohorts.filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.order')}
|
||||
{$t('cohorts.filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
@@ -113,14 +113,14 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroup.Item value="asc">{$t('programs.filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('programs.filters.descending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="asc">{$t('cohorts.filters.ascending')}</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="desc">{$t('cohorts.filters.descending')}</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('programs.filters.status')}
|
||||
{$t('cohorts.filters.status')}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each statusOptions as option (option.value)}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{programApi.program?.name || ''}
|
||||
{cohortApi.cohort?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+12
-12
@@ -5,7 +5,7 @@
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
await programApi.createProgram({ name: name.trim(), description: description.trim() || undefined });
|
||||
await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined });
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
@@ -34,29 +34,29 @@
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.create_modal.title') || 'Create Program'}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.create_modal.title') || 'Create Cohort'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$t('programs.create_modal.description') || 'Add a new certification or compliance program.'}
|
||||
{$t('cohorts.create_modal.description') || 'Add a new certification or compliance cohort.'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-name">{$t('programs.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Label for="program-name">{$t('cohorts.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Input
|
||||
id="program-name"
|
||||
id="cohort-name"
|
||||
bind:value={name}
|
||||
placeholder={$t('programs.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
|
||||
placeholder={$t('cohorts.create_modal.name_placeholder') || 'e.g. OSHA Safety Certification'}
|
||||
maxlength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="program-description">{$t('programs.create_modal.description_label') || 'Description'}</Label>
|
||||
<Label for="program-description">{$t('cohorts.create_modal.description_label') || 'Description'}</Label>
|
||||
<Textarea
|
||||
id="program-description"
|
||||
id="cohort-description"
|
||||
bind:value={description}
|
||||
placeholder={$t('programs.create_modal.description_placeholder') || 'What is this program about?'}
|
||||
placeholder={$t('cohorts.create_modal.description_placeholder') || 'What is this cohort about?'}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
/>
|
||||
@@ -65,8 +65,8 @@
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || programApi.isLoading}>
|
||||
{$t('programs.create_modal.submit') || 'Create'}
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || cohortApi.isLoading}>
|
||||
{$t('cohorts.create_modal.submit') || 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+45
-45
@@ -8,26 +8,26 @@
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import {
|
||||
PROGRAM_GOAL_DEADLINE_KINDS,
|
||||
PROGRAM_GOAL_TYPES,
|
||||
type TCreateProgramGoal,
|
||||
type TUpdateProgramGoal
|
||||
} from '@cio/utils/validation/program';
|
||||
COHORT_GOAL_DEADLINE_KINDS,
|
||||
COHORT_GOAL_TYPES,
|
||||
type TCreateCohortGoal,
|
||||
type TUpdateCohortGoal
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi, programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortApi, cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
type GoalType = (typeof PROGRAM_GOAL_TYPES)[number];
|
||||
type DeadlineKind = (typeof PROGRAM_GOAL_DEADLINE_KINDS)[number];
|
||||
type GoalType = (typeof COHORT_GOAL_TYPES)[number];
|
||||
type DeadlineKind = (typeof COHORT_GOAL_DEADLINE_KINDS)[number];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
programId: string;
|
||||
goal: ProgramGoal | null;
|
||||
cohortId: string;
|
||||
goal: CohortGoal | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), programId, goal, onClose }: Props = $props();
|
||||
let { open = $bindable(), cohortId, goal, onClose }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
@@ -45,9 +45,9 @@
|
||||
let formError = $state<string | null>(null);
|
||||
|
||||
const isEditing = $derived(goal !== null);
|
||||
const availableCourses = $derived(programApi.courses);
|
||||
const availableCourses = $derived(cohortApi.courses);
|
||||
|
||||
function resetFromGoal(source: ProgramGoal | null) {
|
||||
function resetFromGoal(source: CohortGoal | null) {
|
||||
if (source) {
|
||||
title = source.title;
|
||||
description = source.description ?? '';
|
||||
@@ -105,14 +105,14 @@
|
||||
.filter((value) => Number.isFinite(value) && value >= 0 && value <= 365);
|
||||
}
|
||||
|
||||
function buildPayload(): TCreateProgramGoal | null {
|
||||
function buildPayload(): TCreateCohortGoal | null {
|
||||
if (!title.trim()) {
|
||||
formError = $t('programs.goals.modal.title_label') + ' is required';
|
||||
formError = $t('cohorts.goals.modal.title_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (courseIds.length === 0) {
|
||||
formError = $t('programs.goals.modal.courses_label') + ' is required';
|
||||
formError = $t('cohorts.goals.modal.courses_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -141,10 +141,10 @@
|
||||
try {
|
||||
const ok =
|
||||
isEditing && goal
|
||||
? await programGoalApi.updateGoal(programId, goal.id, payload as TUpdateProgramGoal)
|
||||
: await programGoalApi.createGoal(programId, payload);
|
||||
? await cohortGoalApi.updateGoal(cohortId, goal.id, payload as TUpdateCohortGoal)
|
||||
: await cohortGoalApi.createGoal(cohortId, payload);
|
||||
if (ok) onClose();
|
||||
else formError = programGoalApi.error ?? 'Failed to save';
|
||||
else formError = cohortGoalApi.error ?? 'Failed to save';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
@@ -159,40 +159,40 @@
|
||||
<Dialog.Content class="max-h-[90vh] w-[96vw] max-w-2xl overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{isEditing ? $t('programs.goals.modal.edit_title') : $t('programs.goals.modal.create_title')}
|
||||
{isEditing ? $t('cohorts.goals.modal.edit_title') : $t('cohorts.goals.modal.create_title')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-title">{$t('programs.goals.modal.title_label')} *</Label>
|
||||
<Label for="goal-title">{$t('cohorts.goals.modal.title_label')} *</Label>
|
||||
<Input
|
||||
id="goal-title"
|
||||
bind:value={title}
|
||||
maxlength={255}
|
||||
placeholder={$t('programs.goals.modal.title_placeholder')}
|
||||
placeholder={$t('cohorts.goals.modal.title_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-description">{$t('programs.goals.modal.description_label')}</Label>
|
||||
<Label for="goal-description">{$t('cohorts.goals.modal.description_label')}</Label>
|
||||
<Textarea
|
||||
id="goal-description"
|
||||
bind:value={description}
|
||||
rows={2}
|
||||
maxlength={2000}
|
||||
placeholder={$t('programs.goals.modal.description_placeholder')}
|
||||
placeholder={$t('cohorts.goals.modal.description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.type_label')}</Label>
|
||||
<Label>{$t('cohorts.goals.modal.type_label')}</Label>
|
||||
<Select.Root type="single" bind:value={type}>
|
||||
<Select.Trigger>{$t(`programs.goals.type.${type}`)}</Select.Trigger>
|
||||
<Select.Trigger>{$t(`cohorts.goals.type.${type}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each PROGRAM_GOAL_TYPES as goalType (goalType)}
|
||||
{#each COHORT_GOAL_TYPES as goalType (goalType)}
|
||||
<Select.Item value={goalType}>
|
||||
{$t(`programs.goals.type.${goalType}`)}
|
||||
{$t(`cohorts.goals.type.${goalType}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
@@ -200,9 +200,9 @@
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.courses_label')} *</Label>
|
||||
<Label>{$t('cohorts.goals.modal.courses_label')} *</Label>
|
||||
{#if availableCourses.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">No courses in this program yet.</p>
|
||||
<p class="ui:text-muted-foreground text-sm">No courses in this cohort yet.</p>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
{#each availableCourses as item (item.courseId)}
|
||||
@@ -220,7 +220,7 @@
|
||||
|
||||
{#if type === 'n_of_m'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-required-count">{$t('programs.goals.modal.required_count_label')} *</Label>
|
||||
<Label for="goal-required-count">{$t('cohorts.goals.modal.required_count_label')} *</Label>
|
||||
<Input
|
||||
id="goal-required-count"
|
||||
type="number"
|
||||
@@ -237,7 +237,7 @@
|
||||
|
||||
{#if type === 'score' || type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-score">{$t('programs.goals.modal.score_threshold_label')} *</Label>
|
||||
<Label for="goal-score">{$t('cohorts.goals.modal.score_threshold_label')} *</Label>
|
||||
<Input
|
||||
id="goal-score"
|
||||
type="number"
|
||||
@@ -254,7 +254,7 @@
|
||||
|
||||
{#if type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-team-rate">{$t('programs.goals.modal.team_pass_rate_label')} *</Label>
|
||||
<Label for="goal-team-rate">{$t('cohorts.goals.modal.team_pass_rate_label')} *</Label>
|
||||
<Input
|
||||
id="goal-team-rate"
|
||||
type="number"
|
||||
@@ -270,13 +270,13 @@
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('programs.goals.modal.deadline_label')}</Label>
|
||||
<Label>{$t('cohorts.goals.modal.deadline_label')}</Label>
|
||||
<Select.Root type="single" bind:value={deadlineKind} disabled={type === 'readiness'}>
|
||||
<Select.Trigger>{$t(`programs.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
|
||||
<Select.Trigger>{$t(`cohorts.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each PROGRAM_GOAL_DEADLINE_KINDS as kind (kind)}
|
||||
{#each COHORT_GOAL_DEADLINE_KINDS as kind (kind)}
|
||||
<Select.Item value={kind}>
|
||||
{$t(`programs.goals.deadline_kind.${kind}`)}
|
||||
{$t(`cohorts.goals.deadline_kind.${kind}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
@@ -285,14 +285,14 @@
|
||||
|
||||
{#if deadlineKind === 'absolute'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-deadline-date">{$t('programs.goals.modal.deadline_date_label')} *</Label>
|
||||
<Label for="goal-deadline-date">{$t('cohorts.goals.modal.deadline_date_label')} *</Label>
|
||||
<Input id="goal-deadline-date" type="date" bind:value={deadlineDate} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deadlineKind === 'relative_to_join'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-relative-days">{$t('programs.goals.modal.relative_days_label')} *</Label>
|
||||
<Label for="goal-relative-days">{$t('cohorts.goals.modal.relative_days_label')} *</Label>
|
||||
<Input
|
||||
id="goal-relative-days"
|
||||
type="number"
|
||||
@@ -309,7 +309,7 @@
|
||||
|
||||
{#if deadlineKind === 'recurring'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-recurring-months">{$t('programs.goals.modal.recurring_months_label')} *</Label>
|
||||
<Label for="goal-recurring-months">{$t('cohorts.goals.modal.recurring_months_label')} *</Label>
|
||||
<Input
|
||||
id="goal-recurring-months"
|
||||
type="number"
|
||||
@@ -326,9 +326,9 @@
|
||||
|
||||
{#if deadlineKind !== 'none'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-reminders">{$t('programs.goals.modal.reminders_label')}</Label>
|
||||
<Label for="goal-reminders">{$t('cohorts.goals.modal.reminders_label')}</Label>
|
||||
<Input id="goal-reminders" bind:value={reminderDaysBeforeText} placeholder="7, 3, 1" />
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.modal.reminders_helper')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.modal.reminders_helper')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -338,10 +338,10 @@
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<Button variant="ghost" onclick={onClose} disabled={isSaving}>
|
||||
{$t('programs.goals.modal.cancel')}
|
||||
{$t('cohorts.goals.modal.cancel')}
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleSave} loading={isSaving} disabled={isSaving}>
|
||||
{$t('programs.goals.modal.save')}
|
||||
{$t('cohorts.goals.modal.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
+15
-15
@@ -2,30 +2,30 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
const goals = $derived(programGoalApi.goalsFor(programId));
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listGoals(programId);
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function totalLearners(goal: ProgramGoal): number {
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: ProgramGoal, status: string): number {
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
|
||||
function onTrackPct(goal: ProgramGoal): number {
|
||||
function onTrackPct(goal: CohortGoal): number {
|
||||
const total = totalLearners(goal);
|
||||
if (total === 0) return 0;
|
||||
const onTrack =
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
{#if goals.length > 0}
|
||||
<section class="mb-4">
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.heading')}</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.heading')}</h3>
|
||||
<div class="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each goals as goal (goal.id)}
|
||||
{@const total = totalLearners(goal)}
|
||||
@@ -46,27 +46,27 @@
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<p class="line-clamp-2 text-sm font-medium">{goal.title}</p>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-1">
|
||||
<span class="text-lg font-semibold">{onTrackPct(goal)}%</span>
|
||||
<span class="ui:text-muted-foreground text-xs">{$t('programs.goals.tile.on_track')}</span>
|
||||
<span class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.tile.on_track')}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 text-xs">
|
||||
<span>
|
||||
<span class="font-medium">{completed}/{total}</span>
|
||||
{' '}{$t('programs.goals.tile.completed_short')}
|
||||
{' '}{$t('cohorts.goals.tile.completed_short')}
|
||||
</span>
|
||||
{#if atRisk > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRisk}</span>
|
||||
{' '}{$t('programs.goals.tile.at_risk_short')}
|
||||
{' '}{$t('cohorts.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdue > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdue}</span>
|
||||
{' '}{$t('programs.goals.tile.overdue_short')}
|
||||
{' '}{$t('cohorts.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
+27
-27
@@ -7,24 +7,24 @@
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { ProgramGoal } from '$features/program/utils/types';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
import GoalFormModal from './goal-form-modal.svelte';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
let { programId, canManage }: Props = $props();
|
||||
let { cohortId, canManage }: Props = $props();
|
||||
|
||||
let isModalOpen = $state(false);
|
||||
let editingGoal = $state<ProgramGoal | null>(null);
|
||||
let editingGoal = $state<CohortGoal | null>(null);
|
||||
|
||||
const goals = $derived(programGoalApi.goalsFor(programId));
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listGoals(programId);
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
@@ -32,20 +32,20 @@
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(goal: ProgramGoal) {
|
||||
function openEdit(goal: CohortGoal) {
|
||||
editingGoal = goal;
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
async function deleteGoal(goal: ProgramGoal) {
|
||||
const confirmMessage = $t('programs.goals.delete_confirm');
|
||||
async function deleteGoal(goal: CohortGoal) {
|
||||
const confirmMessage = $t('cohorts.goals.delete_confirm');
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
await programGoalApi.deleteGoal(programId, goal.id);
|
||||
await cohortGoalApi.deleteGoal(cohortId, goal.id);
|
||||
}
|
||||
|
||||
function summarizeDeadline(goal: ProgramGoal) {
|
||||
if (goal.deadlineKind === 'none') return $t('programs.goals.lms.no_deadline');
|
||||
function summarizeDeadline(goal: CohortGoal) {
|
||||
if (goal.deadlineKind === 'none') return $t('cohorts.goals.lms.no_deadline');
|
||||
if (goal.deadlineKind === 'absolute' && goal.deadlineDate) {
|
||||
return new Date(goal.deadlineDate).toLocaleDateString();
|
||||
}
|
||||
@@ -58,14 +58,14 @@
|
||||
return `Repeats every ${goal.recurringMonths}mo`;
|
||||
}
|
||||
|
||||
return $t('programs.goals.lms.no_deadline');
|
||||
return $t('cohorts.goals.lms.no_deadline');
|
||||
}
|
||||
|
||||
function totalLearners(goal: ProgramGoal): number {
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: ProgramGoal, status: string): number {
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
</script>
|
||||
@@ -73,21 +73,21 @@
|
||||
<section>
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold">{$t('programs.goals.heading')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.description')}</p>
|
||||
<h2 class="text-sm font-semibold">{$t('cohorts.goals.heading')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.description')}</p>
|
||||
</div>
|
||||
{#if canManage}
|
||||
<Button variant="secondary" onclick={openCreate}>
|
||||
<PlusIcon size={16} />
|
||||
{$t('programs.goals.add')}
|
||||
{$t('cohorts.goals.add')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if goals.length === 0}
|
||||
<div class="rounded-md border p-6 text-center">
|
||||
<p class="text-sm font-medium">{$t('programs.goals.empty_title')}</p>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('programs.goals.empty_description')}</p>
|
||||
<p class="text-sm font-medium">{$t('cohorts.goals.empty_title')}</p>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">{$t('cohorts.goals.empty_description')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -101,7 +101,7 @@
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<p class="font-medium">{goal.title}</p>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
<Badge variant="outline">{$t(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</div>
|
||||
{#if goal.description}
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">{goal.description}</p>
|
||||
@@ -124,19 +124,19 @@
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<div class="rounded border p-2 text-center">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.completed')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.completed')}</p>
|
||||
<p class="text-base font-semibold">{completed}/{total}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.in_progress')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.in_progress')}</p>
|
||||
<p class="text-base font-semibold">{statusValue(goal, 'in_progress')}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center {atRisk > 0 ? 'bg-amber-50' : ''}">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.at_risk')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.at_risk')}</p>
|
||||
<p class="text-base font-semibold">{atRisk}</p>
|
||||
</div>
|
||||
<div class="rounded border p-2 text-center {overdue > 0 ? 'bg-red-50' : ''}">
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('programs.goals.status.overdue')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('cohorts.goals.status.overdue')}</p>
|
||||
<p class="text-base font-semibold">{overdue}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,4 +146,4 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<GoalFormModal bind:open={isModalOpen} {programId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
|
||||
<GoalFormModal bind:open={isModalOpen} {cohortId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
|
||||
+17
-19
@@ -2,38 +2,36 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
/** When set, only show goals from this program. */
|
||||
programId?: string;
|
||||
/** When set, only show goals from this cohort. */
|
||||
cohortId?: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listMyGoals();
|
||||
cohortGoalApi.listMyGoals();
|
||||
});
|
||||
|
||||
const myGoals = $derived(
|
||||
programGoalApi.myGoals.filter((assignment) => !programId || assignment.programId === programId)
|
||||
);
|
||||
const myGoals = $derived(cohortGoalApi.myGoals.filter((assignment) => !cohortId || assignment.cohortId === cohortId));
|
||||
|
||||
function formatDue(assignment: (typeof programGoalApi.myGoals)[number]): string {
|
||||
if (!assignment.dueDate) return $t('programs.goals.lms.no_deadline');
|
||||
function formatDue(assignment: (typeof cohortGoalApi.myGoals)[number]): string {
|
||||
if (!assignment.dueDate) return $t('cohorts.goals.lms.no_deadline');
|
||||
|
||||
const dueDate = new Date(assignment.dueDate);
|
||||
const now = new Date();
|
||||
const days = Math.ceil((dueDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000));
|
||||
|
||||
if (days < 0) return $t('programs.goals.lms.overdue');
|
||||
if (days === 0) return $t('programs.goals.lms.due_today');
|
||||
if (days === 1) return $t('programs.goals.lms.due_tomorrow');
|
||||
if (days < 0) return $t('cohorts.goals.lms.overdue');
|
||||
if (days === 0) return $t('cohorts.goals.lms.due_today');
|
||||
if (days === 1) return $t('cohorts.goals.lms.due_tomorrow');
|
||||
|
||||
return $t('programs.goals.lms.due_in_days').replace('{days}', String(days));
|
||||
return $t('cohorts.goals.lms.due_in_days').replace('{days}', String(days));
|
||||
}
|
||||
|
||||
function progressPct(assignment: (typeof programGoalApi.myGoals)[number]): number {
|
||||
function progressPct(assignment: (typeof cohortGoalApi.myGoals)[number]): number {
|
||||
if (assignment.requiredCount <= 0) return 0;
|
||||
return Math.min(100, Math.round((assignment.completedCount / assignment.requiredCount) * 100));
|
||||
}
|
||||
@@ -49,19 +47,19 @@
|
||||
|
||||
{#if myGoals.length > 0}
|
||||
<section class="mb-4">
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('programs.goals.lms.heading')}</h3>
|
||||
<h3 class="mb-2 text-sm font-semibold">{$t('cohorts.goals.lms.heading')}</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each myGoals as assignment (assignment.id)}
|
||||
<div class="flex flex-col gap-2 rounded-md border p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{assignment.goal.title}</p>
|
||||
{#if !programId}
|
||||
<p class="ui:text-muted-foreground text-xs">{assignment.programName}</p>
|
||||
{#if !cohortId}
|
||||
<p class="ui:text-muted-foreground text-xs">{assignment.cohortName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Badge variant={statusVariant(assignment.status)}>
|
||||
{$t(`programs.goals.status.${assignment.status}`)}
|
||||
{$t(`cohorts.goals.status.${assignment.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
+15
-15
@@ -14,13 +14,13 @@
|
||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { programApi } from '../api';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
programId: string;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { programId }: Props = $props();
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -30,12 +30,12 @@
|
||||
const addPeopleParam = $derived(new URLSearchParams(page.url.search).get('add'));
|
||||
const isOpen = $derived(addPeopleParam === 'true');
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, programApi.members));
|
||||
const availableStudents = $derived.by(() => getAvailableStudents(orgApi.audience, cohortApi.members));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutorIds = new Set(
|
||||
programApi.members
|
||||
cohortApi.members
|
||||
.filter((member) => Number(member.roleId) === ROLE.TUTOR || Number(member.roleId) === ROLE.ADMIN)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
@@ -52,9 +52,9 @@
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], programMembers: typeof programApi.members) {
|
||||
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
|
||||
const existingStudentIds = new Set(
|
||||
programMembers
|
||||
cohortMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
@@ -133,9 +133,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
programApi.success = false;
|
||||
cohortApi.success = false;
|
||||
|
||||
await programApi.addMembers(programId, {
|
||||
await cohortApi.addMembers(cohortId, {
|
||||
members: selectedTutors.map((tutor) => ({
|
||||
profileId: tutor.profileId,
|
||||
roleId: ROLE.TUTOR,
|
||||
@@ -144,17 +144,17 @@
|
||||
}))
|
||||
});
|
||||
|
||||
if (programApi.success) {
|
||||
if (cohortApi.success) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
await programApi.assignExistingStudentsToProgram(programId, { profileIds, sendEmail });
|
||||
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
await programApi.inviteStudentsToProgram(programId, { recipientCsv, sendEmail });
|
||||
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -162,7 +162,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen || !programId) {
|
||||
if (!isOpen || !cohortId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.people.invite_modal_title')}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeTab}>
|
||||
@@ -205,7 +205,7 @@
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={programApi.isLoading}>
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
|
||||
{$t(`${INVITE_MODAL}.finish`)}
|
||||
</Button>
|
||||
</div>
|
||||
+18
-18
@@ -5,18 +5,18 @@
|
||||
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Program } from '../utils/types';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
program: Program;
|
||||
cohort: Cohort;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
let { program, isLMS = false, href }: Props = $props();
|
||||
let { cohort, isLMS = false, href }: Props = $props();
|
||||
|
||||
const courseCount = $derived(program.courseCount ?? 0);
|
||||
const studentCount = $derived(program.studentCount ?? 0);
|
||||
const courseCount = $derived(cohort.courseCount ?? 0);
|
||||
const studentCount = $derived(cohort.studentCount ?? 0);
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
@@ -24,12 +24,12 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRoute = isLMS ? `/programs/${program.id}/newsfeed?source=lms` : `/programs/${program.id}/newsfeed`;
|
||||
const nextRoute = isLMS ? `/cohorts/${cohort.id}/newsfeed?source=lms` : `/cohorts/${cohort.id}/newsfeed`;
|
||||
|
||||
goto(resolve(nextRoute, {}));
|
||||
}
|
||||
|
||||
function getStatusVariant(status: Program['status']) {
|
||||
function getStatusVariant(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'success';
|
||||
}
|
||||
@@ -41,16 +41,16 @@
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: Program['status']) {
|
||||
function getStatusLabelKey(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'programs.status.active';
|
||||
return 'cohorts.status.active';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'programs.status.inactive';
|
||||
return 'cohorts.status.inactive';
|
||||
}
|
||||
|
||||
return 'programs.status.archived';
|
||||
return 'cohorts.status.archived';
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,27 +63,27 @@
|
||||
>
|
||||
<div class="ui:flex ui:w-full ui:items-start ui:gap-6">
|
||||
<div class="ui:min-w-0 ui:flex-1">
|
||||
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{program.name}</p>
|
||||
<p class="ui:w-full ui:truncate ui:text-base ui:font-semibold">{cohort.name}</p>
|
||||
|
||||
{#if program.description}
|
||||
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{program.description}</p>
|
||||
{#if cohort.description}
|
||||
<p class="ui:mt-1 ui:text-sm ui:text-muted-foreground">{cohort.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:items-start">
|
||||
<Badge variant={getStatusVariant(program.status)}>
|
||||
{$t(getStatusLabelKey(program.status))}
|
||||
<Badge variant={getStatusVariant(cohort.status)}>
|
||||
{$t(getStatusLabelKey(cohort.status))}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="ui:flex ui:shrink-0 ui:flex-col ui:items-start ui:gap-1">
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.courses'), courseCount, true)}
|
||||
{pluralize(t.get('cohorts.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
|
||||
{#if !isLMS}
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('programs.card.students'), studentCount, true)}
|
||||
{pluralize(t.get('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
import {
|
||||
getNewsfeedReactionCounts,
|
||||
getSelectedNewsfeedReactionType,
|
||||
@@ -12,10 +12,10 @@
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
programId?: string;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
feed: CohortNewsfeedItem;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
cohortId?: string;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -30,7 +30,7 @@
|
||||
deleteComment?: (feedId: string, commentId: string) => void;
|
||||
addNewComment?: (content: string, feedId: string) => Promise<void> | void;
|
||||
addNewReaction?: (reactionType: string, feedId: string, authorId: string) => Promise<void> | void;
|
||||
onPin?: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
onPin?: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
isActive?: boolean;
|
||||
isReacting?: boolean;
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
let {
|
||||
feed,
|
||||
comments,
|
||||
programId,
|
||||
cohortId,
|
||||
editFeed = $bindable(),
|
||||
author,
|
||||
reactionAuthorId,
|
||||
@@ -63,7 +63,7 @@
|
||||
editFeed = feed;
|
||||
edit = true;
|
||||
await tick();
|
||||
programNewsfeedApi.openNewFeedModal();
|
||||
cohortNewsfeedApi.openNewFeedModal();
|
||||
};
|
||||
|
||||
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
|
||||
@@ -108,7 +108,7 @@
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{programId}
|
||||
{cohortId}
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
+17
-17
@@ -8,15 +8,15 @@
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import { cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { ProgramNewsfeedCommentsByFeedId } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import type { CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
feed: ProgramNewsfeedItem;
|
||||
cohortId?: string;
|
||||
feed: CohortNewsfeedItem;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -24,12 +24,12 @@
|
||||
avatarUrl: string;
|
||||
};
|
||||
canComment?: boolean;
|
||||
comments?: ProgramNewsfeedCommentsByFeedId;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
}
|
||||
|
||||
let { programId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
let { cohortId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
@@ -45,14 +45,14 @@
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!programId || !comments) return;
|
||||
if (!cohortId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
|
||||
try {
|
||||
await programNewsfeedApi.getComments(programId, feed.id);
|
||||
await cohortNewsfeedApi.getComments(cohortId, feed.id);
|
||||
} finally {
|
||||
isBootstrapping = false;
|
||||
}
|
||||
@@ -119,18 +119,18 @@
|
||||
class="justify-start"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<p class="py-2 text-sm">{pluralize($t('programs.newsfeed.comments.comment'), totalCount, true)}</p>
|
||||
<p class="py-2 text-sm">{pluralize($t('cohorts.newsfeed.comments.comment'), totalCount, true)}</p>
|
||||
|
||||
{#if totalCount > shownCount}
|
||||
<span class="ml-auto text-xs text-gray-600">
|
||||
{$t('programs.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
|
||||
{$t('cohorts.newsfeed.comments.showing_of', { shown: shownCount, total: totalCount })}
|
||||
</span>
|
||||
{/if}
|
||||
</Button>
|
||||
|
||||
{#if shownCount > 1}
|
||||
<Button variant="ghost" size="sm" onclick={minimize}>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('programs.newsfeed.comments.hide')}</span>
|
||||
<span class="ml-auto text-xs text-gray-600">{$t('cohorts.newsfeed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -144,7 +144,7 @@
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -166,7 +166,7 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('programs.newsfeed.comments.delete')}
|
||||
{$t('cohorts.newsfeed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -179,13 +179,13 @@
|
||||
|
||||
{#if canComment}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('programs.newsfeed.user_avatar_alt')} class="size-7" />
|
||||
<UserAvatar src={author.avatarUrl} alt={$t('cohorts.newsfeed.user_avatar_alt')} class="size-7" />
|
||||
<div class="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={comment}
|
||||
onkeydown={handleAddNewComment}
|
||||
placeholder={$t('programs.newsfeed.comments.placeholder')}
|
||||
placeholder={$t('cohorts.newsfeed.comments.placeholder')}
|
||||
class="w-full rounded-3xl border border-gray-200 bg-transparent p-1 pl-2 text-sm disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
required
|
||||
@@ -197,7 +197,7 @@
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('programs.newsfeed.comments.submit_aria')}
|
||||
aria-label={$t('cohorts.newsfeed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
+4
-4
@@ -19,17 +19,17 @@
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('programs.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
<Dialog.Title>{$t('cohorts.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<p class="text-lg">{$t('programs.newsfeed.delete_feed_description')}</p>
|
||||
<p class="text-lg">{$t('cohorts.newsfeed.delete_feed_description')}</p>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" onclick={() => (openDeleteModal = false)}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
{$t('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('programs.newsfeed.confirm_delete')}
|
||||
{$t('cohorts.newsfeed.confirm_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
+7
-7
@@ -5,13 +5,13 @@
|
||||
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
|
||||
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { HTMLRender } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
feed: ProgramNewsfeedItem;
|
||||
feed: CohortNewsfeedItem;
|
||||
canManageFeed?: boolean;
|
||||
onPin: (feedId: ProgramNewsfeedItem['id'], isPinned: ProgramNewsfeedItem['isPinned']) => void;
|
||||
onPin: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
onEdit: () => void;
|
||||
onRequestDelete: () => void;
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('programs.newsfeed.user_avatar_alt')}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
@@ -44,11 +44,11 @@
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
{feed.isPinned ? $t('programs.newsfeed.card.unpin') : $t('programs.newsfeed.card.pin')}
|
||||
{feed.isPinned ? $t('cohorts.newsfeed.card.unpin') : $t('cohorts.newsfeed.card.pin')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('programs.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('cohorts.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
|
||||
{$t('programs.newsfeed.card.delete')}
|
||||
{$t('cohorts.newsfeed.card.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
+20
-20
@@ -1,26 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import { getErrorKey } from '$features/program/utils/error';
|
||||
import { programNewsfeedApi } from '$features/program/api';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { getErrorKey } from '$features/cohort/utils/error';
|
||||
import { cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { TextEditor } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
programId?: string;
|
||||
cohortId?: string;
|
||||
edit?: boolean;
|
||||
editFeed: ProgramNewsfeedItem | null;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
}
|
||||
|
||||
let { programId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
let { cohortId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let newPost = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!programNewsfeedApi.isNewFeedModalOpen) {
|
||||
if (!cohortNewsfeedApi.isNewFeedModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
newPost = '';
|
||||
edit = false;
|
||||
editFeed = null;
|
||||
programNewsfeedApi.closeNewFeedModal();
|
||||
cohortNewsfeedApi.closeNewFeedModal();
|
||||
};
|
||||
|
||||
const onPost = async () => {
|
||||
@@ -44,27 +44,27 @@
|
||||
|
||||
try {
|
||||
if (edit && editFeed) {
|
||||
await programNewsfeedApi.update(programId, editFeed.id, { content: newPost ?? '' });
|
||||
await cohortNewsfeedApi.update(cohortId, editFeed.id, { content: newPost ?? '' });
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'update'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'update'));
|
||||
}
|
||||
} else {
|
||||
await programNewsfeedApi.create(programId, {
|
||||
await cohortNewsfeedApi.create(cohortId, {
|
||||
content: newPost ?? '',
|
||||
isPinned: false
|
||||
});
|
||||
|
||||
if (programNewsfeedApi.success) {
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, 'create'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'create'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
snackbar.error(getErrorKey(programNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
@@ -72,7 +72,7 @@
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={programNewsfeedApi.isNewFeedModalOpen}
|
||||
bind:open={cohortNewsfeedApi.isNewFeedModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetEditor();
|
||||
@@ -82,7 +82,7 @@
|
||||
<Dialog.Content class="h-fit! w-fit! max-w-none!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{edit ? $t('programs.newsfeed.edit_post') : $t('programs.newsfeed.make_a_post')}
|
||||
{edit ? $t('cohorts.newsfeed.edit_post') : $t('cohorts.newsfeed.make_a_post')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section class="max-w-lg">
|
||||
@@ -93,16 +93,16 @@
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('programs.newsfeed.editor_placeholder')}
|
||||
placeholder={$t('cohorts.newsfeed.editor_placeholder')}
|
||||
/>
|
||||
{/key}
|
||||
</section>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('programs.newsfeed.cancel')}
|
||||
{$t('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('programs.newsfeed.post_submit')}
|
||||
{$t('cohorts.newsfeed.post_submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
+14
-14
@@ -8,7 +8,7 @@
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { programApi } from '$features/program/api';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
@@ -19,10 +19,10 @@
|
||||
let { path, id }: Props = $props();
|
||||
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
const isLmsProgramContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const programsListPath = $derived(isLmsProgramContext ? '/lms/programs' : `${$currentOrgPath}/programs`);
|
||||
const isLmsCohortContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const cohortsListPath = $derived(isLmsCohortContext ? '/lms/cohorts' : `${$currentOrgPath}/cohorts`);
|
||||
|
||||
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);
|
||||
|
||||
// Admin + Tutor can manage members; org admins always can
|
||||
const canManageMembers = $derived(
|
||||
@@ -36,32 +36,32 @@
|
||||
[
|
||||
{
|
||||
id: 'newsfeed',
|
||||
title: $t('programs.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/programs/${id}/newsfeed`,
|
||||
title: $t('cohorts.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/cohorts/${id}/newsfeed`,
|
||||
isActive: currentPath.endsWith('/newsfeed'),
|
||||
icon: NewsFeedIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'courses',
|
||||
title: $t('programs.sidebar.courses') || 'Courses',
|
||||
url: `/programs/${id}/courses`,
|
||||
title: $t('cohorts.sidebar.courses') || 'Courses',
|
||||
url: `/cohorts/${id}/courses`,
|
||||
isActive: currentPath.endsWith('/courses'),
|
||||
icon: CourseIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
title: $t('programs.sidebar.people') || 'People',
|
||||
url: `/programs/${id}/people`,
|
||||
title: $t('cohorts.sidebar.people') || 'People',
|
||||
url: `/cohorts/${id}/people`,
|
||||
isActive: currentPath.endsWith('/people'),
|
||||
icon: PeopleIcon,
|
||||
show: () => canManageMembers
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: $t('programs.sidebar.settings') || 'Settings',
|
||||
url: `/programs/${id}/settings`,
|
||||
title: $t('cohorts.sidebar.settings') || 'Settings',
|
||||
url: `/cohorts/${id}/settings`,
|
||||
isActive: currentPath.endsWith('/settings'),
|
||||
icon: SettingsIcon,
|
||||
show: () => canManageSettings
|
||||
@@ -72,8 +72,8 @@
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton
|
||||
href={resolve(programsListPath, {})}
|
||||
label={$t(isLmsProgramContext ? 'lms_navigation.programs' : 'org_navigation.programs') || 'Programs'}
|
||||
href={resolve(cohortsListPath, {})}
|
||||
label={$t(isLmsCohortContext ? 'lms_navigation.cohorts' : 'org_navigation.cohorts') || 'Cohorts'}
|
||||
class="px-2! py-2!"
|
||||
/>
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.svelte';
|
||||
import Navigation from './program-sidebar-navigation.svelte';
|
||||
import Navigation from './cohort-sidebar-navigation.svelte';
|
||||
import SidebarSkeleton from '$features/ui/sidebar/sidebar-skeleton.svelte';
|
||||
import PoweredBy from '$features/ui/powered-by.svelte';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
@@ -16,10 +16,10 @@
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
isProgramReady?: boolean;
|
||||
isCohortReady?: boolean;
|
||||
}
|
||||
|
||||
let { path, id, isProgramReady = true }: Props = $props();
|
||||
let { path, id, isCohortReady = true }: Props = $props();
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
const sidebar = useSidebar();
|
||||
@@ -35,7 +35,7 @@
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isProgramReady}
|
||||
{#if isCohortReady}
|
||||
<Navigation path={currentPath} {id} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
@@ -62,7 +62,7 @@
|
||||
<Sidebar.Footer>
|
||||
<PoweredBy
|
||||
variant="sidebar"
|
||||
sidebarUtmSource="lms-program-sidebar"
|
||||
sidebarUtmSource="lms-cohort-sidebar"
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CohortSidebar } from './cohort-sidebar.svelte';
|
||||
+10
-10
@@ -7,11 +7,11 @@
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ProgramList from '../components/list.svelte';
|
||||
import type { Program } from '../utils/types';
|
||||
import CohortList from '../components/list.svelte';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
programs?: Program[];
|
||||
cohorts?: Cohort[];
|
||||
isLMS?: boolean;
|
||||
isLoading?: boolean;
|
||||
searchValue?: string;
|
||||
@@ -22,13 +22,13 @@
|
||||
}
|
||||
|
||||
let {
|
||||
programs = [],
|
||||
cohorts = [],
|
||||
isLMS = false,
|
||||
isLoading = false,
|
||||
searchValue = $bindable(''),
|
||||
emptyTitle = $t('programs.empty_title'),
|
||||
emptyDescription = $t('programs.empty_description'),
|
||||
searchPlaceholder = $t('programs.search_placeholder'),
|
||||
emptyTitle = $t('cohorts.empty_title'),
|
||||
emptyDescription = $t('cohorts.empty_description'),
|
||||
searchPlaceholder = $t('cohorts.search_placeholder'),
|
||||
filterControls
|
||||
}: Props = $props();
|
||||
</script>
|
||||
@@ -43,12 +43,12 @@
|
||||
<div class="flex h-64 items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
{:else if !programs.length}
|
||||
{:else if !cohorts.length}
|
||||
<Empty title={emptyTitle} description={emptyDescription} icon={GoalIcon} variant="page" />
|
||||
{:else}
|
||||
<ResourceListRow.Group class="w-full">
|
||||
{#each programs as program (program.id)}
|
||||
<ProgramList {program} {isLMS} />
|
||||
{#each cohorts as cohort (cohort.id)}
|
||||
<CohortList {cohort} {isLMS} />
|
||||
{/each}
|
||||
</ResourceListRow.Group>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CohortsPage } from './cohorts.svelte';
|
||||
@@ -0,0 +1,4 @@
|
||||
export type CohortSortBy = 'name' | 'courses' | 'students' | 'status';
|
||||
export type CohortSortOrder = 'asc' | 'desc';
|
||||
export const DEFAULT_COHORT_SORT: CohortSortBy = 'name';
|
||||
export const DEFAULT_COHORT_SORT_ORDER: CohortSortOrder = 'asc';
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
|
||||
|
||||
const COHORT_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
|
||||
[ErrorCodes.COHORT_FORBIDDEN]: 'snackbar.cohort.error.code.cohort_forbidden',
|
||||
[ErrorCodes.COHORT_MEMBER_CHECK_FAILED]: 'snackbar.cohort.error.code.cohort_member_check_failed',
|
||||
[ErrorCodes.COHORT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_not_found',
|
||||
[ErrorCodes.COHORT_CREATE_FAILED]: 'snackbar.cohort.error.code.cohort_create_failed',
|
||||
[ErrorCodes.COHORT_UPDATE_FAILED]: 'snackbar.cohort.error.code.cohort_update_failed',
|
||||
[ErrorCodes.COHORT_DELETE_FAILED]: 'snackbar.cohort.error.code.cohort_delete_failed',
|
||||
[ErrorCodes.COHORT_MEMBER_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_member_not_found',
|
||||
[ErrorCodes.COHORT_NEWSFEED_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_not_found',
|
||||
[ErrorCodes.COHORT_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.cohort.error.code.cohort_newsfeed_comment_not_found'
|
||||
};
|
||||
|
||||
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
|
||||
const parsed = tryParseApiErrorBody(rawError);
|
||||
const mapped = parsed?.code ? COHORT_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return kind === 'update' ? 'snackbar.cohort.error.post_update_failed' : 'snackbar.cohort.error.post_create_failed';
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type CohortListResponse = InferResponseType<typeof classroomio.cohort.$get>;
|
||||
type CohortListSuccess = Extract<CohortListResponse, { success: true }>;
|
||||
export type Cohort = CohortListSuccess['data'][number];
|
||||
|
||||
type CohortResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['$get']>;
|
||||
type CohortSuccess = Extract<CohortResponse, { success: true }>;
|
||||
export type CohortDetail = CohortSuccess['data'];
|
||||
|
||||
type CohortMembersResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['members']['$get']>;
|
||||
type CohortMembersSuccess = Extract<CohortMembersResponse, { success: true }>;
|
||||
export type CohortMember = CohortMembersSuccess['data'][number];
|
||||
|
||||
type CohortCoursesResponse = InferResponseType<(typeof classroomio.cohort)[':cohortId']['courses']['$get']>;
|
||||
type CohortCoursesSuccess = Extract<CohortCoursesResponse, { success: true }>;
|
||||
export type CohortCourse = CohortCoursesSuccess['data'][number];
|
||||
|
||||
export type ListCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$get'];
|
||||
type ListCohortNewsfeedResponse = InferResponseType<ListCohortNewsfeedRequest>;
|
||||
type ListCohortNewsfeedSuccess = Extract<ListCohortNewsfeedResponse, { success: true }>;
|
||||
export type CohortNewsfeed = ListCohortNewsfeedSuccess['data'];
|
||||
export type ListCohortNewsfeed = CohortNewsfeed['items'];
|
||||
export type CohortNewsfeedItem = ListCohortNewsfeed[number];
|
||||
|
||||
export type CreateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed']['$post'];
|
||||
export type UpdateCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$put'];
|
||||
export type ReactToCohortNewsfeedRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['react']['$put'];
|
||||
export type DeleteCohortNewsfeedRequest = (typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['$delete'];
|
||||
|
||||
export type GetCohortNewsfeedCommentsRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comments']['$get'];
|
||||
type GetCohortNewsfeedCommentsResponse = InferResponseType<GetCohortNewsfeedCommentsRequest>;
|
||||
type GetCohortNewsfeedCommentsSuccess = Extract<GetCohortNewsfeedCommentsResponse, { success: true }>;
|
||||
export type CohortNewsfeedCommentsResponse = GetCohortNewsfeedCommentsSuccess['data'];
|
||||
export type CohortNewsfeedComment = CohortNewsfeedCommentsResponse[number];
|
||||
|
||||
export type CreateCohortNewsfeedCommentRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment']['$post'];
|
||||
export type DeleteCohortNewsfeedCommentRequest =
|
||||
(typeof classroomio.cohort)[':cohortId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
|
||||
|
||||
// ─── Goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ListCohortGoalsRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$get'];
|
||||
type ListCohortGoalsResponse = InferResponseType<ListCohortGoalsRequest>;
|
||||
type ListCohortGoalsSuccess = Extract<ListCohortGoalsResponse, { success: true }>;
|
||||
export type CohortGoal = ListCohortGoalsSuccess['data'][number];
|
||||
export type CohortGoalStatusCounts = CohortGoal['statusCounts'];
|
||||
|
||||
export type CreateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals']['$post'];
|
||||
export type UpdateCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$put'];
|
||||
export type DeleteCohortGoalRequest = (typeof classroomio.cohort)[':cohortId']['goals'][':goalId']['$delete'];
|
||||
|
||||
export type MyGoalsRequest = typeof classroomio.cohort.my.goals.$get;
|
||||
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
|
||||
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
|
||||
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
|
||||
|
||||
export type GoalsOverviewRequest = typeof classroomio.cohort.goals.overview.$get;
|
||||
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
|
||||
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
|
||||
export type GoalsOverview = GoalsOverviewSuccess['data'];
|
||||
export type GoalsOverviewRow = GoalsOverview['goals'][number];
|
||||
@@ -1,129 +0,0 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { TCreateProgramGoal, TUpdateProgramGoal } from '@cio/utils/validation/program';
|
||||
import type { GoalsOverviewRow, MyGoalAssignment, ProgramGoal } from '../utils/types';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
class ProgramGoalApi extends BaseApiWithErrors {
|
||||
goalsByProgramId = $state<Record<string, ProgramGoal[]>>({});
|
||||
myGoals = $state<MyGoalAssignment[]>([]);
|
||||
overviewRows = $state<GoalsOverviewRow[]>([]);
|
||||
loadedProgramId = $state<string | null>(null);
|
||||
|
||||
goalsFor(programId: string): ProgramGoal[] {
|
||||
return this.goalsByProgramId[programId] ?? [];
|
||||
}
|
||||
|
||||
async listGoals(programId: string, force = false) {
|
||||
if (!force && this.goalsByProgramId[programId] && this.loadedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].goals.$get({ param: { programId } }),
|
||||
onSuccess: (res) => {
|
||||
this.goalsByProgramId = { ...this.goalsByProgramId, [programId]: res.data };
|
||||
this.loadedProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async createGoal(programId: string, data: TCreateProgramGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].goals.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.created') || 'Goal created');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'createProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async updateGoal(programId: string, goalId: string, data: TUpdateProgramGoal): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].$put({
|
||||
param: { programId, goalId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.updated') || 'Goal updated');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'updateProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async deleteGoal(programId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].$delete({
|
||||
param: { programId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
snackbar.success(t.get('programs.goals.deleted') || 'Goal deleted');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'deleteProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async evaluateGoal(programId: string, goalId: string): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['goals'][':goalId']['evaluate']['$post']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].goals[':goalId'].evaluate.$post({
|
||||
param: { programId, goalId }
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await this.listGoals(programId, true);
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'evaluateProgramGoal'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
async listMyGoals() {
|
||||
await this.execute<typeof classroomio.program.my.goals.$get>({
|
||||
requestFn: () => classroomio.program.my.goals.$get({}),
|
||||
onSuccess: (res) => {
|
||||
this.myGoals = res.data;
|
||||
},
|
||||
logContext: 'listMyGoals'
|
||||
});
|
||||
}
|
||||
|
||||
async listOverview() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.goals.overview.$get>({
|
||||
requestFn: () => classroomio.program.goals.overview.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (res) => {
|
||||
this.overviewRows = res.data.goals;
|
||||
},
|
||||
logContext: 'listGoalsOverview'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programGoalApi = new ProgramGoalApi();
|
||||
@@ -1,3 +0,0 @@
|
||||
export { programApi } from './program.svelte';
|
||||
export { programGoalApi } from './goal.svelte';
|
||||
export { programNewsfeedApi, type ProgramNewsfeedCommentsByFeedId } from './newsfeed.svelte';
|
||||
@@ -1,376 +0,0 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { Program, ProgramDetail, ProgramMember, ProgramCourse, ProgramNewsfeed } from '../utils/types';
|
||||
import type {
|
||||
TCreateProgram,
|
||||
TUpdateProgram,
|
||||
TAddProgramMembers,
|
||||
TUpdateProgramMember,
|
||||
TAddCourseToProgram,
|
||||
TInviteStudentsToProgram,
|
||||
TAssignExistingStudentsToProgram
|
||||
} from '@cio/utils/validation/program';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { get } from 'svelte/store';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
class ProgramApi extends BaseApiWithErrors {
|
||||
programs = $state<Program[]>([]);
|
||||
program = $state<ProgramDetail | null>(null);
|
||||
members = $state<ProgramMember[]>([]);
|
||||
courses = $state<ProgramCourse[]>([]);
|
||||
newsfeed = $state<ProgramNewsfeed | null>(null);
|
||||
isProgramShellLoading = $state(false);
|
||||
currentProgramId = $state<string | null>(null);
|
||||
|
||||
loadedProgramId = $state<string | null>(null);
|
||||
loadedMembersProgramId = $state<string | null>(null);
|
||||
loadedCoursesProgramId = $state<string | null>(null);
|
||||
loadedCoursesStudentExperience = $state<boolean | null>(null);
|
||||
loadedNewsfeedProgramId = $state<string | null>(null);
|
||||
|
||||
syncProgramCourseCount(programId: string, courseCount: number) {
|
||||
this.programs = this.programs.map((program) => (program.id === programId ? { ...program, courseCount } : program));
|
||||
}
|
||||
|
||||
async listPrograms() {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$get>({
|
||||
requestFn: () => classroomio.program.$get({ query: { organizationId: org.id } }),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listPrograms'
|
||||
});
|
||||
}
|
||||
|
||||
async getProgram(programId: string, force = false) {
|
||||
if (!force && this.loadedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.program = data.data;
|
||||
this.loadedProgramId = programId;
|
||||
},
|
||||
logContext: 'getProgram'
|
||||
});
|
||||
}
|
||||
|
||||
resetProgramShell(programId?: string) {
|
||||
this.currentProgramId = programId ?? null;
|
||||
this.program = null;
|
||||
this.members = [];
|
||||
this.courses = [];
|
||||
this.newsfeed = null;
|
||||
this.loadedProgramId = null;
|
||||
this.loadedMembersProgramId = null;
|
||||
this.loadedCoursesProgramId = null;
|
||||
this.loadedCoursesStudentExperience = null;
|
||||
this.loadedNewsfeedProgramId = null;
|
||||
}
|
||||
|
||||
async ensureProgramShell(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
const isShellReady =
|
||||
this.loadedProgramId === programId &&
|
||||
this.loadedMembersProgramId === programId &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience;
|
||||
|
||||
if (!force && isShellReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProgramChanged = this.currentProgramId !== programId;
|
||||
if (force || isProgramChanged) {
|
||||
this.resetProgramShell(programId);
|
||||
}
|
||||
|
||||
this.currentProgramId = programId;
|
||||
this.isProgramShellLoading = true;
|
||||
|
||||
try {
|
||||
await Promise.all([
|
||||
this.getProgram(programId, force),
|
||||
this.listMembers(programId, force),
|
||||
this.listCourses(programId, force)
|
||||
]);
|
||||
} finally {
|
||||
this.isProgramShellLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createProgram(data: TCreateProgram) {
|
||||
const org = get(currentOrg);
|
||||
if (!org.id) return;
|
||||
|
||||
await this.execute<typeof classroomio.program.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.program.$post({
|
||||
json: { ...data, organizationId: org.id }
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.programs = [res.data, ...this.programs];
|
||||
snackbar.success(t.get('programs.create_success') || 'Program created');
|
||||
},
|
||||
logContext: 'createProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async updateProgram(programId: string, data: TUpdateProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$put']>({
|
||||
requestFn: () => classroomio.program[':programId'].$put({ param: { programId }, json: data }),
|
||||
onSuccess: (res) => {
|
||||
this.program = res.data;
|
||||
this.programs = this.programs.map((p) => (p.id === programId ? { ...p, ...res.data } : p));
|
||||
snackbar.success(t.get('programs.update_success') || 'Program updated');
|
||||
},
|
||||
onError: (result) => {
|
||||
const message = typeof result === 'string' ? result : result.error;
|
||||
snackbar.error(message || 'Failed to update program');
|
||||
},
|
||||
logContext: 'updateProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProgram(programId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['$delete']>({
|
||||
requestFn: () => classroomio.program[':programId'].$delete({ param: { programId } }),
|
||||
onSuccess: () => {
|
||||
this.programs = this.programs.filter((p) => p.id !== programId);
|
||||
snackbar.success(t.get('programs.delete_success') || 'Program deleted');
|
||||
},
|
||||
logContext: 'deleteProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Members
|
||||
|
||||
async listMembers(programId: string, force = false) {
|
||||
if (!force && this.loadedMembersProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.members = data.data;
|
||||
this.loadedMembersProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async addMembers(programId: string, data: TAddProgramMembers) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].members.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.members_added') || 'Members added');
|
||||
},
|
||||
logContext: 'addProgramMembers'
|
||||
});
|
||||
}
|
||||
|
||||
async updateMember(programId: string, memberId: string, data: TUpdateProgramMember) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$put']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$put({
|
||||
param: { programId, memberId },
|
||||
json: data
|
||||
}),
|
||||
onSuccess: (res) => {
|
||||
this.members = this.members.map((m) => (m.id === memberId ? { ...m, ...res.data } : m));
|
||||
},
|
||||
logContext: 'updateProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(programId: string, memberId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['members'][':memberId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].members[':memberId'].$delete({
|
||||
param: { programId, memberId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.members = this.members.filter((m) => m.id !== memberId);
|
||||
snackbar.success(t.get('programs.member_removed') || 'Member removed');
|
||||
},
|
||||
logContext: 'removeProgramMember'
|
||||
});
|
||||
}
|
||||
|
||||
// Courses
|
||||
|
||||
async listCourses(programId: string, force = false) {
|
||||
const studentExperience = get(isStudentExperience);
|
||||
|
||||
if (
|
||||
!force &&
|
||||
this.loadedCoursesProgramId === programId &&
|
||||
this.loadedCoursesStudentExperience === studentExperience
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$get']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$get({ param: { programId } }),
|
||||
onSuccess: (data) => {
|
||||
this.courses = data.data;
|
||||
this.syncProgramCourseCount(programId, data.data.length);
|
||||
this.loadedCoursesProgramId = programId;
|
||||
this.loadedCoursesStudentExperience = studentExperience;
|
||||
},
|
||||
logContext: 'listProgramCourses'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourse(programId: string, data: TAddCourseToProgram) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].courses.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_added') || 'Course added');
|
||||
},
|
||||
logContext: 'addCourseToProgram'
|
||||
});
|
||||
}
|
||||
|
||||
async addCourses(programId: string, courseIds: string[]) {
|
||||
if (courseIds.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.success = false;
|
||||
|
||||
try {
|
||||
for (const courseId of courseIds) {
|
||||
const response = await classroomio.program[':programId'].courses.$post({
|
||||
param: { programId },
|
||||
json: { courseId }
|
||||
});
|
||||
const result = (await response.json()) as { success?: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
this.error = result.error ?? 'Failed to add course to program';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await this.listCourses(programId, true);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
|
||||
const successMessage =
|
||||
courseIds.length === 1
|
||||
? t.get('programs.course_added') || 'Course added'
|
||||
: t.get('programs.multiple_added') || 'Courses added';
|
||||
|
||||
snackbar.success(successMessage);
|
||||
this.success = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error in addCoursesToProgram:', error);
|
||||
this.error = error instanceof Error ? error.message : 'Failed to add courses to program';
|
||||
return false;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async removeCourse(programId: string, courseId: string) {
|
||||
await this.execute<(typeof classroomio.program)[':programId']['courses'][':courseId']['$delete']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].courses[':courseId'].$delete({
|
||||
param: { programId, courseId }
|
||||
}),
|
||||
onSuccess: () => {
|
||||
this.courses = this.courses.filter((c) => c.courseId !== courseId);
|
||||
this.syncProgramCourseCount(programId, this.courses.length);
|
||||
snackbar.success(t.get('programs.course_removed') || 'Course removed');
|
||||
},
|
||||
logContext: 'removeCourseFromProgram'
|
||||
});
|
||||
}
|
||||
|
||||
// Invitations (program-scoped)
|
||||
|
||||
/**
|
||||
* Invite new students to a program by CSV. Goes through the program-scoped
|
||||
* endpoint so a Program ADMIN/TUTOR can invite without org-admin rights.
|
||||
* Returns whether the call succeeded so callers can refresh their lists.
|
||||
*/
|
||||
async inviteStudentsToProgram(programId: string, data: TInviteStudentsToProgram): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['invite']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].invite.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.invite_sent') || 'Invites sent');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'inviteStudentsToProgram'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign existing org audience student profiles to this program.
|
||||
* Same scoping as `inviteStudentsToProgram` — caller only needs program-team
|
||||
* membership for this program.
|
||||
*/
|
||||
async assignExistingStudentsToProgram(programId: string, data: TAssignExistingStudentsToProgram): Promise<boolean> {
|
||||
let ok = false;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['invite']['assign']['$post']>({
|
||||
requestFn: () => classroomio.program[':programId'].invite.assign.$post({ param: { programId }, json: data }),
|
||||
onSuccess: async () => {
|
||||
await this.listMembers(programId, true);
|
||||
snackbar.success(t.get('programs.members_added') || 'Members added');
|
||||
ok = true;
|
||||
},
|
||||
logContext: 'assignExistingStudentsToProgram'
|
||||
});
|
||||
|
||||
return ok;
|
||||
}
|
||||
|
||||
// Newsfeed
|
||||
|
||||
async listNewsfeed(programId: string, force = false, cursor?: string) {
|
||||
if (!force && this.loadedNewsfeedProgramId === programId) return;
|
||||
|
||||
await this.execute<(typeof classroomio.program)[':programId']['newsfeed']['$get']>({
|
||||
requestFn: () =>
|
||||
classroomio.program[':programId'].newsfeed.$get({
|
||||
param: { programId },
|
||||
query: { limit: '10', ...(cursor ? { cursor } : {}) }
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
this.newsfeed = data.data;
|
||||
this.loadedNewsfeedProgramId = programId;
|
||||
},
|
||||
logContext: 'listProgramNewsfeed'
|
||||
});
|
||||
}
|
||||
|
||||
// LMS — enrolled programs
|
||||
|
||||
async listEnrolledPrograms() {
|
||||
await this.execute<typeof classroomio.program.enrolled.$get>({
|
||||
requestFn: () => classroomio.program.enrolled.$get({}),
|
||||
onSuccess: (data) => {
|
||||
this.programs = data.data;
|
||||
},
|
||||
logContext: 'listEnrolledPrograms'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const programApi = new ProgramApi();
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ProgramSidebar } from './program-sidebar.svelte';
|
||||
@@ -1 +0,0 @@
|
||||
export { default as ProgramsPage } from './programs.svelte';
|
||||
@@ -1,4 +0,0 @@
|
||||
export type ProgramSortBy = 'name' | 'courses' | 'students' | 'status';
|
||||
export type ProgramSortOrder = 'asc' | 'desc';
|
||||
export const DEFAULT_PROGRAM_SORT: ProgramSortBy = 'name';
|
||||
export const DEFAULT_PROGRAM_SORT_ORDER: ProgramSortOrder = 'asc';
|
||||
@@ -1,25 +0,0 @@
|
||||
import { ErrorCodes } from '@cio/utils/constants';
|
||||
import { tryParseApiErrorBody } from '$lib/utils/services/api/parse-api-error-body';
|
||||
|
||||
const PROGRAM_ERROR_CODE_SNACKBAR_KEYS: Record<string, string> = {
|
||||
[ErrorCodes.PROGRAM_FORBIDDEN]: 'snackbar.program.error.code.program_forbidden',
|
||||
[ErrorCodes.PROGRAM_MEMBER_CHECK_FAILED]: 'snackbar.program.error.code.program_member_check_failed',
|
||||
[ErrorCodes.PROGRAM_NOT_FOUND]: 'snackbar.program.error.code.program_not_found',
|
||||
[ErrorCodes.PROGRAM_CREATE_FAILED]: 'snackbar.program.error.code.program_create_failed',
|
||||
[ErrorCodes.PROGRAM_UPDATE_FAILED]: 'snackbar.program.error.code.program_update_failed',
|
||||
[ErrorCodes.PROGRAM_DELETE_FAILED]: 'snackbar.program.error.code.program_delete_failed',
|
||||
[ErrorCodes.PROGRAM_MEMBER_NOT_FOUND]: 'snackbar.program.error.code.program_member_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_not_found',
|
||||
[ErrorCodes.PROGRAM_NEWSFEED_COMMENT_NOT_FOUND]: 'snackbar.program.error.code.program_newsfeed_comment_not_found'
|
||||
};
|
||||
|
||||
export function getErrorKey(rawError: string | null, kind: 'create' | 'update'): string {
|
||||
const parsed = tryParseApiErrorBody(rawError);
|
||||
const mapped = parsed?.code ? PROGRAM_ERROR_CODE_SNACKBAR_KEYS[parsed.code] : undefined;
|
||||
|
||||
if (mapped) {
|
||||
return mapped;
|
||||
}
|
||||
|
||||
return kind === 'update' ? 'snackbar.program.error.post_update_failed' : 'snackbar.program.error.post_create_failed';
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
type ProgramListResponse = InferResponseType<typeof classroomio.program.$get>;
|
||||
type ProgramListSuccess = Extract<ProgramListResponse, { success: true }>;
|
||||
export type Program = ProgramListSuccess['data'][number];
|
||||
|
||||
type ProgramResponse = InferResponseType<(typeof classroomio.program)[':programId']['$get']>;
|
||||
type ProgramSuccess = Extract<ProgramResponse, { success: true }>;
|
||||
export type ProgramDetail = ProgramSuccess['data'];
|
||||
|
||||
type ProgramMembersResponse = InferResponseType<(typeof classroomio.program)[':programId']['members']['$get']>;
|
||||
type ProgramMembersSuccess = Extract<ProgramMembersResponse, { success: true }>;
|
||||
export type ProgramMember = ProgramMembersSuccess['data'][number];
|
||||
|
||||
type ProgramCoursesResponse = InferResponseType<(typeof classroomio.program)[':programId']['courses']['$get']>;
|
||||
type ProgramCoursesSuccess = Extract<ProgramCoursesResponse, { success: true }>;
|
||||
export type ProgramCourse = ProgramCoursesSuccess['data'][number];
|
||||
|
||||
export type ListProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$get'];
|
||||
type ListProgramNewsfeedResponse = InferResponseType<ListProgramNewsfeedRequest>;
|
||||
type ListProgramNewsfeedSuccess = Extract<ListProgramNewsfeedResponse, { success: true }>;
|
||||
export type ProgramNewsfeed = ListProgramNewsfeedSuccess['data'];
|
||||
export type ListProgramNewsfeed = ProgramNewsfeed['items'];
|
||||
export type ProgramNewsfeedItem = ListProgramNewsfeed[number];
|
||||
|
||||
export type CreateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed']['$post'];
|
||||
export type UpdateProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$put'];
|
||||
export type ReactToProgramNewsfeedRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['react']['$put'];
|
||||
export type DeleteProgramNewsfeedRequest = (typeof classroomio.program)[':programId']['newsfeed'][':feedId']['$delete'];
|
||||
|
||||
export type GetProgramNewsfeedCommentsRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comments']['$get'];
|
||||
type GetProgramNewsfeedCommentsResponse = InferResponseType<GetProgramNewsfeedCommentsRequest>;
|
||||
type GetProgramNewsfeedCommentsSuccess = Extract<GetProgramNewsfeedCommentsResponse, { success: true }>;
|
||||
export type ProgramNewsfeedCommentsResponse = GetProgramNewsfeedCommentsSuccess['data'];
|
||||
export type ProgramNewsfeedComment = ProgramNewsfeedCommentsResponse[number];
|
||||
|
||||
export type CreateProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment']['$post'];
|
||||
export type DeleteProgramNewsfeedCommentRequest =
|
||||
(typeof classroomio.program)[':programId']['newsfeed'][':feedId']['comment'][':commentId']['$delete'];
|
||||
|
||||
// ─── Goals ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ListProgramGoalsRequest = (typeof classroomio.program)[':programId']['goals']['$get'];
|
||||
type ListProgramGoalsResponse = InferResponseType<ListProgramGoalsRequest>;
|
||||
type ListProgramGoalsSuccess = Extract<ListProgramGoalsResponse, { success: true }>;
|
||||
export type ProgramGoal = ListProgramGoalsSuccess['data'][number];
|
||||
export type ProgramGoalStatusCounts = ProgramGoal['statusCounts'];
|
||||
|
||||
export type CreateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals']['$post'];
|
||||
export type UpdateProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$put'];
|
||||
export type DeleteProgramGoalRequest = (typeof classroomio.program)[':programId']['goals'][':goalId']['$delete'];
|
||||
|
||||
export type MyGoalsRequest = typeof classroomio.program.my.goals.$get;
|
||||
type MyGoalsResponse = InferResponseType<MyGoalsRequest>;
|
||||
type MyGoalsSuccess = Extract<MyGoalsResponse, { success: true }>;
|
||||
export type MyGoalAssignment = MyGoalsSuccess['data'][number];
|
||||
|
||||
export type GoalsOverviewRequest = typeof classroomio.program.goals.overview.$get;
|
||||
type GoalsOverviewResponse = InferResponseType<GoalsOverviewRequest>;
|
||||
type GoalsOverviewSuccess = Extract<GoalsOverviewResponse, { success: true }>;
|
||||
export type GoalsOverview = GoalsOverviewSuccess['data'];
|
||||
export type GoalsOverviewRow = GoalsOverview['goals'][number];
|
||||
@@ -14,7 +14,7 @@ const SEARCH_DEBOUNCE_MS = 200;
|
||||
function emptyResults(): GroupedSearchResults {
|
||||
return {
|
||||
course: [],
|
||||
program: [],
|
||||
cohort: [],
|
||||
widget: [],
|
||||
tag: [],
|
||||
audience: [],
|
||||
@@ -137,13 +137,13 @@ function mapSearchResults(data: SearchOrgData, currentOrgPath: string): GroupedS
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
results.cohort = data.cohorts.map(
|
||||
(cohort): SearchResultItem => ({
|
||||
kind: 'cohort',
|
||||
id: cohort.id,
|
||||
title: cohort.name,
|
||||
subtitle: summarize(cohort.description),
|
||||
url: `/cohorts/${cohort.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
@@ -193,13 +193,13 @@ function mapLmsSearchResults(data: SearchLmsData): GroupedSearchResults {
|
||||
})
|
||||
);
|
||||
|
||||
results.program = data.programs.map(
|
||||
(program): SearchResultItem => ({
|
||||
kind: 'program',
|
||||
id: program.id,
|
||||
title: program.name,
|
||||
subtitle: summarize(program.description),
|
||||
url: `/programs/${program.id}/courses`
|
||||
results.cohort = data.cohorts.map(
|
||||
(cohort): SearchResultItem => ({
|
||||
kind: 'cohort',
|
||||
id: cohort.id,
|
||||
title: cohort.name,
|
||||
subtitle: summarize(cohort.description),
|
||||
url: `/cohorts/${cohort.id}/courses`
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
const hasQuery = $derived(query.trim().length > 0);
|
||||
const hasServerResults = $derived(
|
||||
searchApi.results.course.length > 0 ||
|
||||
searchApi.results.program.length > 0 ||
|
||||
searchApi.results.cohort.length > 0 ||
|
||||
searchApi.results.widget.length > 0 ||
|
||||
searchApi.results.tag.length > 0 ||
|
||||
searchApi.results.audience.length > 0
|
||||
@@ -133,8 +133,8 @@
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
<SearchResultGroup
|
||||
heading={$t('app.search.command_palette.groups.programs')}
|
||||
items={groupItems('program')}
|
||||
heading={$t('app.search.command_palette.groups.cohorts')}
|
||||
items={groupItems('cohort')}
|
||||
fallbackIcon={Goal}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ export type SearchLmsResponse = InferResponseType<SearchLmsRequest>;
|
||||
export type SearchLmsSuccess = Extract<SearchLmsResponse, { success: true }>;
|
||||
export type SearchLmsData = SearchLmsSuccess['data'];
|
||||
|
||||
export type SearchResultKind = 'course' | 'program' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
|
||||
export type SearchResultKind = 'course' | 'cohort' | 'widget' | 'tag' | 'audience' | 'nav' | 'settings';
|
||||
export type SearchScope = 'org' | 'lms';
|
||||
|
||||
export interface SearchResultItem {
|
||||
|
||||
@@ -73,10 +73,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
matchPattern: '^/lms/explore(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.programs',
|
||||
path: '/programs',
|
||||
titleKey: 'lms_navigation.cohorts',
|
||||
path: '/cohorts',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/lms/programs(/.*)?$'
|
||||
matchPattern: '^/lms/cohorts(/.*)?$'
|
||||
},
|
||||
{
|
||||
titleKey: 'lms_navigation.exercise',
|
||||
|
||||
@@ -117,10 +117,10 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
titleKey: 'org_navigation.programs',
|
||||
path: '/programs',
|
||||
titleKey: 'org_navigation.cohorts',
|
||||
path: '/cohorts',
|
||||
icon: GoalIcon,
|
||||
matchPattern: '^/org/[^/]+/programs(/.*)?$'
|
||||
matchPattern: '^/org/[^/]+/cohorts(/.*)?$'
|
||||
},
|
||||
{
|
||||
group: 'content',
|
||||
|
||||
Reference in New Issue
Block a user