feat: setup dashboard analytics api (#604)
* feat: setup dashboard analytics api * chore: requested pr fixes * fix: dash analytics server side type * refactor: update Avatar component usage across the dashboard - Replaced custom Avatar component with the new @cio/ui/base/avatar implementation for consistency. - Enhanced avatar rendering with fallback options and improved accessibility. - Cleaned up unused Avatar component files and related functions. - Updated various components to utilize the new Avatar structure, ensuring a uniform appearance and behavior across the application. * chore: remove console.log statements from analytics routes and API calls - Eliminated console.log statements for orgId and siteName in the dash analytics router and DashStatApi class to clean up the code and improve performance. --------- Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
co-authored by
Rotimi Best
parent
c7854efae3
commit
9a5aee1ee0
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
@@ -15,7 +15,13 @@
|
||||
|
||||
<div class="rounded-md border p-5 dark:border-neutral-600">
|
||||
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
|
||||
<Avatar src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" />
|
||||
<Avatar.Root class="h-16 w-16">
|
||||
<Avatar.Image
|
||||
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
|
||||
alt={user.fullName ? user.fullName : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col space-y-2">
|
||||
<p class="text-center text-2xl md:text-left dark:text-white">
|
||||
{user.fullName}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
name?: string;
|
||||
src?: string;
|
||||
className?: string;
|
||||
shape?: string;
|
||||
width?: string;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
name = '',
|
||||
src = '',
|
||||
className = '',
|
||||
shape = 'rounded-full',
|
||||
width = 'w-10',
|
||||
height = 'h-10'
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<img alt={name} {src} class="{className} {width} {height} {shape}" />
|
||||
@@ -8,8 +8,9 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import EmptyState from './EmptyState.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import type { StudentOverview } from '$lib/utils/types/analytics';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
students?: StudentOverview[];
|
||||
@@ -97,7 +98,13 @@
|
||||
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar src={student.profile.avatar_url} name={student.profile.fullname} width="w-8" height="h-8" />
|
||||
<Avatar.Root class="size-8">
|
||||
<Avatar.Image
|
||||
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
|
||||
alt={student.profile.fullname ? student.profile.fullname : 'Student'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||
{student.profile.fullname}
|
||||
|
||||
+18
-5
@@ -5,8 +5,9 @@
|
||||
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
|
||||
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
@@ -62,7 +63,7 @@
|
||||
id: 0,
|
||||
comment: comment,
|
||||
name: $t('course.navItem.lessons.comments.you'),
|
||||
avatar: $profile.avatar_url,
|
||||
avatar: $profile.avatarUrl,
|
||||
commentAt: new Date(),
|
||||
groupmember_id: groupmember.id
|
||||
},
|
||||
@@ -270,7 +271,13 @@
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex h-full items-start gap-3">
|
||||
<Avatar src={$profile.avatar_url} name={$profile.fullname} width="w-8" height="h-8" className="mt-2" />
|
||||
<Avatar.Root class="mt-2 h-8 w-8">
|
||||
<Avatar.Image
|
||||
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
|
||||
alt={$profile.fullname ? $profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="h-full w-full">
|
||||
<TextArea
|
||||
label={$t('course.navItem.lessons.comments.text_area_title')}
|
||||
@@ -293,7 +300,13 @@
|
||||
<div class="my-10">
|
||||
{#each comments as commentItem}
|
||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
||||
<Avatar src={commentItem.avatar} name={commentItem.name} width="w-8" height="h-8" />
|
||||
<Avatar.Root class="h-8 w-8">
|
||||
<Avatar.Image
|
||||
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
|
||||
alt={commentItem.name ? commentItem.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="w-full rounded-md border px-4 pb-4 pt-2 dark:border-neutral-700">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
@@ -349,7 +362,7 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<article class="prose max-w-[300px] sm:prose-sm dark:text-white">
|
||||
<article class="prose sm:prose-sm max-w-[300px] dark:text-white">
|
||||
{commentItem.comment}
|
||||
</article>
|
||||
{/if}
|
||||
|
||||
+9
-2
@@ -7,8 +7,9 @@
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import ReviewFormEditor from './ReviewFormEditor.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { processErrors } from '$lib/utils/functions/validator';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
|
||||
@@ -98,7 +99,13 @@
|
||||
{#if review.id !== reviewToExpand}
|
||||
<!-- the headers -->
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<Avatar src={review.avatar_url} name={review.name} className="mt-1" />
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Reviewer'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="text-sm">{review.name}</p>
|
||||
|
||||
<IconButton value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}>
|
||||
|
||||
@@ -21,9 +21,10 @@
|
||||
import Chip from '../Chip/index.svelte';
|
||||
import Modal from '../Modal/index.svelte';
|
||||
import ImageRenderer from '../Org/ImageRenderer.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import PricingSection from './components/PricingSection.svelte';
|
||||
import { PoweredBy } from '$lib/features/ui';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import SectionsDisplay from './components/SectionsDisplay.svelte';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
@@ -332,7 +333,13 @@
|
||||
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar src={review.avatar_url} name="Avatar" className="mt-1" />
|
||||
<Avatar.Root class="mt-1 size-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
|
||||
<!-- profile content -->
|
||||
@@ -401,7 +408,13 @@
|
||||
<div class="item-start my-2 flex w-full flex-row">
|
||||
<!-- image container -->
|
||||
{#if review.avatar_url}
|
||||
<Avatar src={review.avatar_url} name={review.name} className="mt-1" />
|
||||
<Avatar.Root class="mt-1 h-10 w-10">
|
||||
<Avatar.Image
|
||||
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
|
||||
alt={review.name ? review.name : 'Avatar'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{/if}
|
||||
|
||||
<!-- profile content -->
|
||||
|
||||
@@ -1,39 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
interface Props {
|
||||
navClass?: string;
|
||||
}
|
||||
|
||||
let { navClass = '' }: Props = $props();
|
||||
|
||||
function shortenName(name: string) {
|
||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
|
||||
<ul class="flex w-full items-center">
|
||||
<div class="">
|
||||
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
|
||||
{#if $currentOrg.avatar_url}
|
||||
<Avatar
|
||||
src={$currentOrg.avatar_url}
|
||||
name={$currentOrg.name}
|
||||
shape="rounded-md"
|
||||
width="w-7"
|
||||
height="h-7"
|
||||
className="mr-2"
|
||||
<Avatar.Root class="mr-2 h-7 w-7 rounded-md">
|
||||
<Avatar.Image
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
alt={$currentOrg.name ? $currentOrg.name : 'Organization'}
|
||||
/>
|
||||
{:else}
|
||||
<TextChip value={shortenName($currentOrg.name)} className="bg-primary-200 mr-2 dark:text-black" />
|
||||
{/if}
|
||||
<Avatar.Fallback>{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span class="line-clamp-1">
|
||||
{$currentOrg.name}
|
||||
</span>
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
|
||||
$effect(() => {
|
||||
orgApi.getOrgAudience($currentOrg.id);
|
||||
@@ -81,7 +82,13 @@
|
||||
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
|
||||
class="text-primary-700 flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
|
||||
<Avatar.Root class="h-5 w-5">
|
||||
<Avatar.Image
|
||||
src={row.avatar_url ? row.avatar_url : '/logo-192.png'}
|
||||
alt={row.name ? row.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
{row.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type { DashStatsSuccess } from '$lib/features/org/utils/types';
|
||||
|
||||
/**
|
||||
* API class for organization dashboard analytics
|
||||
*/
|
||||
class DashStatApi extends BaseApi {
|
||||
stats = $state<DashStatsSuccess['data']>({
|
||||
enrollments: [],
|
||||
numberOfCourses: 0,
|
||||
revenue: 0,
|
||||
totalStudents: 0,
|
||||
topCourses: []
|
||||
});
|
||||
|
||||
/**
|
||||
* Fetches dashboard statistics for an organization
|
||||
* @param orgId Optional organization ID
|
||||
* @param siteName Optional site name
|
||||
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
|
||||
*/
|
||||
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
|
||||
await this.execute<typeof classroomio.dash.stats.$post>({
|
||||
requestFn: () => classroomio.dash.stats.$post({ json: { orgId, siteName } }),
|
||||
logContext: 'fetching organization stats',
|
||||
onSuccess: (response) => {
|
||||
this.stats = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const dashStatApi = new DashStatApi();
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './org.svelte';
|
||||
export * from './org-plan.svelte';
|
||||
export * from './dash-stats.svelte';
|
||||
|
||||
@@ -33,6 +33,11 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
|
||||
|
||||
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
||||
|
||||
// dashboard analytics types
|
||||
export type GetDashStatsResponse = typeof classroomio.dash.stats.$post;
|
||||
export type DashStatsResponse = InferResponseType<GetDashStatsResponse> | null;
|
||||
export type DashStatsSuccess = Extract<InferResponseType<GetDashStatsResponse>, { success: true }>;
|
||||
|
||||
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
|
||||
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
|
||||
export type InviteTeamData = InviteTeamSuccess['data'];
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</svg>
|
||||
<!-- Center text -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class="{size === 'small' ? 'text-[10px]' : 'text-sm'} font-bold">{progressPercentage}%</p>
|
||||
<p class={size === 'small' ? 'text-[10px]' : 'text-sm'}>{progressPercentage}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
|
||||
@@ -68,14 +68,14 @@
|
||||
<Card.Root class="ui:w-full max-w-[400px]">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
||||
<Avatar
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
shape="rounded-md"
|
||||
width="w-10"
|
||||
height="max-h-10"
|
||||
className="mr-2"
|
||||
/>
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
/>
|
||||
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<a href="/">
|
||||
<Card.Title class="ui:text-2xl ui:font-bold">
|
||||
{isLogin ? $t('login.welcome') : $t('login.create_account')}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { getShortOrgName } from '$lib/utils/functions/org';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
@@ -44,7 +44,7 @@
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
|
||||
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
|
||||
@@ -66,7 +66,7 @@
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
@@ -100,7 +100,7 @@
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
@@ -132,7 +132,7 @@
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
|
||||
@@ -23,7 +23,3 @@ export function openUpgradeModal() {
|
||||
|
||||
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
|
||||
}
|
||||
|
||||
export function getShortOrgName(name: string) {
|
||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
export function capitalizeFirstLetter(val: string): string {
|
||||
return val.charAt(0).toUpperCase() + val.slice(1);
|
||||
}
|
||||
|
||||
export function shortenName(name: string | null | undefined): string {
|
||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
||||
}
|
||||
|
||||
@@ -119,26 +119,6 @@ export interface UserAnalytics {
|
||||
overallAverageGrade: number;
|
||||
}
|
||||
|
||||
export interface OrganisationAnalytics {
|
||||
revenue: number;
|
||||
numberOfCourses: number;
|
||||
totalStudents: number;
|
||||
topCourses: {
|
||||
id: string;
|
||||
title: string;
|
||||
enrollments: number;
|
||||
completion: number;
|
||||
}[];
|
||||
enrollments: {
|
||||
id: string;
|
||||
avatarUrl: string;
|
||||
name: string;
|
||||
courseId: string;
|
||||
course: string;
|
||||
date: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface UserCourseAnalytics {
|
||||
user: User;
|
||||
averageGrade: number;
|
||||
|
||||
Reference in New Issue
Block a user