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 <[email protected]>
This commit is contained in:
Adeyemi Adetunji
2025-12-02 15:27:41 +01:00
committed by GitHub
co-authored by Rotimi Best
parent c7854efae3
commit 9a5aee1ee0
40 changed files with 573 additions and 276 deletions
+111 -97
View File
@@ -74,8 +74,8 @@ Types are automatically generated from your Drizzle schema using Drizzle's built
import * as schema from './schema';
// For each table, two types are generated:
export type TUser = typeof schema.user.$inferSelect; // For reading
export type TNewUser = typeof schema.user.$inferInsert; // For creating
export type TUser = typeof schema.user.$inferSelect; // For reading
export type TNewUser = typeof schema.user.$inferInsert; // For creating
export type TOrganization = typeof schema.organization.$inferSelect;
export type TNewOrganization = typeof schema.organization.$inferInsert;
@@ -130,11 +130,8 @@ import type { TNewOrganization, TOrganization } from '@db/types';
* @returns Created organization record
*/
export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db
.insert(schema.organization)
.values(data)
.returning();
const [organization] = await db.insert(schema.organization).values(data).returning();
return organization;
};
@@ -150,7 +147,7 @@ export const updateOrganization = async (id: string, data: TOrganization) => {
.set(data)
.where(eq(schema.organization.id, id))
.returning();
return organization;
};
```
@@ -242,11 +239,7 @@ export async function createOrganizationWithOwner(
// Business Logic: Check sitename availability
const exists = await checkSiteNameExists(input.siteName);
if (exists) {
throw new AppError(
`Site name '${input.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409
);
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
// Business Logic: Create org and member in a transaction
@@ -278,11 +271,7 @@ export async function createOrganizationWithOwner(
} catch (error) {
// Handle database constraint violations
if (error.code === '23505') {
throw new AppError(
`Site name '${input.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409
);
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
}
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
}
@@ -302,7 +291,7 @@ export class AppError extends Error {
message: string,
public code: string,
public statusCode: ContentfulStatusCode = 500,
public field?: string // Optional: for field-specific validation errors
public field?: string // Optional: for field-specific validation errors
) {
super(message);
this.name = 'AppError';
@@ -312,7 +301,7 @@ export class AppError extends Error {
export const ErrorCodes = {
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND'
// ... add more as needed
} as const;
```
@@ -334,19 +323,25 @@ export const handleError = (
console.error('Error in route:', error);
if (error instanceof AppError) {
return c.json({
success: false,
error: error.message,
code: error.code,
field: error.field
}, error.statusCode);
return c.json(
{
success: false,
error: error.message,
code: error.code,
field: error.field
},
error.statusCode
);
}
return c.json({
success: false,
error: fallbackMessage,
code: fallbackCode
}, 500);
return c.json(
{
success: false,
error: fallbackMessage,
code: fallbackCode
},
500
);
};
```
@@ -466,20 +461,20 @@ All API responses should follow a consistent structure:
Use semantically correct HTTP status codes:
| Code | Meaning | Usage |
|------|---------|-------|
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
| `201 Created` | Created | Successful POST creating a new resource |
| `204 No Content` | Success | Successful DELETE or update with no response body |
| `400 Bad Request` | Client Error | Malformed request, validation errors |
| `401 Unauthorized` | Auth Error | Missing or invalid authentication token |
| `403 Forbidden` | Auth Error | Authenticated but not authorized for this action |
| `404 Not Found` | Client Error | Resource doesn't exist |
| `409 Conflict` | Client Error | Duplicate resource (e.g., email/sitename already exists) |
| `422 Unprocessable Entity` | Client Error | Valid syntax but semantic validation failed |
| `429 Too Many Requests` | Client Error | Rate limit exceeded |
| `500 Internal Server Error` | Server Error | Unexpected server error |
| `503 Service Unavailable` | Server Error | Temporary service outage |
| Code | Meaning | Usage |
| --------------------------- | ------------ | -------------------------------------------------------- |
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
| `201 Created` | Created | Successful POST creating a new resource |
| `204 No Content` | Success | Successful DELETE or update with no response body |
| `400 Bad Request` | Client Error | Malformed request, validation errors |
| `401 Unauthorized` | Auth Error | Missing or invalid authentication token |
| `403 Forbidden` | Auth Error | Authenticated but not authorized for this action |
| `404 Not Found` | Client Error | Resource doesn't exist |
| `409 Conflict` | Client Error | Duplicate resource (e.g., email/sitename already exists) |
| `422 Unprocessable Entity` | Client Error | Valid syntax but semantic validation failed |
| `429 Too Many Requests` | Client Error | Rate limit exceeded |
| `500 Internal Server Error` | Server Error | Unexpected server error |
| `503 Service Unavailable` | Server Error | Temporary service outage |
#### 3. **Input Validation with Zod**
@@ -533,8 +528,8 @@ import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES } from '../constants';
export const ZCourseClone = z.object({
id: z.string().min(1), // No hardcoded message
title: z.string().min(1), // No hardcoded message
id: z.string().min(1), // No hardcoded message
title: z.string().min(1), // No hardcoded message
description: z.string().optional(),
slug: z.string().min(1),
organizationId: z.string().min(1)
@@ -552,11 +547,10 @@ export * from './course';
import { ZCourseClone } from '@cio/utils/validation/course';
import { zValidator } from '@hono/zod-validator';
export const cloneRouter = new Hono()
.post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
const data = c.req.valid('json'); // Fully typed!
// ... route handler
});
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
const data = c.req.valid('json'); // Fully typed!
// ... route handler
});
```
**Using in Frontend Forms**:
@@ -599,11 +593,11 @@ This allows different messages for shared field names (e.g., "Course title is re
"title": { "too_small": "Lesson title is required" },
"duration": { "too_small": "Lesson duration must be at least 1 minute" }
},
// Generic field translations (fallback for fields used across entities)
"title": { "too_small": "Title is required" },
"email": { "invalid_email": "Please enter a valid email address" },
// Generic error type translations (last resort)
"generic": {
"required": "This field is required",
@@ -618,11 +612,11 @@ This allows different messages for shared field names (e.g., "Course title is re
```typescript
// With entity name (entity-specific messages)
mapZodErrorsToTranslations(error, 'course') // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson') // "Lesson title is required"
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson'); // "Lesson title is required"
// Without entity name (generic messages)
mapZodErrorsToTranslations(error) // "Title is required"
mapZodErrorsToTranslations(error); // "Title is required"
```
**Key Principles**:
@@ -703,7 +697,7 @@ Skip entity names when:
"too_small": "Lesson must be at least 1 minute long"
}
},
// 2. Generic field-level (fallback for common fields)
"title": {
"too_small": "Title is required"
@@ -714,7 +708,7 @@ Skip entity names when:
"password": {
"too_small": "Password must be at least 8 characters"
},
// 3. Generic error types (last resort fallback)
"generic": {
"required": "This field is required",
@@ -776,7 +770,7 @@ if (!result.success) {
✅ **Maintainable**: Clear fallback hierarchy reduces duplication
✅ **Developer-friendly**: Works with or without entity name parameter
✅ **Multi-language**: Same system works across all locales
✅ **Professional UX**: Context-specific messages improve user experience
✅ **Professional UX**: Context-specific messages improve user experience
---
@@ -843,8 +837,11 @@ import { createOrganizationWithOwner } from '@api/services/organization';
import { handleError } from '@api/utils/errors';
import { ZCreateOrg } from '@cio/utils/validation/organization';
export const organizationRouter = new Hono()
.post('/create', authMiddleware, zValidator('json', ZCreateOrg), async (c) => {
export const organizationRouter = new Hono().post(
'/create',
authMiddleware,
zValidator('json', ZCreateOrg),
async (c) => {
try {
const user = c.get('user');
const body = c.req.valid('json');
@@ -855,7 +852,8 @@ export const organizationRouter = new Hono()
} catch (error) {
return handleError(c, error, 'Failed to create organization');
}
});
}
);
```
**Key Points:**
@@ -942,12 +940,12 @@ export async function updateUser(userId: string, data: Partial<TProfile>) {
return updatedProfile;
} catch (error) {
if (error instanceof AppError) throw error;
// Handle constraint violations
if (error instanceof Error && error.message.includes('profile_username_key')) {
throw new AppError('Username already exists', ErrorCodes.VALIDATION_ERROR, 400, 'username');
}
throw new AppError('Failed to update profile', ErrorCodes.PROFILE_UPDATE_FAILED, 500);
}
}
@@ -967,18 +965,17 @@ import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono()
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
try {
const user = c.get('user');
const validatedData = c.req.valid('json');
const updatedProfile = await updateUser(user.id, validatedData);
export const accountRouter = new Hono().put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
try {
const user = c.get('user');
const validatedData = c.req.valid('json');
const updatedProfile = await updateUser(user.id, validatedData);
return c.json({ success: true, profile: updatedProfile }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
return c.json({ success: true, profile: updatedProfile }, 200);
} catch (error) {
return handleError(c, error, 'Failed to update profile');
}
});
```
---
@@ -992,13 +989,20 @@ export * from './account';
// apps/api/src/app.ts
import { accountRouter } from '@api/routes/account';
export const app = new Hono()
.route('/account', accountRouter); // Route available at PUT /account/user
export const app = new Hono().route('/account', accountRouter); // Route available at PUT /account/user
```
---
### Step 5: Use in Frontend
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
### Step 6: Use in Frontend
**Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`
@@ -1021,15 +1025,15 @@ export class ProfileApi {
try {
this.isLoading = true;
// Call API using RPC client (matches route path)
const response = await classroomio.account.user.$put({
json: result.data
});
const data = await response.json();
if (!data.success) throw new Error(data.error);
// Update local state, show success message, etc.
} catch (error) {
this.errors.general = error.message;
@@ -1637,19 +1641,27 @@ import { onboardingRouter } from '@api/routes/onboarding';
export const app = new Hono()
// ... middleware ...
// Routes
.route('/account', accountRouter)
.route('/onboarding', onboardingRouter) // Add new route
.route('/onboarding', onboardingRouter) // Add new route
.route('/course', courseRouter)
.route('/mail', mailRouter)
// ... error handling ...
.route('/mail', mailRouter);
// ... error handling ...
```
---
## Step 5: Update Frontend
### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
## Step 6: Update Frontend
**Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar)
@@ -1732,7 +1744,7 @@ Example with **client-side validation** before API call:
// Client-side validation with entity-specific translations
const result = ZCourseCreate.safeParse(fields);
if (!result.success) {
// Map validation errors to translated messages
errors = mapZodErrorsToTranslations(result.error, 'course');
@@ -1763,7 +1775,7 @@ Example with **client-side validation** before API call:
// Success - redirect to course
const { course } = data.data;
await goto(`/courses/${course.slug}`);
} catch (error) {
console.error('Error creating course:', error);
errors.general = 'Network error. Please try again.';
@@ -2062,7 +2074,7 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
- **Use entity-specific translations** when multiple entities share field names
```typescript
mapZodErrorsToTranslations(error, 'course') // "Course title is required"
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
```
- **Provide generic translations** as fallbacks for shared fields (email, password)
@@ -2130,7 +2142,7 @@ Add JSDoc comments to document your routes:
/**
* POST /api/onboarding/create-org
* Creates organization during onboarding
*
*
* @requires Authentication
* @returns {201} Created - Organization created successfully
* @returns {409} Conflict - Sitename already exists
@@ -2154,11 +2166,11 @@ const paginationSchema = z.object({
.get('/list', zValidator('query', paginationSchema), async (c) => {
const { page, limit, sort, sortBy } = c.req.valid('query');
const offset = (page - 1) * limit;
const items = await getItems({ offset, limit, sort, sortBy });
const total = await getItemsCount();
return c.json({
success: true,
data: items,
@@ -2255,7 +2267,9 @@ describe('Onboarding Routes', () => {
// Create first, then attempt duplicate
const res = await app.request('/onboarding/create-org', {
method: 'POST',
body: JSON.stringify({ /* duplicate data */ })
body: JSON.stringify({
/* duplicate data */
})
});
expect(res.status).toBe(409);
@@ -2371,11 +2385,11 @@ const result = await db.transaction(async (tx) => {
items: z.array(itemSchema).min(1).max(100)
})), async (c) => {
const { items } = c.req.valid('json');
const results = await db.transaction(async (tx) => {
return await Promise.all(items.map(item => createItem(item)));
});
return c.json({ success: true, data: results }, 201);
});
```
+12 -9
View File
@@ -1,20 +1,22 @@
import 'dotenv/config';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { secureHeaders } from 'hono/secure-headers';
import { auth } from '@cio/db/auth';
import { Hono } from '@api/utils/hono';
import rateLimiter from '@api/middlewares/rate-limiter';
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
import { Hono } from '@api/utils/hono';
import { accountRouter } from '@api/routes/account';
import { auth } from '@cio/db/auth';
import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
// --- routes
import { mailRouter } from '@api/routes/mail';
import { courseRouter } from '@api/routes/course';
import { accountRouter } from '@api/routes/account';
import { dashAnalyticsRouter } from '@api/routes/dash';
import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support
export const app = new Hono()
@@ -74,6 +76,7 @@ export const app = new Hono()
.route('/mail', mailRouter)
.route('/media', mediaRouter)
.route('/organization', organizationRouter)
.route('/dash', dashAnalyticsRouter)
// Error handling
.onError((err, c) => {
+1
View File
@@ -0,0 +1 @@
export * from './stats';
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().post(
'/stats',
authMiddleware,
zValidator('json', ZDashStats),
async (c) => {
try {
const { orgId, siteName } = c.req.valid('json');
const result = await getOrganisationAnalytics(orgId, siteName);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
}
);
+60
View File
@@ -0,0 +1,60 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = {
revenue: 0,
numberOfCourses: 0,
totalStudents: 0,
topCourses: [],
enrollments: []
};
let resolvedOrgId = orgId;
if (!resolvedOrgId && siteName) {
const [org] = await getOrgIdBySiteName(siteName);
if (org) {
resolvedOrgId = org.id;
} else {
throw new AppError('Organization not found for the given site name', ErrorCodes.ORG_NOT_FOUND, 404);
}
}
try {
const [stats, topCourses, enrollments] = await Promise.all([
getDashOrgStats(resolvedOrgId!),
getCourseStats(resolvedOrgId!),
getRecentEnrollments(resolvedOrgId!)
]);
analytics.revenue = 0;
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
analytics.topCourses = topCourses.map((c) => ({
id: c.courseId,
title: c.courseTitle,
enrollments: c.totalStudents,
completion: c.completionPercentage
}));
analytics.enrollments = enrollments.map((e) => ({
id: e.profileId,
avatarUrl: e.avatarUrl,
name: e.fullname,
courseId: e.courseId,
course: e.courseTitle,
date: e.enrolledAt
}));
return analytics;
} catch (error) {
console.error('Failed to load organisation analytics:', error);
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './account';
export * from './media';
export * from './onboarding';
export * from './dash';
+23
View File
@@ -0,0 +1,23 @@
export interface TopCourse {
id: string;
title: string;
enrollments: number;
completion: number;
}
export interface Enrollment {
id: string;
avatarUrl: string | null;
name: string;
courseId: string;
course: string;
date: string;
}
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: TopCourse[];
enrollments: Enrollment[];
}
+1
View File
@@ -1,2 +1,3 @@
// Re-export all types
export * from './database';
export * from './dash';
+4
View File
@@ -28,6 +28,10 @@ export interface ErrorResponse {
// Error codes constants for consistency
export const ErrorCodes = {
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
@@ -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}
@@ -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}
@@ -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;
@@ -4,7 +4,7 @@
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import { currentOrg } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Avatar from '@cio/ui/base/avatar';
const errorCode = $derived(new URLSearchParams(page.url.search).get('error'));
</script>
@@ -15,14 +15,13 @@
<AuthUI isLogin={false} showOnlyContent={true}>
<div class="flex flex-col items-center 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>
<p class="text-xl font-semibold">Something went wrong</p>
@@ -10,7 +10,7 @@
import SearchIcon from '@lucide/svelte/icons/search';
import TextChip from '$lib/components/Chip/Text.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
@@ -21,6 +21,7 @@
import type { GroupPerson } from '$lib/utils/types';
import { group } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations';
import { shortenName } from '$lib/utils/functions/string';
import Select from '$lib/components/Form/Select.svelte';
import { IconButton } from '$lib/components/IconButton';
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
@@ -133,13 +134,13 @@
<Table.Cell class="w-4/6 md:w-3/6">
{#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar
src={person.profile.avatar_url}
name={person.profile.fullname}
width="w-8"
height="h-8"
className="mr-3"
/>
<Avatar.Root class="mr-3">
<Avatar.Image
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
alt={person.profile.fullname ? person.profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2">
<p class="text-base font-normal dark:text-white">
@@ -173,7 +174,7 @@
{:else}
<div class="flex w-2/4 items-start lg:items-center">
<TextChip
value={person.email.substring(0, 2).toUpperCase()}
value={shortenName(person.email)}
className="bg-primary-200 text-black font-semibold text-xs mr-3"
shape="rounded-full"
/>
@@ -21,8 +21,9 @@
import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import TextField from '$lib/components/Form/TextField.svelte';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
@@ -441,7 +442,13 @@
<div class="border-1 border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
alt={question.author.name ? question.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
@@ -490,7 +497,13 @@
<div class="border-1 border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
@@ -7,7 +7,6 @@ const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
export const load = async ({ params, url }) => {
const subdomain = getSubdomain(url);
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
// If this is LMS but user is on org site, redirect to LMS
if (isOrgSite) {
redirect(307, `/lms`);
@@ -10,14 +10,13 @@
import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import { currentOrgPath } from '$lib/utils/store/org';
import { dashStatApi } from '$lib/features/org/api';
import { CreateCourseButton } from '$lib/features/course/components';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import { ActivityCard } from '$lib/components/Analytics';
import { shortenName } from '$lib/utils/functions/string';
import Progress from '$lib/components/Progress/index.svelte';
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$lib/features/onboarding/components';
@@ -25,60 +24,33 @@
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
let dashAnalytics: OrganisationAnalytics | undefined = $state();
let hasFetched = $state(false);
const { data } = $props();
async function fetchDashAnalytics(orgId: string) {
if (!orgId || hasFetched) return;
hasFetched = true;
const accessToken = await getAccessToken();
try {
const response = await fetch('/api/analytics/dash', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify({ orgId })
});
if (!response.ok) {
console.error(response);
throw new Error('Failed to fetch analytics data');
}
dashAnalytics = (await response.json()) as OrganisationAnalytics;
} catch (error) {
snackbar.error('Failed to fetch analytics data');
}
}
const { enrollments, numberOfCourses, revenue, totalStudents, topCourses } = $derived(dashStatApi.stats);
$effect(() => {
fetchDashAnalytics($currentOrg.id);
dashStatApi.fetchOrgStats({ siteName: data.orgName });
});
let cards = $derived([
{
icon: DollarSignIcon,
title: `${$t('dashboard.revenue')} ($)`,
percentage: dashAnalytics?.revenue ?? 0,
percentage: revenue ?? 0,
description: $t('dashboard.revenue_description'),
hidePercentage: true
},
{
icon: BookIcon,
title: $t('dashboard.no_of_courses'),
percentage: dashAnalytics?.numberOfCourses ?? 0,
percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'),
hidePercentage: true
},
{
icon: UsersIcon,
title: $t('dashboard.total_students'),
percentage: dashAnalytics?.totalStudents ?? 0,
percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'),
hidePercentage: true
}
@@ -110,7 +82,7 @@
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
{#if !revenue && !numberOfCourses && !totalStudents}
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
@@ -123,21 +95,21 @@
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
<h3 class="mb-4 text-lg">
{$t('dashboard.top_courses')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#if !topCourses}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.topCourses as course}
{#each topCourses as course}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
<a class="hover:underline" href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{course.title}
</p>
</a>
@@ -148,7 +120,7 @@
</div>
<div class="ml-auto w-2/6">
<Progress value={course.completion} />
<div class="text-sm font-medium">
<div class="text-sm">
{course.completion}%
{$t('dashboard.completion')}
</div>
@@ -171,23 +143,29 @@
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
<h3 class="mb-4 text-lg">
{$t('dashboard.recent_enrollments')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#if !enrollments}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.enrollments as enrollment}
{#each enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<Avatar.Root class="h-6 w-6">
<Avatar.Image
src={enrollment.avatarUrl ? enrollment.avatarUrl : '/logo-192.png'}
alt={enrollment.name ? enrollment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(enrollment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
<p class="text-sm capitalize leading-none">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
@@ -197,8 +175,8 @@
</div>
<div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
<a class="hover:underline" href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{enrollment.course}
</p>
</a>
@@ -21,8 +21,9 @@
import { profile } from '$lib/utils/store/user';
import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Avatar from '@cio/ui/base/avatar';
import TextField from '$lib/components/Form/TextField.svelte';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
@@ -454,7 +455,13 @@
<div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
alt={question.author.name ? question.author.name : 'User'}
/>
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
@@ -503,7 +510,13 @@
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<Avatar.Root class="h-7 w-7">
<Avatar.Image
src={comment.avatar ? comment.avatar : '/logo-192.png'}
alt={comment.name ? comment.name : 'User'}
/>
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
+81
View File
@@ -0,0 +1,81 @@
import {
and,
course,
dashOrgStats,
db,
desc,
eq,
group,
groupmember,
lesson,
lessonCompletion,
organization,
profile,
sql
} from '@db/drizzle';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
courseId: course.id,
courseTitle: course.title,
totalStudents: sql<number>`COUNT(DISTINCT ${groupmember.id})`.as('total_students'),
totalLessons: sql<number>`COUNT(DISTINCT ${lesson.id})`.as('total_lessons'),
completedLessons: sql<number>`
COUNT(DISTINCT ${lessonCompletion.id})
FILTER (WHERE ${lessonCompletion.isComplete} = true)
`.as('completed_lessons')
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.leftJoin(lesson, eq(lesson.courseId, course.id))
.leftJoin(
lessonCompletion,
and(eq(lessonCompletion.lessonId, lesson.id), eq(lessonCompletion.profileId, groupmember.profileId))
)
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(course.id, course.title)
.orderBy(sql`completed_lessons DESC`)
.as('course_stats');
return db
.select({
courseId: subquery.courseId,
courseTitle: subquery.courseTitle,
totalStudents: sql<number>`${subquery.totalStudents}::integer`,
completionPercentage: sql<number>`
CASE
WHEN ${subquery.totalStudents} * ${subquery.totalLessons} = 0 THEN 0
ELSE ROUND((${subquery.completedLessons}::numeric / (${subquery.totalStudents} * ${subquery.totalLessons})) * 100)::integer
END
`
})
.from(subquery)
.orderBy(subquery.completedLessons)
.limit(5);
}
export function getRecentEnrollments(orgId: string) {
return db
.select({
profileId: profile.id,
avatarUrl: profile.avatarUrl,
fullname: profile.fullname,
courseId: course.id,
courseTitle: course.title,
enrolledAt: groupmember.createdAt
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.innerJoin(profile, eq(profile.id, groupmember.profileId))
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(profile.id, course.id, course.title, groupmember.createdAt)
.orderBy(desc(groupmember.createdAt))
.limit(5);
}
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
+1
View File
@@ -0,0 +1 @@
export * from './dash';
+19
View File
@@ -0,0 +1,19 @@
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;
}[];
}
@@ -13,6 +13,10 @@ import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle';
export function getOrgIdBySiteName(siteName: string) {
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
}
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db
.select({
+2
View File
@@ -137,3 +137,5 @@ export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
export type TLocale = (typeof schema.locale.enumValues)[number];
export type TPlan = (typeof schema.plan.enumValues)[number];
export type TDashStats = typeof schema.dashOrgStats.$inferSelect;
@@ -0,0 +1 @@
export * from './stats';
@@ -0,0 +1,12 @@
import * as z from 'zod';
export const ZDashStats = z
.object({
orgId: z.string().optional(),
siteName: z.string().optional()
})
.refine((v) => !!(v.orgId || v.siteName), {
message: 'Either orgId or siteName is required'
});
export type TDashStats = z.infer<typeof ZDashStats>;
+1
View File
@@ -6,3 +6,4 @@ export * from './mail';
export * from './media';
export * from './onboarding';
export * from './organization';
export * from './dash';