feat(cohorts): rename Programs to Cohorts with data migration (#694)
* feat(cohorts): rename Programs to Cohorts with data migration - Add cohort database tables and Drizzle migration alongside legacy program tables - Introduce migrate-programs-to-cohorts script to copy existing program data safely - Rename API routes (/cohort), services, queries, validation, and error codes - Rename dashboard feature module, routes (/cohorts), navigation, and translations - Update docs, emails, and internal compliance cron endpoints for cohort terminology - Keep legacy program tables in place for rollback until explicitly removed Co-authored-by: Rotimi Best <[email protected]> * fix(db): restore migrate:answer-data script alongside cohort migration Co-authored-by: Rotimi Best <[email protected]> * fix(db): cast program enums when migrating data to cohort tables Co-authored-by: Rotimi Best <[email protected]> * fix(db): truncate cohort tables before program-to-cohort copy Co-authored-by: Rotimi Best <[email protected]> * fix(db): report accurate row counts in programs-to-cohorts migration Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
Cursor Agent
parent
fe9f8c441f
commit
ec61bfe7d9
@@ -0,0 +1,199 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '../api';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
const COURSE_PAGE_SIZE = 20;
|
||||
|
||||
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(cohortApi.courses.map((course) => course.courseId));
|
||||
|
||||
return coursesApi.orgCourses.filter((course) => !existingCourseIds.has(course.id));
|
||||
});
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const normalizedSearch = searchValue.trim().toLowerCase();
|
||||
if (!normalizedSearch) {
|
||||
return availableCourses;
|
||||
}
|
||||
|
||||
return availableCourses.filter((course) =>
|
||||
[course.title, course.description].some((value) => value?.toLowerCase().includes(normalizedSearch))
|
||||
);
|
||||
});
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(filteredCourses.length / COURSE_PAGE_SIZE)));
|
||||
const paginatedCourses = $derived(
|
||||
filteredCourses.slice((currentPage - 1) * COURSE_PAGE_SIZE, currentPage * COURSE_PAGE_SIZE)
|
||||
);
|
||||
const selectedCount = $derived(selectedCourseIds.size);
|
||||
const showLoadingState = $derived(coursesApi.isLoading);
|
||||
const canGoToPreviousPage = $derived(currentPage > 1);
|
||||
const canGoToNextPage = $derived(currentPage < totalPages);
|
||||
const emptyMessage = $derived(
|
||||
searchValue.trim()
|
||||
? $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) => ({
|
||||
id: course.id,
|
||||
label: course.title || course.id,
|
||||
description: course.description || undefined
|
||||
}))
|
||||
);
|
||||
|
||||
async function ensureCoursesLoaded() {
|
||||
if (coursesApi.orgCourses.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await coursesApi.getOrgCourses();
|
||||
}
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
if (selectedCourseIds.has(courseId)) {
|
||||
selectedCourseIds.delete(courseId);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedCourseIds.add(courseId);
|
||||
}
|
||||
|
||||
function handleSearchValueChange(value: string) {
|
||||
searchValue = value;
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
selectedCourseIds.clear();
|
||||
searchValue = '';
|
||||
currentPage = 1;
|
||||
open = false;
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
currentPage = page;
|
||||
}
|
||||
|
||||
async function handleAdd() {
|
||||
const courseIds = [...selectedCourseIds];
|
||||
if (courseIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const didAddCourses = await cohortApi.addCourses(cohortId, courseIds);
|
||||
if (!didAddCourses) {
|
||||
return;
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
selectedCourseIds.clear();
|
||||
searchValue = '';
|
||||
currentPage = 1;
|
||||
|
||||
void ensureCoursesLoaded();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
coursesApi.cancelOrgCoursesRequest();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<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">
|
||||
<MultiSelectList
|
||||
class="border-0"
|
||||
{emptyMessage}
|
||||
items={multiSelectItems}
|
||||
isLoading={showLoadingState}
|
||||
isSelected={(id) => selectedCourseIds.has(id)}
|
||||
onSearchValueChange={handleSearchValueChange}
|
||||
onToggle={(id) => toggleCourse(id)}
|
||||
namePrefix="cohort-course"
|
||||
listClass="max-h-60"
|
||||
searchPlaceholder={$t('cohorts.courses.search_placeholder') || 'Search courses'}
|
||||
bind:searchValue
|
||||
>
|
||||
{#snippet headingSnippet()}
|
||||
<p class="ui:text-sm ui:font-medium">
|
||||
{$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 })}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
{/snippet}
|
||||
</MultiSelectList>
|
||||
|
||||
{#if totalPages > 1}
|
||||
<div class="flex items-center justify-between gap-2 text-sm">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{$t('cohorts.courses.pagination_status', {
|
||||
page: currentPage,
|
||||
totalPages
|
||||
}) || `Page ${currentPage} of ${totalPages}`}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => goToPage(currentPage - 1)}
|
||||
disabled={!canGoToPreviousPage || showLoadingState}
|
||||
>
|
||||
{$t('app.previous') || 'Previous'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => goToPage(currentPage + 1)}
|
||||
disabled={!canGoToNextPage || showLoadingState}
|
||||
>
|
||||
{$t('app.next') || 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={closeModal} disabled={cohortApi.isLoading}>
|
||||
{$t('app.cancel') || 'Cancel'}
|
||||
</Button>
|
||||
<Button onclick={handleAdd} loading={cohortApi.isLoading} disabled={selectedCount === 0 || cohortApi.isLoading}>
|
||||
{$t('cohorts.courses.add') || 'Add Course'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
cohort: Cohort;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { cohort, onclick }: Props = $props();
|
||||
|
||||
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}>
|
||||
{#snippet child({ props })}
|
||||
<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!">
|
||||
{cohort.name}
|
||||
</Item.Title>
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex w-full justify-between">
|
||||
<div class="w-[60%]">
|
||||
<p class="pl-2 text-xs dark:text-white">
|
||||
{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('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Item.Content>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Item.Root>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import * as ToggleGroup from '@cio/ui/base/toggle-group';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import FilterIcon from '@lucide/svelte/icons/filter';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import {
|
||||
DEFAULT_COHORT_SORT,
|
||||
DEFAULT_COHORT_SORT_ORDER,
|
||||
type CohortSortBy,
|
||||
type CohortSortOrder
|
||||
} from '../utils/constants';
|
||||
|
||||
interface StatusOption {
|
||||
value: 'ACTIVE' | 'INACTIVE' | 'ARCHIVED';
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SortOption {
|
||||
value: CohortSortBy;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sortKey?: CohortSortBy;
|
||||
selectedOrder?: CohortSortOrder;
|
||||
selectedStatuses?: string[];
|
||||
statusOptions?: StatusOption[];
|
||||
sortOptions?: SortOption[];
|
||||
onToggleStatus?: (status: string, checked: boolean) => void;
|
||||
onClearFilters?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
sortKey = $bindable(DEFAULT_COHORT_SORT),
|
||||
selectedOrder = $bindable(DEFAULT_COHORT_SORT_ORDER),
|
||||
selectedStatuses = [],
|
||||
statusOptions = [],
|
||||
sortOptions = [],
|
||||
onToggleStatus = () => {},
|
||||
onClearFilters = () => {}
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
|
||||
const hasActiveFilters = $derived(
|
||||
sortKey !== DEFAULT_COHORT_SORT || selectedOrder !== DEFAULT_COHORT_SORT_ORDER || selectedStatuses.length > 0
|
||||
);
|
||||
|
||||
function isStatusSelected(status: string) {
|
||||
return selectedStatuses.includes(status);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open={isOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div class="relative">
|
||||
<IconButton {...props} aria-label={$t('cohorts.filters.filter')} tooltip={$t('cohorts.filters.filter')}>
|
||||
<FilterIcon size={16} />
|
||||
</IconButton>
|
||||
{#if hasActiveFilters}
|
||||
<span
|
||||
class="ui:bg-primary absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border border-white"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
|
||||
<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('cohorts.filters.popover_title')}</p>
|
||||
<Button variant="link" class="h-auto p-0" onclick={onClearFilters} disabled={!hasActiveFilters}>
|
||||
{$t('cohorts.filters.clear_all')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('cohorts.filters.sort')}
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each sortOptions as option (option.value)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={sortKey === option.value ? 'secondary' : 'outline'}
|
||||
onclick={() => (sortKey = option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<p class="ui:text-muted-foreground text-xs font-semibold uppercase">
|
||||
{$t('cohorts.filters.order')}
|
||||
</p>
|
||||
<ToggleGroup.Root
|
||||
type="single"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
value={selectedOrder}
|
||||
onValueChange={(value) => {
|
||||
if (value === 'asc' || value === 'desc') {
|
||||
selectedOrder = value;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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('cohorts.filters.status')}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each statusOptions as option (option.value)}
|
||||
<label class="hover:ui:bg-muted/30 flex cursor-pointer items-center rounded-md border px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isStatusSelected(option.value)}
|
||||
onCheckedChange={(checked) => onToggleStatus(option.value, Boolean(checked))}
|
||||
/>
|
||||
<span class="text-sm">{option.label}</span>
|
||||
</div>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { cohortApi } from '../api';
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="border-border ui:bg-background sticky top-0 z-100 flex h-12 w-full shrink-0 items-center gap-2 border-b backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-8"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
|
||||
<div class="h-4 w-2">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<p class="max-w-2xs truncate text-sm font-medium">
|
||||
{cohortApi.cohort?.name || ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span class="grow"></span>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open = $bindable(false) }: Props = $props();
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
|
||||
async function handleCreate() {
|
||||
if (!name.trim()) return;
|
||||
await cohortApi.createCohort({ name: name.trim(), description: description.trim() || undefined });
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
name = '';
|
||||
description = '';
|
||||
open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('cohorts.create_modal.title') || 'Create Cohort'}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{$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('cohorts.create_modal.name_label') || 'Name'} *</Label>
|
||||
<Input
|
||||
id="cohort-name"
|
||||
bind:value={name}
|
||||
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('cohorts.create_modal.description_label') || 'Description'}</Label>
|
||||
<Textarea
|
||||
id="cohort-description"
|
||||
bind:value={description}
|
||||
placeholder={$t('cohorts.create_modal.description_placeholder') || 'What is this cohort about?'}
|
||||
rows={3}
|
||||
maxlength={2000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={handleCancel}>{$t('app.cancel') || 'Cancel'}</Button>
|
||||
<Button onclick={handleCreate} disabled={!name.trim() || cohortApi.isLoading}>
|
||||
{$t('cohorts.create_modal.submit') || 'Create'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,349 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import {
|
||||
COHORT_GOAL_DEADLINE_KINDS,
|
||||
COHORT_GOAL_TYPES,
|
||||
type TCreateCohortGoal,
|
||||
type TUpdateCohortGoal
|
||||
} from '@cio/utils/validation/cohort';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortApi, cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
type GoalType = (typeof COHORT_GOAL_TYPES)[number];
|
||||
type DeadlineKind = (typeof COHORT_GOAL_DEADLINE_KINDS)[number];
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
cohortId: string;
|
||||
goal: CohortGoal | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { open = $bindable(), cohortId, goal, onClose }: Props = $props();
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let type = $state<GoalType>('complete_all');
|
||||
let courseIds = $state<string[]>([]);
|
||||
let requiredCount = $state<number | null>(null);
|
||||
let scoreThreshold = $state<number | null>(80);
|
||||
let teamPassRateThreshold = $state<number | null>(80);
|
||||
let deadlineKind = $state<DeadlineKind>('none');
|
||||
let deadlineDate = $state('');
|
||||
let relativeDays = $state<number | null>(null);
|
||||
let recurringMonths = $state<number | null>(null);
|
||||
let reminderDaysBeforeText = $state('7, 1');
|
||||
let isSaving = $state(false);
|
||||
let formError = $state<string | null>(null);
|
||||
|
||||
const isEditing = $derived(goal !== null);
|
||||
const availableCourses = $derived(cohortApi.courses);
|
||||
|
||||
function resetFromGoal(source: CohortGoal | null) {
|
||||
if (source) {
|
||||
title = source.title;
|
||||
description = source.description ?? '';
|
||||
type = source.type;
|
||||
courseIds = source.courseIds ?? [];
|
||||
requiredCount = source.requiredCount ?? null;
|
||||
scoreThreshold = source.scoreThreshold ?? 80;
|
||||
teamPassRateThreshold = source.teamPassRateThreshold ?? 80;
|
||||
deadlineKind = source.deadlineKind;
|
||||
deadlineDate = source.deadlineDate ? new Date(source.deadlineDate).toISOString().slice(0, 10) : '';
|
||||
relativeDays = source.relativeDays ?? null;
|
||||
recurringMonths = source.recurringMonths ?? null;
|
||||
reminderDaysBeforeText = (source.reminderDaysBefore ?? [7, 1]).join(', ');
|
||||
formError = null;
|
||||
return;
|
||||
}
|
||||
|
||||
title = '';
|
||||
description = '';
|
||||
type = 'complete_all';
|
||||
courseIds = [];
|
||||
requiredCount = null;
|
||||
scoreThreshold = 80;
|
||||
teamPassRateThreshold = 80;
|
||||
deadlineKind = 'none';
|
||||
deadlineDate = '';
|
||||
relativeDays = null;
|
||||
recurringMonths = null;
|
||||
reminderDaysBeforeText = '7, 1';
|
||||
formError = null;
|
||||
}
|
||||
|
||||
// Re-seed form fields only on the open→true transition or when the goal
|
||||
// *reference* changes. `untrack` prevents per-property mutations on the goal
|
||||
// proxy (e.g. an in-place store refresh after save) from clobbering edits.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
const currentGoal = goal;
|
||||
untrack(() => resetFromGoal(currentGoal));
|
||||
});
|
||||
|
||||
// Readiness goals must use deadline=none.
|
||||
$effect(() => {
|
||||
if (type === 'readiness' && deadlineKind !== 'none') {
|
||||
deadlineKind = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
function parseReminders(text: string): number[] {
|
||||
return text
|
||||
.split(/[,\s]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => Number(part))
|
||||
.filter((value) => Number.isFinite(value) && value >= 0 && value <= 365);
|
||||
}
|
||||
|
||||
function buildPayload(): TCreateCohortGoal | null {
|
||||
if (!title.trim()) {
|
||||
formError = $t('cohorts.goals.modal.title_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
if (courseIds.length === 0) {
|
||||
formError = $t('cohorts.goals.modal.courses_label') + ' is required';
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
title: title.trim(),
|
||||
description: description.trim() || null,
|
||||
type,
|
||||
courseIds,
|
||||
requiredCount: type === 'n_of_m' ? requiredCount : null,
|
||||
scoreThreshold: type === 'score' || type === 'pass_rate' ? scoreThreshold : null,
|
||||
teamPassRateThreshold: type === 'pass_rate' ? teamPassRateThreshold : null,
|
||||
reminderDaysBefore: parseReminders(reminderDaysBeforeText),
|
||||
deadlineKind,
|
||||
deadlineDate: deadlineKind === 'absolute' && deadlineDate ? new Date(deadlineDate).toISOString() : null,
|
||||
relativeDays: deadlineKind === 'relative_to_join' ? relativeDays : null,
|
||||
recurringMonths: deadlineKind === 'recurring' ? recurringMonths : null
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
formError = null;
|
||||
const payload = buildPayload();
|
||||
if (!payload) return;
|
||||
|
||||
isSaving = true;
|
||||
try {
|
||||
const ok =
|
||||
isEditing && goal
|
||||
? await cohortGoalApi.updateGoal(cohortId, goal.id, payload as TUpdateCohortGoal)
|
||||
: await cohortGoalApi.createGoal(cohortId, payload);
|
||||
if (ok) onClose();
|
||||
else formError = cohortGoalApi.error ?? 'Failed to save';
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCourse(courseId: string) {
|
||||
courseIds = courseIds.includes(courseId) ? courseIds.filter((id) => id !== courseId) : [...courseIds, courseId];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(value) => !value && onClose()}>
|
||||
<Dialog.Content class="max-h-[90vh] w-[96vw] max-w-2xl overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.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('cohorts.goals.modal.title_label')} *</Label>
|
||||
<Input
|
||||
id="goal-title"
|
||||
bind:value={title}
|
||||
maxlength={255}
|
||||
placeholder={$t('cohorts.goals.modal.title_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-description">{$t('cohorts.goals.modal.description_label')}</Label>
|
||||
<Textarea
|
||||
id="goal-description"
|
||||
bind:value={description}
|
||||
rows={2}
|
||||
maxlength={2000}
|
||||
placeholder={$t('cohorts.goals.modal.description_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('cohorts.goals.modal.type_label')}</Label>
|
||||
<Select.Root type="single" bind:value={type}>
|
||||
<Select.Trigger>{$t(`cohorts.goals.type.${type}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each COHORT_GOAL_TYPES as goalType (goalType)}
|
||||
<Select.Item value={goalType}>
|
||||
{$t(`cohorts.goals.type.${goalType}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('cohorts.goals.modal.courses_label')} *</Label>
|
||||
{#if availableCourses.length === 0}
|
||||
<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)}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={courseIds.includes(item.courseId)}
|
||||
onCheckedChange={() => toggleCourse(item.courseId)}
|
||||
/>
|
||||
<span>{item.course.title ?? 'Untitled course'}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if type === 'n_of_m'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-required-count">{$t('cohorts.goals.modal.required_count_label')} *</Label>
|
||||
<Input
|
||||
id="goal-required-count"
|
||||
type="number"
|
||||
min="1"
|
||||
max={courseIds.length || 50}
|
||||
value={requiredCount ?? ''}
|
||||
oninput={(event) => {
|
||||
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
|
||||
requiredCount = Number.isNaN(value) ? null : value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if type === 'score' || type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-score">{$t('cohorts.goals.modal.score_threshold_label')} *</Label>
|
||||
<Input
|
||||
id="goal-score"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={scoreThreshold ?? ''}
|
||||
oninput={(event) => {
|
||||
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
|
||||
scoreThreshold = Number.isNaN(value) ? null : value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if type === 'pass_rate'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-team-rate">{$t('cohorts.goals.modal.team_pass_rate_label')} *</Label>
|
||||
<Input
|
||||
id="goal-team-rate"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
value={teamPassRateThreshold ?? ''}
|
||||
oninput={(event) => {
|
||||
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
|
||||
teamPassRateThreshold = Number.isNaN(value) ? null : value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label>{$t('cohorts.goals.modal.deadline_label')}</Label>
|
||||
<Select.Root type="single" bind:value={deadlineKind} disabled={type === 'readiness'}>
|
||||
<Select.Trigger>{$t(`cohorts.goals.deadline_kind.${deadlineKind}`)}</Select.Trigger>
|
||||
<Select.Content style="z-index: 500 !important">
|
||||
{#each COHORT_GOAL_DEADLINE_KINDS as kind (kind)}
|
||||
<Select.Item value={kind}>
|
||||
{$t(`cohorts.goals.deadline_kind.${kind}`)}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
{#if deadlineKind === 'absolute'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<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('cohorts.goals.modal.relative_days_label')} *</Label>
|
||||
<Input
|
||||
id="goal-relative-days"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3650"
|
||||
value={relativeDays ?? ''}
|
||||
oninput={(event) => {
|
||||
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
|
||||
relativeDays = Number.isNaN(value) ? null : value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deadlineKind === 'recurring'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="goal-recurring-months">{$t('cohorts.goals.modal.recurring_months_label')} *</Label>
|
||||
<Input
|
||||
id="goal-recurring-months"
|
||||
type="number"
|
||||
min="1"
|
||||
max="120"
|
||||
value={recurringMonths ?? ''}
|
||||
oninput={(event) => {
|
||||
const value = (event.currentTarget as HTMLInputElement).valueAsNumber;
|
||||
recurringMonths = Number.isNaN(value) ? null : value;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if deadlineKind !== 'none'}
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<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('cohorts.goals.modal.reminders_helper')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if formError}
|
||||
<p class="text-sm text-red-600">{formError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="mt-2 flex justify-end gap-2">
|
||||
<Button variant="ghost" onclick={onClose} disabled={isSaving}>
|
||||
{$t('cohorts.goals.modal.cancel')}
|
||||
</Button>
|
||||
<Button variant="secondary" onclick={handleSave} loading={isSaving} disabled={isSaving}>
|
||||
{$t('cohorts.goals.modal.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
|
||||
interface Props {
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
|
||||
function onTrackPct(goal: CohortGoal): number {
|
||||
const total = totalLearners(goal);
|
||||
if (total === 0) return 0;
|
||||
const onTrack =
|
||||
statusValue(goal, 'completed') + statusValue(goal, 'in_progress') + statusValue(goal, 'not_started');
|
||||
return Math.round((onTrack / total) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if goals.length > 0}
|
||||
<section class="mb-4">
|
||||
<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)}
|
||||
{@const completed = statusValue(goal, 'completed')}
|
||||
{@const overdue = statusValue(goal, 'overdue')}
|
||||
{@const atRisk = statusValue(goal, 'at_risk')}
|
||||
<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(`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('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('cohorts.goals.tile.completed_short')}
|
||||
</span>
|
||||
{#if atRisk > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRisk}</span>
|
||||
{' '}{$t('cohorts.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdue > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdue}</span>
|
||||
{' '}{$t('cohorts.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import PencilIcon from '@lucide/svelte/icons/pencil';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
import type { CohortGoal } from '$features/cohort/utils/types';
|
||||
import GoalFormModal from './goal-form-modal.svelte';
|
||||
|
||||
interface Props {
|
||||
cohortId: string;
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
let { cohortId, canManage }: Props = $props();
|
||||
|
||||
let isModalOpen = $state(false);
|
||||
let editingGoal = $state<CohortGoal | null>(null);
|
||||
|
||||
const goals = $derived(cohortGoalApi.goalsFor(cohortId));
|
||||
|
||||
onMount(() => {
|
||||
cohortGoalApi.listGoals(cohortId);
|
||||
});
|
||||
|
||||
function openCreate() {
|
||||
editingGoal = null;
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(goal: CohortGoal) {
|
||||
editingGoal = goal;
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
async function deleteGoal(goal: CohortGoal) {
|
||||
const confirmMessage = $t('cohorts.goals.delete_confirm');
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
await cohortGoalApi.deleteGoal(cohortId, goal.id);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
if (goal.deadlineKind === 'relative_to_join' && goal.relativeDays) {
|
||||
return `+${goal.relativeDays}d after join`;
|
||||
}
|
||||
|
||||
if (goal.deadlineKind === 'recurring' && goal.recurringMonths) {
|
||||
return `Repeats every ${goal.recurringMonths}mo`;
|
||||
}
|
||||
|
||||
return $t('cohorts.goals.lms.no_deadline');
|
||||
}
|
||||
|
||||
function totalLearners(goal: CohortGoal): number {
|
||||
return Object.values(goal.statusCounts ?? {}).reduce((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function statusValue(goal: CohortGoal, status: string): number {
|
||||
return goal.statusCounts?.[status] ?? 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="mb-3 flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<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('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('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">
|
||||
{#each goals as goal (goal.id)}
|
||||
{@const total = totalLearners(goal)}
|
||||
{@const completed = statusValue(goal, 'completed')}
|
||||
{@const overdue = statusValue(goal, 'overdue')}
|
||||
{@const atRisk = statusValue(goal, 'at_risk')}
|
||||
<div class="flex flex-col gap-3 rounded-md border p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<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(`cohorts.goals.type.${goal.type}`)}</Badge>
|
||||
</div>
|
||||
{#if goal.description}
|
||||
<p class="ui:text-muted-foreground mt-1 text-sm">{goal.description}</p>
|
||||
{/if}
|
||||
<p class="ui:text-muted-foreground mt-2 text-xs">
|
||||
{goal.courseIds.length} courses · {summarizeDeadline(goal)}
|
||||
</p>
|
||||
</div>
|
||||
{#if canManage}
|
||||
<div class="flex items-center gap-1">
|
||||
<IconButton onclick={() => openEdit(goal)}>
|
||||
<PencilIcon size={16} />
|
||||
</IconButton>
|
||||
<IconButton onclick={() => deleteGoal(goal)}>
|
||||
<TrashIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<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('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('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('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('cohorts.goals.status.overdue')}</p>
|
||||
<p class="text-base font-semibold">{overdue}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<GoalFormModal bind:open={isModalOpen} {cohortId} goal={editingGoal} onClose={() => (isModalOpen = false)} />
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortGoalApi } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
/** When set, only show goals from this cohort. */
|
||||
cohortId?: string;
|
||||
}
|
||||
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
cohortGoalApi.listMyGoals();
|
||||
});
|
||||
|
||||
const myGoals = $derived(cohortGoalApi.myGoals.filter((assignment) => !cohortId || assignment.cohortId === cohortId));
|
||||
|
||||
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('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('cohorts.goals.lms.due_in_days').replace('{days}', String(days));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
function statusVariant(status: string) {
|
||||
if (status === 'completed') return 'default';
|
||||
if (status === 'overdue') return 'destructive';
|
||||
if (status === 'at_risk') return 'outline';
|
||||
|
||||
return 'secondary';
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if myGoals.length > 0}
|
||||
<section class="mb-4">
|
||||
<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 !cohortId}
|
||||
<p class="ui:text-muted-foreground text-xs">{assignment.cohortName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<Badge variant={statusVariant(assignment.status)}>
|
||||
{$t(`cohorts.goals.status.${assignment.status}`)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
{assignment.completedCount}/{assignment.requiredCount} ({progressPct(assignment)}%)
|
||||
</span>
|
||||
<span
|
||||
class={assignment.status === 'overdue'
|
||||
? 'text-red-600'
|
||||
: assignment.status === 'at_risk'
|
||||
? 'text-amber-700'
|
||||
: 'ui:text-muted-foreground'}
|
||||
>
|
||||
{formatDue(assignment)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-1.5 w-full overflow-hidden rounded-full bg-gray-100">
|
||||
<div class="h-full bg-emerald-500 transition-[width]" style="width: {progressPct(assignment)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
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 { cohortApi } from '../api';
|
||||
|
||||
interface Props {
|
||||
cohortId: string;
|
||||
}
|
||||
|
||||
let { cohortId }: Props = $props();
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let isLoadingStudents = $state(false);
|
||||
let activeTab = $state<'tutors' | 'students'>('students');
|
||||
|
||||
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, cohortApi.members));
|
||||
const INVITE_MODAL = 'course.navItem.people.invite_modal';
|
||||
|
||||
function getTutors(team: OrgTeamMember[]) {
|
||||
const existingTutorIds = new Set(
|
||||
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))
|
||||
);
|
||||
|
||||
return team
|
||||
.filter((teamMember) => teamMember.verified)
|
||||
.filter((teamMember) => !existingTutorIds.has(teamMember.profileId))
|
||||
.map((teamMember) => ({
|
||||
id: teamMember.id,
|
||||
text: teamMember.fullname,
|
||||
profileId: teamMember.profileId,
|
||||
email: teamMember.email
|
||||
}));
|
||||
}
|
||||
|
||||
function getAvailableStudents(students: OrgStudent[], cohortMembers: typeof cohortApi.members) {
|
||||
const existingStudentIds = new Set(
|
||||
cohortMembers
|
||||
.filter((member) => Number(member.roleId) === ROLE.STUDENT)
|
||||
.map((member) => member.profileId)
|
||||
.filter((profileId): profileId is string => Boolean(profileId))
|
||||
);
|
||||
|
||||
return students.filter((student) => {
|
||||
if (!student.profileId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !existingStudentIds.has(student.profileId);
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
goto(resolve(page.url.pathname, {}));
|
||||
}
|
||||
|
||||
function setTutors(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
await orgApi.getOrgTeam();
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching teams', orgApi.error);
|
||||
return;
|
||||
}
|
||||
|
||||
tutors = getTutors(orgApi.teamMembers);
|
||||
});
|
||||
}
|
||||
|
||||
function loadStudents(orgId: string | undefined) {
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStudentSearch(value: string) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoadingStudents = true;
|
||||
|
||||
try {
|
||||
await orgApi.getOrgAudience(
|
||||
orgId,
|
||||
{
|
||||
...DEFAULT_ORG_AUDIENCE_QUERY,
|
||||
search: value.trim() || undefined
|
||||
},
|
||||
{ abortPrevious: true }
|
||||
);
|
||||
} finally {
|
||||
isLoadingStudents = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addTutors() {
|
||||
if (!selectedTutors.length) {
|
||||
closeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
cohortApi.success = false;
|
||||
|
||||
await cohortApi.addMembers(cohortId, {
|
||||
members: selectedTutors.map((tutor) => ({
|
||||
profileId: tutor.profileId,
|
||||
roleId: ROLE.TUTOR,
|
||||
email: tutor.email,
|
||||
name: tutor.text
|
||||
}))
|
||||
});
|
||||
|
||||
if (cohortApi.success) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
async function assignExistingStudents(profileIds: string[], sendEmail: boolean) {
|
||||
await cohortApi.assignExistingStudentsToCohort(cohortId, { profileIds, sendEmail });
|
||||
}
|
||||
|
||||
async function inviteNewStudents(recipientCsv: string, sendEmail: boolean) {
|
||||
await cohortApi.inviteStudentsToCohort(cohortId, { recipientCsv, sendEmail });
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setTutors($currentOrg.id);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isOpen || !cohortId) {
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
activeTab = 'students';
|
||||
selectedIds = [];
|
||||
void loadStudents($currentOrg.id);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('cohorts.people.invite_modal_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<UnderlineTabs.Root bind:value={activeTab}>
|
||||
<UnderlineTabs.List class="flex flex-wrap">
|
||||
<UnderlineTabs.Trigger value="tutors">
|
||||
{$t(`${INVITE_MODAL}.invite`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
<UnderlineTabs.Trigger value="students">
|
||||
{$t(`${INVITE_MODAL}.invite_students`)}
|
||||
</UnderlineTabs.Trigger>
|
||||
</UnderlineTabs.List>
|
||||
|
||||
<UnderlineTabs.Content value="tutors">
|
||||
<div class="space-y-3">
|
||||
<TutorSelectSection
|
||||
{tutors}
|
||||
bind:selectedIds
|
||||
isLoading={orgApi.isLoading}
|
||||
helperHref={`/org/${$currentOrg.siteName}/settings/teams`}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center gap-2">
|
||||
<Button variant="secondary" type="button" onclick={addTutors} loading={cohortApi.isLoading}>
|
||||
{$t(`${INVITE_MODAL}.finish`)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
|
||||
<UnderlineTabs.Content value="students">
|
||||
<div class="space-y-6">
|
||||
<ExistingStudentsSection
|
||||
students={availableStudents}
|
||||
isLoading={isLoadingStudents}
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
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 { Cohort } from '../utils/types';
|
||||
|
||||
interface Props {
|
||||
cohort: Cohort;
|
||||
isLMS?: boolean;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
let { cohort, isLMS = false, href }: Props = $props();
|
||||
|
||||
const courseCount = $derived(cohort.courseCount ?? 0);
|
||||
const studentCount = $derived(cohort.studentCount ?? 0);
|
||||
|
||||
function handleRowClick() {
|
||||
if (href) {
|
||||
goto(resolve(href, {}));
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRoute = isLMS ? `/cohorts/${cohort.id}/newsfeed?source=lms` : `/cohorts/${cohort.id}/newsfeed`;
|
||||
|
||||
goto(resolve(nextRoute, {}));
|
||||
}
|
||||
|
||||
function getStatusVariant(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'success';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'secondary';
|
||||
}
|
||||
|
||||
return 'outline';
|
||||
}
|
||||
|
||||
function getStatusLabelKey(status: Cohort['status']) {
|
||||
if (status === 'ACTIVE') {
|
||||
return 'cohorts.status.active';
|
||||
}
|
||||
|
||||
if (status === 'INACTIVE') {
|
||||
return 'cohorts.status.inactive';
|
||||
}
|
||||
|
||||
return 'cohorts.status.archived';
|
||||
}
|
||||
</script>
|
||||
|
||||
<ResourceListRow.Root
|
||||
variant="default"
|
||||
align="start"
|
||||
class="ui:cursor-pointer ui:py-3"
|
||||
onclick={handleRowClick}
|
||||
role="row"
|
||||
>
|
||||
<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">{cohort.name}</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(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('cohorts.card.courses'), courseCount, true)}
|
||||
</p>
|
||||
|
||||
{#if !isLMS}
|
||||
<p class="ui:text-sm ui:text-muted-foreground">
|
||||
{pluralize(t.get('cohorts.card.students'), studentCount, true)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</ResourceListRow.Root>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { cohortNewsfeedApi, type CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
import {
|
||||
getNewsfeedReactionCounts,
|
||||
getSelectedNewsfeedReactionType,
|
||||
NewsfeedReactions
|
||||
} from '@cio/ui/custom/newsfeed-reactions';
|
||||
import DeleteConfirmation from './delete-confirmation.svelte';
|
||||
import Header from './header.svelte';
|
||||
import Comments from './comments.svelte';
|
||||
|
||||
interface Props {
|
||||
feed: CohortNewsfeedItem;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
cohortId?: string;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
reactionAuthorId: string;
|
||||
canComment?: boolean;
|
||||
canManageFeed?: boolean;
|
||||
edit?: boolean;
|
||||
deleteFeed?: (feedId: string) => void;
|
||||
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: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
isActive?: boolean;
|
||||
isReacting?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
feed,
|
||||
comments,
|
||||
cohortId,
|
||||
editFeed = $bindable(),
|
||||
author,
|
||||
reactionAuthorId,
|
||||
canComment = true,
|
||||
canManageFeed = false,
|
||||
edit = $bindable(false),
|
||||
deleteFeed = (_feedId: string) => {},
|
||||
deleteComment = (_feedId: string, _commentId: string) => {},
|
||||
addNewComment = (_content: string, _feedId: string) => {},
|
||||
addNewReaction = (_reactionType: string, _feedId: string, _authorId: string) => {},
|
||||
onPin = (_feedId: string, _isPinned: boolean | null) => {},
|
||||
isActive = false,
|
||||
isReacting = false
|
||||
}: Props = $props();
|
||||
|
||||
let isDeleteFeedModal = $state(false);
|
||||
|
||||
const reactionCounts = $derived(getNewsfeedReactionCounts(feed.reaction));
|
||||
const selectedReactionType = $derived(getSelectedNewsfeedReactionType(feed.reaction, reactionAuthorId));
|
||||
|
||||
const openEditFeed = async () => {
|
||||
editFeed = feed;
|
||||
edit = true;
|
||||
await tick();
|
||||
cohortNewsfeedApi.openNewFeedModal();
|
||||
};
|
||||
|
||||
const handleReact = (reactionType: string) => addNewReaction(reactionType, feed.id, reactionAuthorId);
|
||||
|
||||
onMount(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.getElementById(feed.id);
|
||||
element?.scrollIntoView({
|
||||
block: 'start',
|
||||
behavior: 'smooth',
|
||||
inline: 'nearest'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
id={feed.id}
|
||||
class="mb-7 flex w-full max-w-3xl flex-col justify-between gap-2 rounded-md {isActive
|
||||
? 'border-primary-700 border-2'
|
||||
: 'ui:border'}"
|
||||
>
|
||||
<Header
|
||||
{feed}
|
||||
{canManageFeed}
|
||||
{onPin}
|
||||
onEdit={openEditFeed}
|
||||
onRequestDelete={() => {
|
||||
isDeleteFeedModal = true;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="px-3 pt-1 pb-2">
|
||||
<NewsfeedReactions
|
||||
{reactionCounts}
|
||||
{selectedReactionType}
|
||||
disabled={isReacting || !reactionAuthorId}
|
||||
onReactionToggle={handleReact}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Comments
|
||||
{cohortId}
|
||||
{feed}
|
||||
{author}
|
||||
{comments}
|
||||
{canComment}
|
||||
onAddComment={async (content) => {
|
||||
await addNewComment(content, feed.id);
|
||||
}}
|
||||
onDeleteComment={(commentId) => {
|
||||
deleteComment(feed.id, String(commentId));
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmation
|
||||
bind:openDeleteModal={isDeleteFeedModal}
|
||||
deleteFeed={() => {
|
||||
deleteFeed(feed.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import SendHorizontalIcon from '@lucide/svelte/icons/send-horizontal';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cohortNewsfeedApi } from '$features/cohort/api';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import type { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import type { CohortNewsfeedCommentsByFeedId } from '$features/cohort/api';
|
||||
|
||||
interface Props {
|
||||
cohortId?: string;
|
||||
feed: CohortNewsfeedItem;
|
||||
author: {
|
||||
id: string;
|
||||
username: string;
|
||||
fullname: string;
|
||||
avatarUrl: string;
|
||||
};
|
||||
canComment?: boolean;
|
||||
comments?: CohortNewsfeedCommentsByFeedId;
|
||||
onAddComment: (content: string) => Promise<void> | void;
|
||||
onDeleteComment: (commentId: string | number) => void;
|
||||
}
|
||||
|
||||
let { cohortId, feed, author, canComment = true, comments, onAddComment, onDeleteComment }: Props = $props();
|
||||
|
||||
let comment = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let isBootstrapping = $state(false);
|
||||
let didBootstrap = $state(false);
|
||||
|
||||
const pageSize = 5;
|
||||
let visibleCount = $state(1);
|
||||
|
||||
const loadedCount = $derived((comments?.items?.length ?? 0) as number);
|
||||
const totalCount = $derived(comments?.totalCount || feed.commentCount);
|
||||
const isLoading = $derived((comments?.isLoading ?? false) as boolean);
|
||||
const shownCount = $derived(Math.min(visibleCount, loadedCount));
|
||||
|
||||
const bootstrap = async () => {
|
||||
if (!cohortId || !comments) return;
|
||||
if (didBootstrap) return;
|
||||
|
||||
didBootstrap = true;
|
||||
isBootstrapping = true;
|
||||
|
||||
try {
|
||||
await cohortNewsfeedApi.getComments(cohortId, feed.id);
|
||||
} finally {
|
||||
isBootstrapping = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitComment = async () => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
isSubmitting = true;
|
||||
|
||||
try {
|
||||
await onAddComment(comment);
|
||||
comment = '';
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddNewComment = async (event: KeyboardEvent | MouseEvent) => {
|
||||
if (isSubmitting) return;
|
||||
|
||||
const maybeKey = event as KeyboardEvent & { key?: string };
|
||||
if (maybeKey.key !== 'Enter' && event.type !== 'click') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
await submitComment();
|
||||
};
|
||||
|
||||
const showMore = async () => {
|
||||
if (!comments) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!didBootstrap) {
|
||||
await bootstrap();
|
||||
}
|
||||
|
||||
visibleCount = visibleCount <= 1 ? pageSize : visibleCount + pageSize;
|
||||
};
|
||||
|
||||
const minimize = () => {
|
||||
visibleCount = visibleCount > 1 ? 1 : shownCount;
|
||||
};
|
||||
|
||||
const toggleComments = async () => {
|
||||
if (totalCount === shownCount) {
|
||||
minimize();
|
||||
return;
|
||||
}
|
||||
|
||||
await showMore();
|
||||
};
|
||||
</script>
|
||||
|
||||
<section class="border-t border-gray-200 p-3">
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={toggleComments}
|
||||
loading={isBootstrapping || isLoading}
|
||||
class="justify-start"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
<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('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('cohorts.newsfeed.comments.hide')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if comments}
|
||||
{#each comments.items.slice(0, shownCount) as commentItem (commentItem.id)}
|
||||
{#if commentItem.content}
|
||||
<div class="group flex items-center justify-between py-2">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={commentItem.authorAvatarUrl || ''}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium capitalize">{commentItem.authorFullname || ''}</p>
|
||||
<p class="text-xs font-medium text-gray-600">{calDateDiff(commentItem.createdAt)}</p>
|
||||
</div>
|
||||
<p>{commentItem.content}</p>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if commentItem.authorProfileId === $profile.id || $isOrgAdmin}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="hidden h-8 w-8 items-center justify-center rounded-md group-hover:flex hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item class="text-red-600" onclick={() => onDeleteComment(commentItem.id)}>
|
||||
{$t('cohorts.newsfeed.comments.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if canComment}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<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('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
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="rounded-full"
|
||||
loading={isSubmitting}
|
||||
onclick={handleAddNewComment}
|
||||
aria-label={$t('cohorts.newsfeed.comments.submit_aria')}
|
||||
>
|
||||
<SendHorizontalIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
openDeleteModal?: boolean;
|
||||
deleteFeed?: () => void;
|
||||
}
|
||||
|
||||
let { openDeleteModal = $bindable(false), deleteFeed = () => {} }: Props = $props();
|
||||
|
||||
const handleDelete = async () => {
|
||||
deleteFeed();
|
||||
openDeleteModal = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open={openDeleteModal}>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('cohorts.newsfeed.delete_feed_title')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div>
|
||||
<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('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={handleDelete}>
|
||||
{$t('cohorts.newsfeed.confirm_delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
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 { CohortNewsfeedItem } from '$features/cohort/utils/types';
|
||||
import { HTMLRender } from '$features/ui';
|
||||
|
||||
interface Props {
|
||||
feed: CohortNewsfeedItem;
|
||||
canManageFeed?: boolean;
|
||||
onPin: (feedId: CohortNewsfeedItem['id'], isPinned: CohortNewsfeedItem['isPinned']) => void;
|
||||
onEdit: () => void;
|
||||
onRequestDelete: () => void;
|
||||
}
|
||||
|
||||
let { feed, canManageFeed = false, onPin, onEdit, onRequestDelete }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="px-3 pt-3 pb-0">
|
||||
<div class="mb-2 flex justify-between">
|
||||
<span class="flex items-center gap-3">
|
||||
<div class="h-9 w-9">
|
||||
<img
|
||||
src={feed.authorAvatarUrl || ''}
|
||||
alt={$t('cohorts.newsfeed.user_avatar_alt')}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<p class="text-base font-semibold capitalize">{feed.authorFullname || ''}</p>
|
||||
<p class="text-sm font-medium text-gray-600">{calDateDiff(feed.createdAt)}</p>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if canManageFeed}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-gray-100 dark:hover:bg-neutral-700"
|
||||
>
|
||||
<EllipsisVerticalIcon class="h-5 w-5" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end">
|
||||
<DropdownMenu.Item onclick={() => onPin(feed.id, feed.isPinned)}>
|
||||
{feed.isPinned ? $t('cohorts.newsfeed.card.unpin') : $t('cohorts.newsfeed.card.pin')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={onEdit}>{$t('cohorts.newsfeed.card.edit')}</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="text-red-600" onclick={onRequestDelete}>
|
||||
{$t('cohorts.newsfeed.card.delete')}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isHtmlValueEmpty(feed.content || '')}
|
||||
<HTMLRender className="w-[80%]">
|
||||
<div>
|
||||
<SafeHtmlContent content={feed.content || ''} />
|
||||
</div>
|
||||
</HTMLRender>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as NewFeedModal } from './new-feed-modal.svelte';
|
||||
export { default as NewsFeedCard } from './card/card.svelte';
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
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 {
|
||||
cohortId?: string;
|
||||
edit?: boolean;
|
||||
editFeed: CohortNewsfeedItem | null;
|
||||
}
|
||||
|
||||
let { cohortId = '', edit = $bindable(false), editFeed = $bindable() }: Props = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let newPost = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (!cohortNewsfeedApi.isNewFeedModalOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (edit && editFeed) {
|
||||
newPost = editFeed.content || '';
|
||||
return;
|
||||
}
|
||||
|
||||
newPost = '';
|
||||
});
|
||||
|
||||
const resetEditor = () => {
|
||||
newPost = '';
|
||||
edit = false;
|
||||
editFeed = null;
|
||||
cohortNewsfeedApi.closeNewFeedModal();
|
||||
};
|
||||
|
||||
const onPost = async () => {
|
||||
isLoading = true;
|
||||
|
||||
try {
|
||||
if (edit && editFeed) {
|
||||
await cohortNewsfeedApi.update(cohortId, editFeed.id, { content: newPost ?? '' });
|
||||
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'update'));
|
||||
}
|
||||
} else {
|
||||
await cohortNewsfeedApi.create(cohortId, {
|
||||
content: newPost ?? '',
|
||||
isPinned: false
|
||||
});
|
||||
|
||||
if (cohortNewsfeedApi.success) {
|
||||
resetEditor();
|
||||
} else {
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, 'create'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
snackbar.error(getErrorKey(cohortNewsfeedApi.error, edit ? 'update' : 'create'));
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={cohortNewsfeedApi.isNewFeedModalOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
resetEditor();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="h-fit! w-fit! max-w-none!">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{edit ? $t('cohorts.newsfeed.edit_post') : $t('cohorts.newsfeed.make_a_post')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<section class="max-w-lg">
|
||||
{#key edit ? editFeed?.id : 'new'}
|
||||
<TextEditor
|
||||
content={newPost}
|
||||
onChange={(text) => {
|
||||
newPost = text;
|
||||
}}
|
||||
editorClass="max-h-[400px]"
|
||||
placeholder={$t('cohorts.newsfeed.editor_placeholder')}
|
||||
/>
|
||||
{/key}
|
||||
</section>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={resetEditor}>
|
||||
{$t('cohorts.newsfeed.cancel')}
|
||||
</Button>
|
||||
<Button loading={isLoading} onclick={onPost}>
|
||||
{$t('cohorts.newsfeed.post_submit')}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { HoverableItem, NewsFeedIcon, CourseIcon, PeopleIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
let { path, id }: Props = $props();
|
||||
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
const isLmsCohortContext = $derived(page.url.searchParams.get('source') === 'lms' || $isStudentExperience);
|
||||
const cohortsListPath = $derived(isLmsCohortContext ? '/lms/cohorts' : `${$currentOrgPath}/cohorts`);
|
||||
|
||||
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(
|
||||
$isOrgAdmin === true || (currentMemberRole !== null && currentMemberRole <= ROLE.TUTOR)
|
||||
);
|
||||
|
||||
// Only Admins and org admins can access settings
|
||||
const canManageSettings = $derived($isOrgAdmin === true || currentMemberRole === ROLE.ADMIN);
|
||||
|
||||
const navItems = $derived(
|
||||
[
|
||||
{
|
||||
id: 'newsfeed',
|
||||
title: $t('cohorts.sidebar.newsfeed') || 'Newsfeed',
|
||||
url: `/cohorts/${id}/newsfeed`,
|
||||
isActive: currentPath.endsWith('/newsfeed'),
|
||||
icon: NewsFeedIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'courses',
|
||||
title: $t('cohorts.sidebar.courses') || 'Courses',
|
||||
url: `/cohorts/${id}/courses`,
|
||||
isActive: currentPath.endsWith('/courses'),
|
||||
icon: CourseIcon,
|
||||
show: () => true
|
||||
},
|
||||
{
|
||||
id: 'people',
|
||||
title: $t('cohorts.sidebar.people') || 'People',
|
||||
url: `/cohorts/${id}/people`,
|
||||
isActive: currentPath.endsWith('/people'),
|
||||
icon: PeopleIcon,
|
||||
show: () => canManageMembers
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
title: $t('cohorts.sidebar.settings') || 'Settings',
|
||||
url: `/cohorts/${id}/settings`,
|
||||
isActive: currentPath.endsWith('/settings'),
|
||||
icon: SettingsIcon,
|
||||
show: () => canManageSettings
|
||||
}
|
||||
].filter((item) => item.show())
|
||||
);
|
||||
</script>
|
||||
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<BackButton
|
||||
href={resolve(cohortsListPath, {})}
|
||||
label={$t(isLmsCohortContext ? 'lms_navigation.cohorts' : 'org_navigation.cohorts') || 'Cohorts'}
|
||||
class="px-2! py-2!"
|
||||
/>
|
||||
|
||||
<Sidebar.Menu>
|
||||
{#each navItems as item (item.id)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<HoverableItem>
|
||||
{#snippet children(isHovered)}
|
||||
{@const Icon = item.icon}
|
||||
<a href={resolve(item.url, {})} {...props}>
|
||||
<Icon {isHovered} size={16} />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
|
||||
<style>
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
import OrgLogo from '$features/ui/sidebar/org-sidebar/org-logo.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';
|
||||
|
||||
const SIDEBAR_ITEM_SKELETON_COUNT = 4;
|
||||
|
||||
interface Props {
|
||||
path: string;
|
||||
id: string;
|
||||
isCohortReady?: boolean;
|
||||
}
|
||||
|
||||
let { path, id, isCohortReady = true }: Props = $props();
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
const sidebar = useSidebar();
|
||||
const currentPath = $derived(path || page.url.pathname);
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<OrgLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
{#if isCohortReady}
|
||||
<Navigation path={currentPath} {id} />
|
||||
{:else}
|
||||
<Sidebar.Group class="pt-0!">
|
||||
<div class="flex h-8 items-center gap-2 rounded-md px-2">
|
||||
<Skeleton class="ui:size-4 ui:rounded-md" />
|
||||
<Skeleton class="ui:h-4 ui:w-24" />
|
||||
</div>
|
||||
<Sidebar.Menu>
|
||||
{#each Array(SIDEBAR_ITEM_SKELETON_COUNT) as _, i (i)}
|
||||
<Sidebar.MenuItem>
|
||||
<div class="ui:flex ui:h-8 ui:items-center ui:gap-2 ui:rounded-md ui:px-2" data-sidebar="menu-skeleton">
|
||||
<Skeleton class="ui:size-4 ui:rounded-md" />
|
||||
<Skeleton class="ui:h-4 ui:max-w-32 ui:flex-1" />
|
||||
</div>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Rail />
|
||||
|
||||
<Sidebar.Footer>
|
||||
<PoweredBy
|
||||
variant="sidebar"
|
||||
sidebarUtmSource="lms-cohort-sidebar"
|
||||
showOnlyLogo={!sidebar.open || sidebar.isMobile}
|
||||
/>
|
||||
</Sidebar.Footer>
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CohortSidebar } from './cohort-sidebar.svelte';
|
||||
Reference in New Issue
Block a user