feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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];
|
||||
Reference in New Issue
Block a user