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:
co-authored by
Rotimi Best
parent
c7854efae3
commit
9a5aee1ee0
+51
-37
@@ -130,10 +130,7 @@ import type { TNewOrganization, TOrganization } from '@db/types';
|
|||||||
* @returns Created organization record
|
* @returns Created organization record
|
||||||
*/
|
*/
|
||||||
export const createOrganization = async (data: TNewOrganization) => {
|
export const createOrganization = async (data: TNewOrganization) => {
|
||||||
const [organization] = await db
|
const [organization] = await db.insert(schema.organization).values(data).returning();
|
||||||
.insert(schema.organization)
|
|
||||||
.values(data)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return organization;
|
return organization;
|
||||||
};
|
};
|
||||||
@@ -242,11 +239,7 @@ export async function createOrganizationWithOwner(
|
|||||||
// Business Logic: Check sitename availability
|
// Business Logic: Check sitename availability
|
||||||
const exists = await checkSiteNameExists(input.siteName);
|
const exists = await checkSiteNameExists(input.siteName);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
throw new AppError(
|
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
|
||||||
`Site name '${input.siteName}' already exists`,
|
|
||||||
ErrorCodes.SITENAME_EXISTS,
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Business Logic: Create org and member in a transaction
|
// Business Logic: Create org and member in a transaction
|
||||||
@@ -278,11 +271,7 @@ export async function createOrganizationWithOwner(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle database constraint violations
|
// Handle database constraint violations
|
||||||
if (error.code === '23505') {
|
if (error.code === '23505') {
|
||||||
throw new AppError(
|
throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
|
||||||
`Site name '${input.siteName}' already exists`,
|
|
||||||
ErrorCodes.SITENAME_EXISTS,
|
|
||||||
409
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
|
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
|
||||||
}
|
}
|
||||||
@@ -312,7 +301,7 @@ export class AppError extends Error {
|
|||||||
export const ErrorCodes = {
|
export const ErrorCodes = {
|
||||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||||
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND',
|
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND'
|
||||||
// ... add more as needed
|
// ... add more as needed
|
||||||
} as const;
|
} as const;
|
||||||
```
|
```
|
||||||
@@ -334,19 +323,25 @@ export const handleError = (
|
|||||||
console.error('Error in route:', error);
|
console.error('Error in route:', error);
|
||||||
|
|
||||||
if (error instanceof AppError) {
|
if (error instanceof AppError) {
|
||||||
return c.json({
|
return c.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message,
|
error: error.message,
|
||||||
code: error.code,
|
code: error.code,
|
||||||
field: error.field
|
field: error.field
|
||||||
}, error.statusCode);
|
},
|
||||||
|
error.statusCode
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.json({
|
return c.json(
|
||||||
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: fallbackMessage,
|
error: fallbackMessage,
|
||||||
code: fallbackCode
|
code: fallbackCode
|
||||||
}, 500);
|
},
|
||||||
|
500
|
||||||
|
);
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -467,7 +462,7 @@ All API responses should follow a consistent structure:
|
|||||||
Use semantically correct HTTP status codes:
|
Use semantically correct HTTP status codes:
|
||||||
|
|
||||||
| Code | Meaning | Usage |
|
| Code | Meaning | Usage |
|
||||||
|------|---------|-------|
|
| --------------------------- | ------------ | -------------------------------------------------------- |
|
||||||
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
|
| `200 OK` | Success | Successful GET, PUT, PATCH requests |
|
||||||
| `201 Created` | Created | Successful POST creating a new resource |
|
| `201 Created` | Created | Successful POST creating a new resource |
|
||||||
| `204 No Content` | Success | Successful DELETE or update with no response body |
|
| `204 No Content` | Success | Successful DELETE or update with no response body |
|
||||||
@@ -552,8 +547,7 @@ export * from './course';
|
|||||||
import { ZCourseClone } from '@cio/utils/validation/course';
|
import { ZCourseClone } from '@cio/utils/validation/course';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const cloneRouter = new Hono()
|
export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
|
||||||
.post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
|
|
||||||
const data = c.req.valid('json'); // Fully typed!
|
const data = c.req.valid('json'); // Fully typed!
|
||||||
// ... route handler
|
// ... route handler
|
||||||
});
|
});
|
||||||
@@ -618,11 +612,11 @@ This allows different messages for shared field names (e.g., "Course title is re
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// With entity name (entity-specific messages)
|
// With entity name (entity-specific messages)
|
||||||
mapZodErrorsToTranslations(error, 'course') // "Course title is required"
|
mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
|
||||||
mapZodErrorsToTranslations(error, 'lesson') // "Lesson title is required"
|
mapZodErrorsToTranslations(error, 'lesson'); // "Lesson title is required"
|
||||||
|
|
||||||
// Without entity name (generic messages)
|
// Without entity name (generic messages)
|
||||||
mapZodErrorsToTranslations(error) // "Title is required"
|
mapZodErrorsToTranslations(error); // "Title is required"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Principles**:
|
**Key Principles**:
|
||||||
@@ -843,8 +837,11 @@ import { createOrganizationWithOwner } from '@api/services/organization';
|
|||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { ZCreateOrg } from '@cio/utils/validation/organization';
|
import { ZCreateOrg } from '@cio/utils/validation/organization';
|
||||||
|
|
||||||
export const organizationRouter = new Hono()
|
export const organizationRouter = new Hono().post(
|
||||||
.post('/create', authMiddleware, zValidator('json', ZCreateOrg), async (c) => {
|
'/create',
|
||||||
|
authMiddleware,
|
||||||
|
zValidator('json', ZCreateOrg),
|
||||||
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const body = c.req.valid('json');
|
const body = c.req.valid('json');
|
||||||
@@ -855,7 +852,8 @@ export const organizationRouter = new Hono()
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleError(c, error, 'Failed to create organization');
|
return handleError(c, error, 'Failed to create organization');
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Key Points:**
|
**Key Points:**
|
||||||
@@ -967,8 +965,7 @@ import { authMiddleware } from '@api/middlewares/auth';
|
|||||||
import { handleError } from '@api/utils/errors';
|
import { handleError } from '@api/utils/errors';
|
||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
|
|
||||||
export const accountRouter = new Hono()
|
export const accountRouter = new Hono().put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
|
||||||
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
|
|
||||||
try {
|
try {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const validatedData = c.req.valid('json');
|
const validatedData = c.req.valid('json');
|
||||||
@@ -992,13 +989,20 @@ export * from './account';
|
|||||||
// apps/api/src/app.ts
|
// apps/api/src/app.ts
|
||||||
import { accountRouter } from '@api/routes/account';
|
import { accountRouter } from '@api/routes/account';
|
||||||
|
|
||||||
export const app = new Hono()
|
export const app = new Hono().route('/account', accountRouter); // Route available at PUT /account/user
|
||||||
.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`
|
**Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`
|
||||||
|
|
||||||
@@ -1642,14 +1646,22 @@ export const app = new Hono()
|
|||||||
.route('/account', accountRouter)
|
.route('/account', accountRouter)
|
||||||
.route('/onboarding', onboardingRouter) // Add new route
|
.route('/onboarding', onboardingRouter) // Add new route
|
||||||
.route('/course', courseRouter)
|
.route('/course', courseRouter)
|
||||||
.route('/mail', mailRouter)
|
.route('/mail', mailRouter);
|
||||||
|
|
||||||
// ... error handling ...
|
// ... 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)
|
**Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar)
|
||||||
|
|
||||||
@@ -2062,7 +2074,7 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
|
|||||||
- **Use entity-specific translations** when multiple entities share field names
|
- **Use entity-specific translations** when multiple entities share field names
|
||||||
|
|
||||||
```typescript
|
```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)
|
- **Provide generic translations** as fallbacks for shared fields (email, password)
|
||||||
@@ -2255,7 +2267,9 @@ describe('Onboarding Routes', () => {
|
|||||||
// Create first, then attempt duplicate
|
// Create first, then attempt duplicate
|
||||||
const res = await app.request('/onboarding/create-org', {
|
const res = await app.request('/onboarding/create-org', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ /* duplicate data */ })
|
body: JSON.stringify({
|
||||||
|
/* duplicate data */
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.status).toBe(409);
|
expect(res.status).toBe(409);
|
||||||
|
|||||||
+12
-9
@@ -1,20 +1,22 @@
|
|||||||
import 'dotenv/config';
|
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 { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
|
||||||
|
|
||||||
import { Hono } from '@api/utils/hono';
|
// --- routes
|
||||||
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';
|
|
||||||
import { mailRouter } from '@api/routes/mail';
|
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 { mediaRouter } from '@api/routes/media';
|
||||||
import { onboardingRouter } from '@api/routes/onboarding';
|
import { onboardingRouter } from '@api/routes/onboarding';
|
||||||
import { organizationRouter } from '@api/routes/organization';
|
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
|
// Create Hono app with chaining for RPC support
|
||||||
export const app = new Hono()
|
export const app = new Hono()
|
||||||
@@ -74,6 +76,7 @@ export const app = new Hono()
|
|||||||
.route('/mail', mailRouter)
|
.route('/mail', mailRouter)
|
||||||
.route('/media', mediaRouter)
|
.route('/media', mediaRouter)
|
||||||
.route('/organization', organizationRouter)
|
.route('/organization', organizationRouter)
|
||||||
|
.route('/dash', dashAnalyticsRouter)
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
.onError((err, c) => {
|
.onError((err, c) => {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stats';
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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,3 +1,4 @@
|
|||||||
export * from './account';
|
export * from './account';
|
||||||
export * from './media';
|
export * from './media';
|
||||||
export * from './onboarding';
|
export * from './onboarding';
|
||||||
|
export * from './dash';
|
||||||
|
|||||||
@@ -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,2 +1,3 @@
|
|||||||
// Re-export all types
|
// Re-export all types
|
||||||
export * from './database';
|
export * from './database';
|
||||||
|
export * from './dash';
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ export interface ErrorResponse {
|
|||||||
|
|
||||||
// Error codes constants for consistency
|
// Error codes constants for consistency
|
||||||
export const ErrorCodes = {
|
export const ErrorCodes = {
|
||||||
|
// Dashboard Analytics errors
|
||||||
|
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
|
||||||
|
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
|
||||||
|
|
||||||
// Organization errors
|
// Organization errors
|
||||||
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
SITENAME_EXISTS: 'SITENAME_EXISTS',
|
||||||
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
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="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">
|
<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">
|
<div class="flex flex-col space-y-2">
|
||||||
<p class="text-center text-2xl md:text-left dark:text-white">
|
<p class="text-center text-2xl md:text-left dark:text-white">
|
||||||
{user.fullName}
|
{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 { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
import EmptyState from './EmptyState.svelte';
|
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 type { StudentOverview } from '$lib/utils/types/analytics';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
students?: StudentOverview[];
|
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"
|
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">
|
<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">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate font-medium text-gray-900 dark:text-white">
|
<p class="truncate font-medium text-gray-900 dark:text-white">
|
||||||
{student.profile.fullname}
|
{student.profile.fullname}
|
||||||
|
|||||||
+18
-5
@@ -5,8 +5,9 @@
|
|||||||
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
|
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import { group } from '$lib/components/Course/store';
|
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 TextArea from '$lib/components/Form/TextArea.svelte';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
|
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
id: 0,
|
id: 0,
|
||||||
comment: comment,
|
comment: comment,
|
||||||
name: $t('course.navItem.lessons.comments.you'),
|
name: $t('course.navItem.lessons.comments.you'),
|
||||||
avatar: $profile.avatar_url,
|
avatar: $profile.avatarUrl,
|
||||||
commentAt: new Date(),
|
commentAt: new Date(),
|
||||||
groupmember_id: groupmember.id
|
groupmember_id: groupmember.id
|
||||||
},
|
},
|
||||||
@@ -270,7 +271,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex h-full items-start gap-3">
|
<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">
|
<div class="h-full w-full">
|
||||||
<TextArea
|
<TextArea
|
||||||
label={$t('course.navItem.lessons.comments.text_area_title')}
|
label={$t('course.navItem.lessons.comments.text_area_title')}
|
||||||
@@ -293,7 +300,13 @@
|
|||||||
<div class="my-10">
|
<div class="my-10">
|
||||||
{#each comments as commentItem}
|
{#each comments as commentItem}
|
||||||
<div class="mt-2 flex items-start gap-3 pb-2">
|
<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="w-full rounded-md border px-4 pb-4 pt-2 dark:border-neutral-700">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
@@ -349,7 +362,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{commentItem.comment}
|
||||||
</article>
|
</article>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+9
-2
@@ -7,8 +7,9 @@
|
|||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||||
import ReviewFormEditor from './ReviewFormEditor.svelte';
|
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 { processErrors } from '$lib/utils/functions/validator';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import type { Course } from '$lib/utils/types';
|
import type { Course } from '$lib/utils/types';
|
||||||
|
|
||||||
@@ -98,7 +99,13 @@
|
|||||||
{#if review.id !== reviewToExpand}
|
{#if review.id !== reviewToExpand}
|
||||||
<!-- the headers -->
|
<!-- the headers -->
|
||||||
<div class="flex w-full items-center justify-between">
|
<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>
|
<p class="text-sm">{review.name}</p>
|
||||||
|
|
||||||
<IconButton value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}>
|
<IconButton value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}>
|
||||||
|
|||||||
@@ -21,9 +21,10 @@
|
|||||||
import Chip from '../Chip/index.svelte';
|
import Chip from '../Chip/index.svelte';
|
||||||
import Modal from '../Modal/index.svelte';
|
import Modal from '../Modal/index.svelte';
|
||||||
import ImageRenderer from '../Org/ImageRenderer.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 PricingSection from './components/PricingSection.svelte';
|
||||||
import { PoweredBy } from '$lib/features/ui';
|
import { PoweredBy } from '$lib/features/ui';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import SectionsDisplay from './components/SectionsDisplay.svelte';
|
import SectionsDisplay from './components/SectionsDisplay.svelte';
|
||||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.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">
|
<div class="item-start my-2 flex w-2/4 flex-row">
|
||||||
<!-- image container -->
|
<!-- image container -->
|
||||||
{#if review.avatar_url}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<!-- profile content -->
|
<!-- profile content -->
|
||||||
@@ -401,7 +408,13 @@
|
|||||||
<div class="item-start my-2 flex w-full flex-row">
|
<div class="item-start my-2 flex w-full flex-row">
|
||||||
<!-- image container -->
|
<!-- image container -->
|
||||||
{#if review.avatar_url}
|
{#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}
|
{/if}
|
||||||
|
|
||||||
<!-- profile content -->
|
<!-- profile content -->
|
||||||
|
|||||||
@@ -1,39 +1,30 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import BellIcon from '@lucide/svelte/icons/bell';
|
import BellIcon from '@lucide/svelte/icons/bell';
|
||||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
import * as Avatar from '@cio/ui/base/avatar';
|
||||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
|
||||||
|
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
navClass?: string;
|
navClass?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { navClass = '' }: Props = $props();
|
let { navClass = '' }: Props = $props();
|
||||||
|
|
||||||
function shortenName(name: string) {
|
|
||||||
return name?.substring(0, 2)?.toUpperCase() || '';
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
|
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
|
||||||
<ul class="flex w-full items-center">
|
<ul class="flex w-full items-center">
|
||||||
<div class="">
|
<div class="">
|
||||||
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
|
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
|
||||||
{#if $currentOrg.avatar_url}
|
<Avatar.Root class="mr-2 h-7 w-7 rounded-md">
|
||||||
<Avatar
|
<Avatar.Image
|
||||||
src={$currentOrg.avatar_url}
|
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||||
name={$currentOrg.name}
|
alt={$currentOrg.name ? $currentOrg.name : 'Organization'}
|
||||||
shape="rounded-md"
|
|
||||||
width="w-7"
|
|
||||||
height="h-7"
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
/>
|
||||||
{:else}
|
<Avatar.Fallback>{shortenName($currentOrg.name)}</Avatar.Fallback>
|
||||||
<TextChip value={shortenName($currentOrg.name)} className="bg-primary-200 mr-2 dark:text-black" />
|
</Avatar.Root>
|
||||||
{/if}
|
|
||||||
<span class="line-clamp-1">
|
<span class="line-clamp-1">
|
||||||
{$currentOrg.name}
|
{$currentOrg.name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
import { UpgradeBanner } from '$lib/features/ui';
|
import { UpgradeBanner } from '$lib/features/ui';
|
||||||
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
|
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 * as Page from '@cio/ui/base/page';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
orgApi.getOrgAudience($currentOrg.id);
|
orgApi.getOrgAudience($currentOrg.id);
|
||||||
@@ -81,7 +82,13 @@
|
|||||||
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
|
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
|
||||||
class="text-primary-700 flex items-center gap-2 hover:underline"
|
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}
|
{row.name}
|
||||||
</a>
|
</a>
|
||||||
</Table.Cell>
|
</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.svelte';
|
||||||
export * from './org-plan.svelte';
|
export * from './org-plan.svelte';
|
||||||
|
export * from './dash-stats.svelte';
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
|
|||||||
|
|
||||||
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
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 InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
|
||||||
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
|
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
|
||||||
export type InviteTeamData = InviteTeamSuccess['data'];
|
export type InviteTeamData = InviteTeamSuccess['data'];
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<!-- Center text -->
|
<!-- Center text -->
|
||||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
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 { t } from '$lib/utils/functions/translations';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
|
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
|
||||||
@@ -68,14 +68,14 @@
|
|||||||
<Card.Root class="ui:w-full max-w-[400px]">
|
<Card.Root class="ui:w-full max-w-[400px]">
|
||||||
{#if !showOnlyContent || showLogo}
|
{#if !showOnlyContent || showLogo}
|
||||||
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
||||||
<Avatar
|
<Avatar.Root>
|
||||||
|
<Avatar.Image
|
||||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||||
shape="rounded-md"
|
|
||||||
width="w-10"
|
|
||||||
height="max-h-10"
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
/>
|
||||||
|
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
|
||||||
<a href="/">
|
<a href="/">
|
||||||
<Card.Title class="ui:text-2xl ui:font-bold">
|
<Card.Title class="ui:text-2xl ui:font-bold">
|
||||||
{isLogin ? $t('login.welcome') : $t('login.create_account')}
|
{isLogin ? $t('login.welcome') : $t('login.create_account')}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-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 type { AccountOrg } from '$lib/features/app/types';
|
||||||
|
|
||||||
import { setTheme } from '$lib/utils/functions/theme';
|
import { setTheme } from '$lib/utils/functions/theme';
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
{#if $currentOrg.name}
|
{#if $currentOrg.name}
|
||||||
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
|
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
|
||||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
<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>
|
</Avatar.Root>
|
||||||
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
|
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
|
||||||
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
|
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
<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>
|
</Avatar.Root>
|
||||||
|
|
||||||
{org.name}
|
{org.name}
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
{#if $currentOrg.name}
|
{#if $currentOrg.name}
|
||||||
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
|
<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.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>
|
</Avatar.Root>
|
||||||
|
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<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">
|
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
<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>
|
</Avatar.Root>
|
||||||
|
|
||||||
{org.name}
|
{org.name}
|
||||||
|
|||||||
@@ -23,7 +23,3 @@ export function openUpgradeModal() {
|
|||||||
|
|
||||||
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
|
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 {
|
export function capitalizeFirstLetter(val: string): string {
|
||||||
return val.charAt(0).toUpperCase() + val.slice(1);
|
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;
|
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 {
|
export interface UserCourseAnalytics {
|
||||||
user: User;
|
user: User;
|
||||||
averageGrade: number;
|
averageGrade: number;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import * as Card from '@cio/ui/base/card';
|
import * as Card from '@cio/ui/base/card';
|
||||||
import { currentOrg } from '$lib/utils/store/org';
|
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'));
|
const errorCode = $derived(new URLSearchParams(page.url.search).get('error'));
|
||||||
</script>
|
</script>
|
||||||
@@ -15,14 +15,13 @@
|
|||||||
|
|
||||||
<AuthUI isLogin={false} showOnlyContent={true}>
|
<AuthUI isLogin={false} showOnlyContent={true}>
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<Avatar
|
<Avatar.Root>
|
||||||
|
<Avatar.Image
|
||||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||||
shape="rounded-md"
|
|
||||||
width="w-10"
|
|
||||||
height="max-h-10"
|
|
||||||
className="mr-2"
|
|
||||||
/>
|
/>
|
||||||
|
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
|
||||||
<p class="text-xl font-semibold">Something went wrong</p>
|
<p class="text-xl font-semibold">Something went wrong</p>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import SearchIcon from '@lucide/svelte/icons/search';
|
import SearchIcon from '@lucide/svelte/icons/search';
|
||||||
|
|
||||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
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 { ComingSoon } from '$lib/features/ui';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
import type { GroupPerson } from '$lib/utils/types';
|
import type { GroupPerson } from '$lib/utils/types';
|
||||||
import { group } from '$lib/components/Course/store';
|
import { group } from '$lib/components/Course/store';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import Select from '$lib/components/Form/Select.svelte';
|
import Select from '$lib/components/Form/Select.svelte';
|
||||||
import { IconButton } from '$lib/components/IconButton';
|
import { IconButton } from '$lib/components/IconButton';
|
||||||
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
|
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
|
||||||
@@ -133,13 +134,13 @@
|
|||||||
<Table.Cell class="w-4/6 md:w-3/6">
|
<Table.Cell class="w-4/6 md:w-3/6">
|
||||||
{#if person.profile}
|
{#if person.profile}
|
||||||
<div class="flex items-start lg:items-center">
|
<div class="flex items-start lg:items-center">
|
||||||
<Avatar
|
<Avatar.Root class="mr-3">
|
||||||
src={person.profile.avatar_url}
|
<Avatar.Image
|
||||||
name={person.profile.fullname}
|
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
||||||
width="w-8"
|
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||||
height="h-8"
|
|
||||||
className="mr-3"
|
|
||||||
/>
|
/>
|
||||||
|
<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="flex flex-col items-start lg:flex-row lg:items-center">
|
||||||
<div class="mr-2">
|
<div class="mr-2">
|
||||||
<p class="text-base font-normal dark:text-white">
|
<p class="text-base font-normal dark:text-white">
|
||||||
@@ -173,7 +174,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="flex w-2/4 items-start lg:items-center">
|
<div class="flex w-2/4 items-start lg:items-center">
|
||||||
<TextChip
|
<TextChip
|
||||||
value={person.email.substring(0, 2).toUpperCase()}
|
value={shortenName(person.email)}
|
||||||
className="bg-primary-200 text-black font-semibold text-xs mr-3"
|
className="bg-primary-200 text-black font-semibold text-xs mr-3"
|
||||||
shape="rounded-full"
|
shape="rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,8 +21,9 @@
|
|||||||
|
|
||||||
import Vote from '$lib/components/Vote/index.svelte';
|
import Vote from '$lib/components/Vote/index.svelte';
|
||||||
import { IconButton } from '$lib/components/IconButton';
|
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 TextField from '$lib/components/Form/TextField.svelte';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.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">
|
<div class="border-1 border-gray my-1 rounded-lg border px-1">
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black no-underline hover:underline">
|
<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 dark:text-white">{question.author.name}</p>
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
{question.createdAt}
|
{question.createdAt}
|
||||||
@@ -490,7 +497,13 @@
|
|||||||
<div class="border-1 border-gray w-full rounded-lg border">
|
<div class="border-1 border-gray w-full rounded-lg border">
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black">
|
<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 dark:text-white">{comment.name}</p>
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
{comment.createdAt}
|
{comment.createdAt}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
|
|||||||
export const load = async ({ params, url }) => {
|
export const load = async ({ params, url }) => {
|
||||||
const subdomain = getSubdomain(url);
|
const subdomain = getSubdomain(url);
|
||||||
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
||||||
|
|
||||||
// If this is LMS but user is on org site, redirect to LMS
|
// If this is LMS but user is on org site, redirect to LMS
|
||||||
if (isOrgSite) {
|
if (isOrgSite) {
|
||||||
redirect(307, `/lms`);
|
redirect(307, `/lms`);
|
||||||
|
|||||||
@@ -10,14 +10,13 @@
|
|||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { calDateDiff } from '$lib/utils/functions/date';
|
import { calDateDiff } from '$lib/utils/functions/date';
|
||||||
import { getGreeting } from '$lib/utils/functions/date';
|
import { getGreeting } from '$lib/utils/functions/date';
|
||||||
import { snackbar } from '$lib/components/Snackbar/store';
|
import { currentOrgPath } from '$lib/utils/store/org';
|
||||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
import { dashStatApi } from '$lib/features/org/api';
|
||||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
|
||||||
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
|
|
||||||
|
|
||||||
import { CreateCourseButton } from '$lib/features/course/components';
|
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 { ActivityCard } from '$lib/components/Analytics';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import Progress from '$lib/components/Progress/index.svelte';
|
import Progress from '$lib/components/Progress/index.svelte';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { WelcomeModal } from '$lib/features/onboarding/components';
|
import { WelcomeModal } from '$lib/features/onboarding/components';
|
||||||
@@ -25,60 +24,33 @@
|
|||||||
import * as Page from '@cio/ui/base/page';
|
import * as Page from '@cio/ui/base/page';
|
||||||
import { Empty } from '@cio/ui/custom/empty';
|
import { Empty } from '@cio/ui/custom/empty';
|
||||||
|
|
||||||
let dashAnalytics: OrganisationAnalytics | undefined = $state();
|
const { data } = $props();
|
||||||
let hasFetched = $state(false);
|
|
||||||
|
|
||||||
async function fetchDashAnalytics(orgId: string) {
|
const { enrollments, numberOfCourses, revenue, totalStudents, topCourses } = $derived(dashStatApi.stats);
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
fetchDashAnalytics($currentOrg.id);
|
dashStatApi.fetchOrgStats({ siteName: data.orgName });
|
||||||
});
|
});
|
||||||
|
|
||||||
let cards = $derived([
|
let cards = $derived([
|
||||||
{
|
{
|
||||||
icon: DollarSignIcon,
|
icon: DollarSignIcon,
|
||||||
title: `${$t('dashboard.revenue')} ($)`,
|
title: `${$t('dashboard.revenue')} ($)`,
|
||||||
percentage: dashAnalytics?.revenue ?? 0,
|
percentage: revenue ?? 0,
|
||||||
description: $t('dashboard.revenue_description'),
|
description: $t('dashboard.revenue_description'),
|
||||||
hidePercentage: true
|
hidePercentage: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BookIcon,
|
icon: BookIcon,
|
||||||
title: $t('dashboard.no_of_courses'),
|
title: $t('dashboard.no_of_courses'),
|
||||||
percentage: dashAnalytics?.numberOfCourses ?? 0,
|
percentage: numberOfCourses ?? 0,
|
||||||
description: $t('dashboard.no_courses_description'),
|
description: $t('dashboard.no_courses_description'),
|
||||||
hidePercentage: true
|
hidePercentage: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
title: $t('dashboard.total_students'),
|
title: $t('dashboard.total_students'),
|
||||||
percentage: dashAnalytics?.totalStudents ?? 0,
|
percentage: totalStudents ?? 0,
|
||||||
description: $t('dashboard.total_students_description'),
|
description: $t('dashboard.total_students_description'),
|
||||||
hidePercentage: true
|
hidePercentage: true
|
||||||
}
|
}
|
||||||
@@ -110,7 +82,7 @@
|
|||||||
<div class="mb-10 flex flex-wrap items-start">
|
<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">
|
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each cards as card}
|
{#each cards as card}
|
||||||
{#if !dashAnalytics}
|
{#if !revenue && !numberOfCourses && !totalStudents}
|
||||||
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
|
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
|
||||||
{:else}
|
{:else}
|
||||||
<ActivityCard activity={card} />
|
<ActivityCard activity={card} />
|
||||||
@@ -123,21 +95,21 @@
|
|||||||
<div
|
<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"
|
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')}
|
{$t('dashboard.top_courses')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="h-full space-y-6">
|
<div class="h-full space-y-6">
|
||||||
{#if !dashAnalytics}
|
{#if !topCourses}
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _}
|
||||||
<Skeleton class="h-10 w-full rounded-md" />
|
<Skeleton class="h-10 w-full rounded-md" />
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each dashAnalytics.topCourses as course}
|
{#each topCourses as course}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div class="w-4/6 space-y-1">
|
<div class="w-4/6 space-y-1">
|
||||||
<a href={`/courses/${course.id}`}>
|
<a class="hover:underline" href={`/courses/${course.id}`}>
|
||||||
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
|
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||||
{course.title}
|
{course.title}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
@@ -148,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="ml-auto w-2/6">
|
<div class="ml-auto w-2/6">
|
||||||
<Progress value={course.completion} />
|
<Progress value={course.completion} />
|
||||||
<div class="text-sm font-medium">
|
<div class="text-sm">
|
||||||
{course.completion}%
|
{course.completion}%
|
||||||
{$t('dashboard.completion')}
|
{$t('dashboard.completion')}
|
||||||
</div>
|
</div>
|
||||||
@@ -171,23 +143,29 @@
|
|||||||
<div
|
<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"
|
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')}
|
{$t('dashboard.recent_enrollments')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="h-full space-y-6">
|
<div class="h-full space-y-6">
|
||||||
{#if !dashAnalytics}
|
{#if !enrollments}
|
||||||
{#each Array(5) as _}
|
{#each Array(5) as _}
|
||||||
<Skeleton class="h-10 w-full rounded-md" />
|
<Skeleton class="h-10 w-full rounded-md" />
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
{#each dashAnalytics.enrollments as enrollment}
|
{#each enrollments as enrollment}
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center 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">
|
<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">
|
<p class="text-muted-foreground text-sm">
|
||||||
<span class="italic">
|
<span class="italic">
|
||||||
{calDateDiff(enrollment.date)}
|
{calDateDiff(enrollment.date)}
|
||||||
@@ -197,8 +175,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-2/4">
|
<div class="w-2/4">
|
||||||
<a href={`/courses/${enrollment.courseId}`}>
|
<a class="hover:underline" href={`/courses/${enrollment.courseId}`}>
|
||||||
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
|
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||||
{enrollment.course}
|
{enrollment.course}
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -21,8 +21,9 @@
|
|||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import Vote from '$lib/components/Vote/index.svelte';
|
import Vote from '$lib/components/Vote/index.svelte';
|
||||||
import { IconButton } from '$lib/components/IconButton';
|
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 TextField from '$lib/components/Form/TextField.svelte';
|
||||||
|
import { shortenName } from '$lib/utils/functions/string';
|
||||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||||
import { CommunityDeleteModal } from '$lib/features/community/components';
|
import { CommunityDeleteModal } from '$lib/features/community/components';
|
||||||
@@ -454,7 +455,13 @@
|
|||||||
<div class="border-gray my-1 rounded-lg border px-1">
|
<div class="border-gray my-1 rounded-lg border px-1">
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black no-underline hover:underline">
|
<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 dark:text-white">{question.author.name}</p>
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
{question.createdAt}
|
{question.createdAt}
|
||||||
@@ -503,7 +510,13 @@
|
|||||||
<div class="border-gray w-full rounded-lg border">
|
<div class="border-gray w-full rounded-lg border">
|
||||||
<header class="flex items-center justify-between p-2 leading-none">
|
<header class="flex items-center justify-between p-2 leading-none">
|
||||||
<div class="flex items-center text-black">
|
<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 dark:text-white">{comment.name}</p>
|
||||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||||
{comment.createdAt}
|
{comment.createdAt}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './dash';
|
||||||
@@ -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 { ROLE } from '@cio/utils/constants';
|
||||||
import { db } from '@db/drizzle';
|
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[]> => {
|
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
|
||||||
const result = await db
|
const result = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -137,3 +137,5 @@ export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
|
|||||||
export type TLocale = (typeof schema.locale.enumValues)[number];
|
export type TLocale = (typeof schema.locale.enumValues)[number];
|
||||||
|
|
||||||
export type TPlan = (typeof schema.plan.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>;
|
||||||
@@ -6,3 +6,4 @@ export * from './mail';
|
|||||||
export * from './media';
|
export * from './media';
|
||||||
export * from './onboarding';
|
export * from './onboarding';
|
||||||
export * from './organization';
|
export * from './organization';
|
||||||
|
export * from './dash';
|
||||||
|
|||||||
Reference in New Issue
Block a user