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