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
@@ -0,0 +1,32 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { ComplianceOverviewData, ComplianceOverviewRequest } from '../utils/types';
class ComplianceApi extends BaseApi {
overview = $state<ComplianceOverviewData | null>(null);
loading = $state(false);
lastFetchedOrgId = $state<string | null>(null);
async fetchOverview(orgId: string) {
if (!orgId) return;
this.loading = true;
this.lastFetchedOrgId = orgId;
await this.execute<ComplianceOverviewRequest>({
requestFn: () => classroomio.dash['compliance-overview'].$get({ query: { orgId } }),
logContext: 'fetching org compliance overview',
onSuccess: (response) => {
this.overview = response.data;
}
});
this.loading = false;
}
ensureFetched(orgId: string) {
if (this.lastFetchedOrgId === orgId) return;
this.fetchOverview(orgId);
}
}
export const complianceApi = new ComplianceApi();
@@ -0,0 +1,60 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import BookIcon from '@lucide/svelte/icons/book';
import { t } from '$lib/utils/functions/translations';
import type { ComplianceCourseRow } from '../utils/types';
interface Props {
rows: ComplianceCourseRow[];
}
let { rows }: Props = $props();
</script>
<Card.Root>
<Card.Header>
<Card.Title>{$t('compliance.courses.heading')}</Card.Title>
<Card.Description>{$t('compliance.courses.description')}</Card.Description>
</Card.Header>
<Card.Content>
{#if rows.length === 0}
<Empty icon={BookIcon} title={$t('compliance.courses.empty')} />
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('compliance.courses.course')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.courses.learners')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.compliant')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.expiring_soon')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.status.non_compliant')}</Table.Head>
<Table.Head class="text-right">{$t('compliance.courses.completion')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each rows as row (row.courseId)}
{@const complete = row.counts.compliant}
{@const percent = row.learnerCount > 0 ? Math.round((complete / row.learnerCount) * 100) : 0}
<Table.Row>
<Table.Cell class="font-medium">{row.courseTitle}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.learnerCount}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.compliant}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.expiring_soon}</Table.Cell>
<Table.Cell class="text-right tabular-nums">{row.counts.non_compliant}</Table.Cell>
<Table.Cell class="text-right">
<div class="flex items-center justify-end gap-2">
<div class="ui:bg-muted h-1.5 w-20 overflow-hidden rounded-full">
<div class="ui:bg-primary h-full" style="width: {percent}%"></div>
</div>
<span class="ui:text-muted-foreground w-9 text-right text-xs tabular-nums">{percent}%</span>
</div>
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,3 @@
export { default as StatusTiles } from './status-tiles.svelte';
export { default as CourseBreakdown } from './course-breakdown.svelte';
export { default as LearnersTable } from './learners-table.svelte';
@@ -0,0 +1,88 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import * as Table from '@cio/ui/base/table';
import { Badge } from '@cio/ui/base/badge';
import { Empty } from '@cio/ui/custom/empty';
import { UserAvatar } from '@cio/ui/custom/user-avatar';
import UsersIcon from '@lucide/svelte/icons/users';
import { t } from '$lib/utils/functions/translations';
import type { ComplianceLearnerRow } from '../utils/types';
interface Props {
rows: ComplianceLearnerRow[];
statusFilter?: ComplianceLearnerRow['status'] | 'all';
}
let { rows, statusFilter = 'all' }: Props = $props();
const filtered = $derived(statusFilter === 'all' ? rows : rows.filter((row) => row.status === statusFilter));
const statusTone: Record<ComplianceLearnerRow['status'], string> = {
compliant: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-300',
expiring_soon: 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
in_grace_period: 'bg-amber-500/10 text-amber-700 dark:text-amber-300',
non_compliant: 'bg-rose-500/10 text-rose-700 dark:text-rose-300',
waived: 'ui:bg-muted ui:text-muted-foreground',
in_progress: 'ui:bg-primary/10 ui:text-primary',
not_started: 'ui:bg-muted ui:text-muted-foreground',
no_record: 'ui:bg-muted ui:text-muted-foreground'
};
function statusLabel(status: ComplianceLearnerRow['status']) {
return $t(`compliance.status.${status}`);
}
function formatDate(iso: string | null) {
if (!iso) return '—';
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '—';
return date.toLocaleDateString();
}
</script>
<Card.Root>
<Card.Header>
<Card.Title>{$t('compliance.learners.heading')}</Card.Title>
<Card.Description>
{$t('compliance.learners.subtitle', { count: filtered.length })}
</Card.Description>
</Card.Header>
<Card.Content>
{#if filtered.length === 0}
<Empty icon={UsersIcon} title={$t('compliance.learners.empty')} />
{:else}
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head>{$t('compliance.learners.learner')}</Table.Head>
<Table.Head>{$t('compliance.learners.course')}</Table.Head>
<Table.Head>{$t('compliance.learners.status')}</Table.Head>
<Table.Head>{$t('compliance.learners.due_date')}</Table.Head>
<Table.Head>{$t('compliance.learners.expires')}</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each filtered as row (row.groupMemberId + ':' + row.courseId)}
<Table.Row>
<Table.Cell>
<div class="flex items-center gap-2">
<UserAvatar src={row.avatarUrl} alt={row.fullname ?? row.email ?? 'Learner'} class="h-7 w-7" />
<div class="min-w-0">
<p class="ui:text-foreground truncate text-sm font-medium">{row.fullname ?? '—'}</p>
<p class="ui:text-muted-foreground truncate text-xs">{row.email ?? ''}</p>
</div>
</div>
</Table.Cell>
<Table.Cell class="text-sm">{row.courseTitle}</Table.Cell>
<Table.Cell>
<Badge variant="outline" class={statusTone[row.status]}>{statusLabel(row.status)}</Badge>
</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">{formatDate(row.dueDate)}</Table.Cell>
<Table.Cell class="ui:text-muted-foreground text-sm">{formatDate(row.validUntil)}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,70 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { cn } from '@cio/ui/tools';
import { t } from '$lib/utils/functions/translations';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import AlertTriangleIcon from '@lucide/svelte/icons/alert-triangle';
import ClockIcon from '@lucide/svelte/icons/clock';
import ShieldOffIcon from '@lucide/svelte/icons/shield-off';
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash';
import Footprints from '@lucide/svelte/icons/footprints';
import CircleDashedIcon from '@lucide/svelte/icons/circle-dashed';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import type { Component } from 'svelte';
import type { ComplianceOverviewData } from '../utils/types';
interface Props {
data: ComplianceOverviewData | null;
}
let { data }: Props = $props();
type Tile = {
key: keyof ComplianceOverviewData['summary']['counts'];
labelKey: string;
icon: Component;
tone: 'success' | 'warning' | 'danger' | 'info' | 'muted';
};
/** Order: healthy → risk → pipeline → administrative, so the grid reads left-to-top then downward. */
const tiles: Tile[] = [
{ key: 'compliant', labelKey: 'compliance.status.compliant', icon: ShieldCheckIcon, tone: 'success' },
{ key: 'non_compliant', labelKey: 'compliance.status.non_compliant', icon: ShieldOffIcon, tone: 'danger' },
{ key: 'expiring_soon', labelKey: 'compliance.status.expiring_soon', icon: AlertTriangleIcon, tone: 'warning' },
{ key: 'in_grace_period', labelKey: 'compliance.status.in_grace_period', icon: ClockIcon, tone: 'warning' },
{ key: 'in_progress', labelKey: 'compliance.status.in_progress', icon: Footprints, tone: 'info' },
{ key: 'not_started', labelKey: 'compliance.status.not_started', icon: CircleDashedIcon, tone: 'muted' },
{ key: 'waived', labelKey: 'compliance.status.waived', icon: CircleSlashIcon, tone: 'muted' },
{ key: 'no_record', labelKey: 'compliance.status.no_record', icon: HelpCircleIcon, tone: 'muted' }
];
const toneClass: Record<Tile['tone'], string> = {
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
danger: 'bg-rose-500/10 text-rose-600 dark:text-rose-400',
info: 'ui:bg-primary/10 ui:text-primary',
muted: 'ui:bg-muted ui:text-muted-foreground'
};
const counts = $derived(data?.summary.counts ?? null);
</script>
<Card.Root class="ui:bg-card">
<Card.Header class="pb-2">
<Card.Title class="text-base font-semibold">{$t('compliance.summary.heading')}</Card.Title>
<Card.Description>{$t('compliance.summary.description')}</Card.Description>
</Card.Header>
<Card.Content class="pt-0">
<div class="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3 lg:grid-cols-4">
{#each tiles as tile (tile.key)}
<div class="flex min-w-0 flex-col gap-2">
<div class={cn('flex h-9 w-9 shrink-0 items-center justify-center rounded-full', toneClass[tile.tone])}>
<tile.icon class="h-4 w-4" />
</div>
<p class="ui:text-foreground text-2xl font-semibold tabular-nums">{counts?.[tile.key] ?? 0}</p>
<p class="ui:text-muted-foreground text-xs leading-snug">{$t(tile.labelKey)}</p>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
@@ -0,0 +1,2 @@
export * from './components';
export { complianceApi } from './api/compliance.svelte';
@@ -0,0 +1,12 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ComplianceOverviewRequest = (typeof classroomio.dash)['compliance-overview']['$get'];
export type ComplianceOverviewSuccess = Extract<InferResponseType<ComplianceOverviewRequest>, { success: true }>;
export type ComplianceOverviewData = ComplianceOverviewSuccess['data'];
export type ComplianceStatus = ComplianceOverviewData['summary']['counts'] extends Record<infer K, number> ? K : never;
export type ComplianceCourseRow = ComplianceOverviewData['courses'][number];
export type ComplianceLearnerRow = ComplianceOverviewData['learners'][number];