feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -10,7 +10,16 @@
import { CourseCreator } from '@cio/ui/custom/course-creator';
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
import { courseApi } from '$features/course/api';
import { setInitialChatModel, setInitialChatPrompt } from '$features/ai-assistant/utils/store';
import {
setInitialChatModel,
setInitialChatPrompt,
setInitialChatTemplateId
} from '$features/ai-assistant/utils/store';
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
import { COURSE_TEMPLATES, type CourseTemplate, type CourseTemplateId } from '@cio/ai-assistant';
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
import CompassIcon from '@lucide/svelte/icons/compass';
import AwardIcon from '@lucide/svelte/icons/award';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
@@ -33,6 +42,9 @@
let creatingState: 'idle' | 'creating' = $state('idle');
let creatingStep: CreatingStep = $state('reading');
let draftingPrompt = $state('');
let composerPrompt = $state('');
let pinnedTemplatePrompt: string | null = $state(null);
let selectedTemplateId: CourseTemplateId | null = $state(null);
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
@@ -86,6 +98,23 @@
}
});
function selectTemplate(template: CourseTemplate) {
composerPrompt = template.promptTemplate;
pinnedTemplatePrompt = template.promptTemplate;
selectedTemplateId = template.id;
}
$effect(() => {
if (!selectedTemplateId || pinnedTemplatePrompt === null) {
return;
}
if (composerPrompt !== pinnedTemplatePrompt) {
selectedTemplateId = null;
pinnedTemplatePrompt = null;
}
});
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
if (isAgentModelId(model)) {
if ($isFreePlan && paidModelIds.includes(model)) {
@@ -103,16 +132,33 @@
await new Promise((r) => setTimeout(r, 700));
creatingStep = 'naming';
const meta = await aiAssistantApi.generateCourseMeta(prompt);
const title = meta?.title ?? prompt.slice(0, 80);
const description = meta?.description ?? prompt.slice(0, 150);
const template = selectedTemplateId ? COURSE_TEMPLATES.find((tpl) => tpl.id === selectedTemplateId) : undefined;
const promptIsStillTemplateDefault = template !== undefined && prompt.trim() === template.promptTemplate.trim();
let title: string;
let description: string;
if (promptIsStillTemplateDefault) {
title = t.get('course.creator.untitled_course');
description = t.get('course.creator.untitled_course_description');
} else {
const meta = await aiAssistantApi.generateCourseMeta(prompt);
title = meta?.title ?? prompt.slice(0, 80);
description = meta?.description ?? prompt.slice(0, 150);
}
creatingStep = 'building';
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
setInitialChatModel(selectedModel);
if (selectedTemplateId) {
setInitialChatTemplateId(selectedTemplateId);
}
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
goto(resolve(`/courses/${courseId}`, {}));
goto(resolve(`/courses/${courseId}/lessons`, {}));
});
}
@@ -177,11 +223,12 @@
</div>
</div>
{:else}
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
<div class="flex min-h-[90vh] items-center justify-center px-4 py-12">
<div class="w-full max-w-3xl">
<CourseCreator
heading={$t('course.creator.heading')}
placeholder={$t('course.creator.placeholder')}
bind:prompt={composerPrompt}
model={selectedModel}
levelOptions={[
{ value: 'beginner', label: $t('course.creator.level.beginner') },
@@ -193,6 +240,41 @@
onLockedModelSelect={handleLockedModelSelect}
onsubmit={handleCreate}
/>
<p class="ui:text-muted-foreground my-3 text-center text-sm font-medium">
{$t('course.creator.template.heading')}
</p>
<div class="grid gap-3 sm:grid-cols-3">
{#each COURSE_TEMPLATES as template (template.id)}
{@const display = DISPLAY_BY_ID[template.id]}
{@const isSelected = selectedTemplateId === template.id}
<button
type="button"
class="group ui:border ui:transition-colors focus-visible:ui:ring-[3px] focus-visible:ui:ring-ring/50 focus-visible:ui:outline-none flex h-full min-h-[168px] w-full cursor-pointer flex-col items-start gap-3 rounded-2xl p-5 text-left sm:p-6 {isSelected
? 'ui:bg-primary/5 ui:border-primary ui:ring-primary/20 ui:ring-2'
: 'ui:bg-card ui:border-border ui:hover:border-primary'}"
onclick={() => selectTemplate(template)}
aria-label={$t(display.titleKey)}
aria-pressed={isSelected}
>
<div class="ui:text-primary ui:bg-primary/10 flex size-10 shrink-0 items-center justify-center rounded-lg">
{#if display.iconName === 'GraduationCap'}
<GraduationCapIcon class="size-6" />
{:else if display.iconName === 'Compass'}
<CompassIcon class="size-6" />
{:else}
<AwardIcon class="size-6" />
{/if}
</div>
<span class="ui:text-foreground text-sm leading-tight font-semibold">
{$t(display.titleKey)}
</span>
<span class="ui:text-muted-foreground text-xs leading-snug">
{$t(display.descriptionKey)}
</span>
</button>
{/each}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import * as Page from '@cio/ui/base/page';
import * as Tabs from '@cio/ui/custom/underline-tabs';
import {
CountriesTable,
Funnel,
LandingKpis,
PopularTypes,
RangePicker,
RefreshButton,
TrendChart,
analyticsApi
} from '$features/analytics';
onMount(() => {
const orgId = $currentOrg.id;
if (orgId) analyticsApi.ensureFetched(orgId);
});
function handleRangeChange(days: 7 | 30 | 90) {
const orgId = $currentOrg.id;
if (!orgId) return;
analyticsApi.setRange(days, orgId);
}
function handleRefresh() {
const orgId = $currentOrg.id;
if (!orgId) return;
analyticsApi.refresh(orgId);
}
</script>
<svelte:head>
<title>{$t('analytics.title')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('analytics.title')}</Page.Title>
<p class="ui:text-muted-foreground text-sm">{$t('analytics.subtitle')}</p>
</Page.HeaderContent>
<Page.Action>
<RangePicker value={analyticsApi.range} onChange={handleRangeChange} />
<RefreshButton loading={analyticsApi.loading} onClick={handleRefresh} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="space-y-6">
<LandingKpis data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
<Tabs.Root value="overview">
<Tabs.List class="mb-6">
<Tabs.Trigger value="overview">{$t('analytics.tabs.overview')}</Tabs.Trigger>
<Tabs.Trigger value="funnel">{$t('analytics.tabs.funnel')}</Tabs.Trigger>
<Tabs.Trigger value="geography">{$t('analytics.tabs.geography')}</Tabs.Trigger>
<Tabs.Trigger value="course_types">{$t('analytics.tabs.course_types')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview" class="space-y-4">
<TrendChart data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
</div>
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
</Tabs.Content>
<Tabs.Content value="funnel">
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
</Tabs.Content>
<Tabs.Content value="geography">
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
</Tabs.Content>
<Tabs.Content value="course_types">
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
</Tabs.Content>
</Tabs.Root>
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,99 @@
<script lang="ts">
import { onMount } from 'svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import * as Page from '@cio/ui/base/page';
import * as Tabs from '@cio/ui/custom/underline-tabs';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
import { StatusTiles, CourseBreakdown, LearnersTable, complianceApi } from '$features/compliance';
import type { ComplianceLearnerRow } from '$features/compliance/utils/types';
onMount(() => {
const orgId = $currentOrg.id;
if (orgId) complianceApi.ensureFetched(orgId);
});
function handleRefresh() {
const orgId = $currentOrg.id;
if (!orgId) return;
complianceApi.fetchOverview(orgId);
}
type LearnerStatus = ComplianceLearnerRow['status'];
const statusFilters: Array<LearnerStatus | 'all'> = [
'all',
'compliant',
'expiring_soon',
'in_grace_period',
'non_compliant',
'in_progress',
'not_started',
'waived',
'no_record'
];
let activeFilter = $state<LearnerStatus | 'all'>('all');
</script>
<svelte:head>
<title>{$t('compliance.title')} - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('compliance.title')}</Page.Title>
<p class="ui:text-muted-foreground text-sm">{$t('compliance.subtitle')}</p>
</Page.HeaderContent>
<Page.Action>
<Button variant="outline" size="sm" disabled={complianceApi.loading} onclick={handleRefresh}>
<RefreshIcon class={complianceApi.loading ? 'animate-spin' : ''} />
{$t('analytics.refresh')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="space-y-6">
{#if complianceApi.loading && !complianceApi.overview}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else}
<StatusTiles data={complianceApi.overview} />
<Tabs.Root value="courses">
<Tabs.List class="mb-6">
<Tabs.Trigger value="courses">{$t('compliance.tabs.courses')}</Tabs.Trigger>
<Tabs.Trigger value="learners">{$t('compliance.tabs.learners')}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="courses" class="space-y-4">
<CourseBreakdown rows={complianceApi.overview?.courses ?? []} />
</Tabs.Content>
<Tabs.Content value="learners" class="space-y-4">
<div class="flex flex-wrap gap-2">
{#each statusFilters as filter (filter)}
<Button
variant={activeFilter === filter ? 'default' : 'outline'}
size="sm"
onclick={() => (activeFilter = filter)}
>
{$t(`compliance.filter.${filter}`)}
</Button>
{/each}
</div>
<LearnersTable rows={complianceApi.overview?.learners ?? []} statusFilter={activeFilter} />
</Tabs.Content>
</Tabs.Root>
{/if}
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -25,6 +25,14 @@
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
let selectedTags = $state<string[]>(getInitialSelectedTags());
let courseType = $state<string>('all');
const courseTypeOptions = $derived([
{ value: 'SELF_PACED', label: $t('new_course_modal.self_paced_label') },
{ value: 'LIVE_CLASS', label: $t('new_course_modal.live_class_label') },
{ value: 'COMPLIANCE', label: $t('new_course_modal.compliance_label') },
{ value: 'PUBLIC', label: $t('new_course_modal.public_label') }
]);
let hasInitializedFilters = $state(false);
let hasCompletedInitialUrlSync = false;
@@ -61,6 +69,12 @@
nextUrl.searchParams.delete('order');
}
if (courseType !== 'all') {
nextUrl.searchParams.set('type', courseType);
} else {
nextUrl.searchParams.delete('type');
}
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
window.history.replaceState(window.history.state, '', nextPath);
}
@@ -107,21 +121,30 @@
async function clearFilters() {
sortKey = DEFAULT_COURSE_SORT;
selectedOrder = DEFAULT_SORT_ORDER;
courseType = 'all';
if (selectedTags.length === 0) {
updateFiltersUrl();
return;
}
await applyTagFilters([]);
}
function setCourseType(nextType: string) {
courseType = nextType;
updateFiltersUrl();
}
const filteredCourses = $derived.by(() => {
const filteredCourses = (coursesApi.orgCourses ?? []).filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
const matchesSearch = !searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase());
return false;
if (!matchesSearch) return false;
if (courseType !== 'all' && course.type !== courseType) return false;
return true;
});
const sortedCourses = [...filteredCourses];
@@ -169,6 +192,11 @@
selectedOrder = orderFromStorage;
}
const typeFromUrl = searchParams.get('type');
if (typeFromUrl && courseTypeOptions.some((opt) => opt.value === typeFromUrl)) {
courseType = typeFromUrl;
}
hasInitializedFilters = true;
});
</script>
@@ -194,10 +222,13 @@
<CourseFilterPopover
bind:sortKey
bind:selectedOrder
bind:courseType
{courseTypeOptions}
{selectedTags}
tagGroups={data.tagGroups}
{isFiltering}
onToggleTag={toggleTag}
onCourseTypeChange={setCourseType}
onClearFilters={clearFilters}
/>
{/snippet}
@@ -8,13 +8,8 @@ const cache: Record<string, DashStatsSuccess['data'] | null> = {};
function loginActivityDataFromSettled(
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
): LoginActivityData {
if (result.status === 'rejected') {
return [];
}
if (!result.value.ok) {
return [];
}
if (result.status === 'rejected') return [];
if (!result.value.ok) return [];
return result.value.body.data;
}
@@ -0,0 +1,9 @@
<script lang="ts">
import { OrgAiTutorSettingsPage } from '$features/ai-tutor-settings';
</script>
<svelte:head>
<title>AI Tutor Settings - ClassroomIO</title>
</svelte:head>
<OrgAiTutorSettingsPage />
@@ -0,0 +1,13 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
</script>
<Page.Root class="w-full pb-10">
{@render children?.()}
</Page.Root>
@@ -2,21 +2,7 @@
import { LandingpagePage } from '$features/settings/pages';
import { VisitOrgSiteButton } from '$features/ui';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let landingPageComponent: LandingpagePage | null = $state(null);
let hasUnsavedChanges = $state(false);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await landingPageComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
@@ -30,14 +16,10 @@
</Page.HeaderContent>
<Page.Action>
<VisitOrgSiteButton labelKey="markdown_editor.preview" variant="secondary" />
<Button variant="default" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
{$t('settings.landing_page.save_changes')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<LandingpagePage bind:this={landingPageComponent} bind:hasUnsavedChanges />
<LandingpagePage />
{/snippet}
</Page.Body>
@@ -1,26 +1,67 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { onMount } from 'svelte';
import { onMount, type Component } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import type { AccountOrg } from '$features/app/types';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { orgApi } from '$features/org/api/org.svelte';
import { landingPageSettings } from '$features/settings/utils/store';
import { landingPageSettings, landingPageEditorSelection } from '$features/settings/utils/store';
import { buildOrgLandingPageProps, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
import { landingPageThemes } from '$features/org/utils/landing-page';
import LandingpageEditor from '$features/settings/pages/landingpage-editor.svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
import { setLandingPageEditContext, type LandingSectionKey } from '@cio/ui/custom/org-landing-page';
import {
ContentIcon,
ExploreIcon,
ExternalLinkIcon,
GoalIcon,
HeaderIcon,
SettingsIcon
} from '@cio/ui/custom/moving-icons';
import { basePath } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
const sectionIcons: Record<LandingSectionKey, Component> = {
navigation: ContentIcon,
hero: HeaderIcon,
links: ExploreIcon,
embed: ExternalLinkIcon,
callout: GoalIcon,
footer: SettingsIcon
};
// Reset selection on every mount so the editor opens to the section list.
landingPageEditorSelection.set(null);
// Mirror the store into a $state so the preview's wrappers ($derived) react
// to selection changes. Reading store via get() inside an arrow is non-reactive.
let selectedSectionKey = $state<LandingSectionKey | null>(null);
$effect(() => landingPageEditorSelection.subscribe((value) => (selectedSectionKey = value)));
setLandingPageEditContext({
selectedKey: () => selectedSectionKey,
selectKey: (key) => landingPageEditorSelection.set(key),
labelFor: (key) => t.get(`settings.landing_page.editor.sections.${key}`),
iconFor: (key) => sectionIcons[key]
});
function handlePreviewClick(event: MouseEvent) {
if (!(event.target instanceof Element)) return;
if (event.target.closest('[data-landing-section]')) return;
landingPageEditorSelection.set(null);
}
let sidebarOpen = $state(true);
let hasInitialized = $state(false);
function isLandingPageTheme(value: string | null): value is 'minimal' | 'bold' | 'classic' {
return value === 'minimal' || value === 'bold' || value === 'classic';
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
if (value === null) return false;
return (landingPageThemes as readonly string[]).includes(value);
}
const savedLandingPageSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
@@ -94,14 +135,14 @@
in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }}
>
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 360px; --sidebar-width-icon: 4rem">
<Sidebar.Root side="left" collapsible="icon" class="h-full">
<LandingpageEditor
bind:settings={$landingPageSettings}
onSave={async () => {
const updatedLandingPage = normalizeLandingPageSettings({
...$landingPageSettings,
theme: page.url.searchParams.get('theme') ? savedLandingPageSettings.theme : $landingPageSettings.theme
theme: previewTheme
});
await orgApi.update($currentOrg.id, {
@@ -114,6 +155,7 @@
<Sidebar.Inset
class="relative h-screen! w-[calc(100vw-var(--sidebar-width))] overflow-y-auto group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
onclick={handlePreviewClick}
>
<div class="absolute top-2 left-2 z-60">
<Sidebar.Trigger variant="secondary" />
@@ -0,0 +1,22 @@
<script lang="ts">
import * as Page from '@cio/ui/base/page';
import { WorkspacesPage } from '$features/account';
import { t } from '$lib/utils/functions/translations';
</script>
<svelte:head>
<title>{$t('account.workspaces.page_title')} - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('account.workspaces.page_title')}</Page.Title>
<Page.Subtitle>{$t('account.workspaces.page_description')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<WorkspacesPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,147 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import * as Page from '@cio/ui/base/page';
import * as Table from '@cio/ui/base/table';
import { Badge } from '@cio/ui/base/badge';
import { Empty } from '@cio/ui/custom/empty';
import { GoalIcon } from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
import { programGoalApi } from '$features/program/api';
import type { GoalsOverviewRow } from '$features/program/utils/types';
onMount(() => {
programGoalApi.listOverview();
});
const overviewRows = $derived(programGoalApi.overviewRows);
const programs = $derived.by(() => {
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
for (const row of overviewRows) {
const bucket = map.get(row.programId) ?? {
programId: row.programId,
programName: row.programName,
goals: []
};
bucket.goals.push(row);
map.set(row.programId, bucket);
}
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
});
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
if (rows.length === 0) return 0;
const total = rows.reduce((sum, row) => sum + row.totalLearners, 0);
if (total === 0) return 0;
const onTrack = rows.reduce((sum, row) => sum + (row.totalLearners - row.atRiskCount - row.overdueCount), 0);
return Math.round((onTrack / total) * 100);
}
function aggregateCount(rows: GoalsOverviewRow[], field: keyof GoalsOverviewRow): number {
return rows.reduce((sum, row) => sum + Number(row[field] ?? 0), 0);
}
</script>
<svelte:head>
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
{#if !programGoalApi.isLoading && overviewRows.length === 0}
<Empty
title={$t('programs.goals.empty_title')}
description={$t('programs.goals.empty_description')}
icon={GoalIcon}
variant="page"
/>
{:else}
<div class="flex w-full flex-col gap-6 p-4">
{#each programs as program (program.programId)}
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
<section class="flex flex-col gap-3 rounded-md border p-4">
<header class="flex flex-wrap items-baseline justify-between gap-3">
<a
href={resolve(`/programs/${program.programId}/settings`, {})}
class="text-base font-semibold hover:underline"
>
{program.programName}
</a>
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="ui:text-muted-foreground">
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
{$t('programs.goals.tile.on_track')}
</span>
<span class="ui:text-muted-foreground">
<span class="font-medium">{totalLearners}</span> learners
</span>
{#if atRiskLearners > 0}
<span class="text-amber-700">
<span class="font-medium">{atRiskLearners}</span>
{$t('programs.goals.tile.at_risk_short')}
</span>
{/if}
{#if overdueLearners > 0}
<span class="text-red-600">
<span class="font-medium">{overdueLearners}</span>
{$t('programs.goals.tile.overdue_short')}
</span>
{/if}
</div>
</header>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>Goal</Table.Head>
<Table.Head>Type</Table.Head>
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each program.goals as goal (goal.goalId)}
<Table.Row>
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
<Table.Cell>
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
</Table.Cell>
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
<Table.Cell>{goal.inProgressCount}</Table.Cell>
<Table.Cell class={goal.atRiskCount > 0 ? 'text-amber-700' : ''}>
{goal.atRiskCount}
</Table.Cell>
<Table.Cell class={goal.overdueCount > 0 ? 'text-red-600' : ''}>
{goal.overdueCount}
</Table.Cell>
<Table.Cell>{goal.onTrackPct}%</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
</section>
{/each}
</div>
{/if}
{/snippet}
</Page.Body>
</Page.Root>