add changes
This commit is contained in:
@@ -313,7 +313,7 @@ router.get('/users/:userId', verifyToken, isAdmin, adminController.getUserById);
|
|||||||
* type: string
|
* type: string
|
||||||
* questionType:
|
* questionType:
|
||||||
* type: string
|
* type: string
|
||||||
* enum: [multiple_choice, true_false, short_answer]
|
* enum: [multiple_choice, trueFalse, short_answer]
|
||||||
* options:
|
* options:
|
||||||
* type: array
|
* type: array
|
||||||
* items:
|
* items:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
import { ApplicationConfig, ErrorHandler, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core';
|
||||||
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
|
import { provideRouter, withPreloading, PreloadAllModules, withInMemoryScrolling } from '@angular/router';
|
||||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||||
|
|
||||||
@@ -13,7 +13,10 @@ export const appConfig: ApplicationConfig = {
|
|||||||
provideZonelessChangeDetection(),
|
provideZonelessChangeDetection(),
|
||||||
provideRouter(
|
provideRouter(
|
||||||
routes,
|
routes,
|
||||||
withPreloading(PreloadAllModules)
|
withPreloading(PreloadAllModules),
|
||||||
|
withInMemoryScrolling({
|
||||||
|
scrollPositionRestoration: 'top'
|
||||||
|
})
|
||||||
),
|
),
|
||||||
provideAnimationsAsync(),
|
provideAnimationsAsync(),
|
||||||
provideHttpClient(
|
provideHttpClient(
|
||||||
|
|||||||
@@ -8,40 +8,55 @@
|
|||||||
*/
|
*/
|
||||||
export interface UserGrowthData {
|
export interface UserGrowthData {
|
||||||
date: string;
|
date: string;
|
||||||
count: number;
|
newUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Category popularity data for chart
|
* Category popularity data for chart
|
||||||
*/
|
*/
|
||||||
export interface CategoryPopularity {
|
export interface CategoryPopularity {
|
||||||
categoryId: string;
|
id: string;
|
||||||
categoryName: string;
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
icon: any;
|
||||||
|
color: string;
|
||||||
quizCount: number;
|
quizCount: number;
|
||||||
percentage: number;
|
averageScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System-wide statistics response
|
* System-wide statistics response
|
||||||
*/
|
*/
|
||||||
export interface AdminStatistics {
|
export interface AdminStatistics {
|
||||||
totalUsers: number;
|
users: AdminStatisticsUsers;
|
||||||
activeUsers: number; // Last 7 days
|
quizzes: AdminStatisticsQuizzes;
|
||||||
totalQuizSessions: number;
|
content: AdminStatisticsContent;
|
||||||
totalQuestions: number;
|
quizActivity: QuizActivity[];
|
||||||
averageQuizScore: number;
|
|
||||||
userGrowth: UserGrowthData[];
|
userGrowth: UserGrowthData[];
|
||||||
popularCategories: CategoryPopularity[];
|
popularCategories: CategoryPopularity[];
|
||||||
stats: {
|
}
|
||||||
newUsersToday: number;
|
export interface AdminStatisticsContent {
|
||||||
newUsersThisWeek: number;
|
totalCategories: number;
|
||||||
newUsersThisMonth: number;
|
totalQuestions: number;
|
||||||
quizzesToday: number;
|
questionsByDifficulty: {
|
||||||
quizzesThisWeek: number;
|
easy: number;
|
||||||
quizzesThisMonth: number;
|
medium: number;
|
||||||
|
hard: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface AdminStatisticsQuizzes {
|
||||||
|
totalSessions: number;
|
||||||
|
averageScore: number;
|
||||||
|
averageScorePercentage: number;
|
||||||
|
passRate: number;
|
||||||
|
passedQuizzes: number;
|
||||||
|
failedQuizzes: number;
|
||||||
|
}
|
||||||
|
export interface AdminStatisticsUsers {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inactiveLast7Days: number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* API response wrapper for statistics
|
* API response wrapper for statistics
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +66,10 @@ export interface AdminStatisticsResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface QuizActivity {
|
||||||
|
date: string;
|
||||||
|
quizzesCompleted: number;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Date range filter for statistics
|
* Date range filter for statistics
|
||||||
*/
|
*/
|
||||||
@@ -71,43 +90,58 @@ export interface AdminCacheEntry<T> {
|
|||||||
/**
|
/**
|
||||||
* Guest session timeline data point
|
* Guest session timeline data point
|
||||||
*/
|
*/
|
||||||
export interface GuestSessionTimelineData {
|
// export interface GuestSessionTimelineData {
|
||||||
date: string;
|
// date: string;
|
||||||
activeSessions: number;
|
// activeSessions: number;
|
||||||
newSessions: number;
|
// newSessions: number;
|
||||||
convertedSessions: number;
|
// convertedSessions: number;
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Conversion funnel stage
|
* Conversion funnel stage
|
||||||
*/
|
*/
|
||||||
export interface ConversionFunnelStage {
|
// export interface ConversionFunnelStage {
|
||||||
stage: string;
|
// stage: string;
|
||||||
count: number;
|
// count: number;
|
||||||
percentage: number;
|
// percentage: number;
|
||||||
dropoff?: number;
|
// dropoff?: number;
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Guest analytics data
|
* Guest analytics data
|
||||||
*/
|
*/
|
||||||
export interface GuestAnalytics {
|
|
||||||
|
export interface GuestAnalyticsOverview {
|
||||||
totalGuestSessions: number;
|
totalGuestSessions: number;
|
||||||
activeGuestSessions: number;
|
activeGuestSessions: number;
|
||||||
conversionRate: number; // Percentage
|
expiredGuestSessions: number;
|
||||||
averageQuizzesPerGuest: number;
|
convertedGuestSessions: number;
|
||||||
totalConversions: number;
|
conversionRate: number;
|
||||||
timeline: GuestSessionTimelineData[];
|
}
|
||||||
conversionFunnel: ConversionFunnelStage[];
|
export interface GuestAnalyticsQuizActivity {
|
||||||
stats: {
|
totalGuestQuizzes: number;
|
||||||
sessionsToday: number;
|
completedGuestQuizzes: number;
|
||||||
sessionsThisWeek: number;
|
guestQuizCompletionRate: number;
|
||||||
sessionsThisMonth: number;
|
avgQuizzesPerGuest: number;
|
||||||
conversionsToday: number;
|
avgQuizzesBeforeConversion: number;
|
||||||
conversionsThisWeek: number;
|
}
|
||||||
conversionsThisMonth: number;
|
|
||||||
|
export interface GuestAnalyticsBehavior {
|
||||||
|
bounceRate: number;
|
||||||
|
avgSessionDurationMinutes: number;
|
||||||
|
}
|
||||||
|
export interface GuestAnalyticsRecentActivity {
|
||||||
|
last30Days: {
|
||||||
|
newGuestSessions: number;
|
||||||
|
conversions: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
export interface GuestAnalytics {
|
||||||
|
overview: GuestAnalyticsOverview;
|
||||||
|
quizActivity: GuestAnalyticsQuizActivity;
|
||||||
|
behavior: GuestAnalyticsBehavior;
|
||||||
|
recentActivity: GuestAnalyticsRecentActivity;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API response wrapper for guest analytics
|
* API response wrapper for guest analytics
|
||||||
@@ -183,10 +217,10 @@ export interface AdminUserListResponse {
|
|||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalUsers: number;
|
totalItems: number;
|
||||||
limit: number;
|
itemsPerPage: number;
|
||||||
hasNext: boolean;
|
hasNextPage: boolean;
|
||||||
hasPrev: boolean;
|
hasPreviousPage: boolean;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
@@ -197,7 +231,13 @@ export interface AdminUserListResponse {
|
|||||||
*/
|
*/
|
||||||
export interface UserActivity {
|
export interface UserActivity {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'login' | 'quiz_start' | 'quiz_complete' | 'bookmark' | 'profile_update' | 'role_change';
|
type:
|
||||||
|
| 'login'
|
||||||
|
| 'quiz_start'
|
||||||
|
| 'quiz_complete'
|
||||||
|
| 'bookmark'
|
||||||
|
| 'profile_update'
|
||||||
|
| 'role_change';
|
||||||
description: string;
|
description: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
metadata?: {
|
metadata?: {
|
||||||
|
|||||||
@@ -73,12 +73,14 @@ export class AdminService {
|
|||||||
readonly isLoadingUsers = signal<boolean>(false);
|
readonly isLoadingUsers = signal<boolean>(false);
|
||||||
readonly usersError = signal<string | null>(null);
|
readonly usersError = signal<string | null>(null);
|
||||||
readonly usersPagination = signal<{
|
readonly usersPagination = signal<{
|
||||||
|
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalUsers: number;
|
totalItems: number;
|
||||||
limit: number;
|
itemsPerPage: number;
|
||||||
hasNext: boolean;
|
hasNextPage: boolean;
|
||||||
hasPrev: boolean;
|
hasPreviousPage: boolean;
|
||||||
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
readonly currentUserFilters = signal<UserListParams>({});
|
readonly currentUserFilters = signal<UserListParams>({});
|
||||||
|
|
||||||
@@ -95,18 +97,18 @@ export class AdminService {
|
|||||||
|
|
||||||
// Computed signals - Statistics
|
// Computed signals - Statistics
|
||||||
readonly hasStats = computed(() => this.adminStatsState() !== null);
|
readonly hasStats = computed(() => this.adminStatsState() !== null);
|
||||||
readonly totalUsers = computed(() => this.adminStatsState()?.totalUsers ?? 0);
|
readonly totalUsers = computed(() => this.adminStatsState()?.users.total ?? 0);
|
||||||
readonly activeUsers = computed(() => this.adminStatsState()?.activeUsers ?? 0);
|
readonly activeUsers = computed(() => this.adminStatsState()?.users.active ?? 0);
|
||||||
readonly totalQuizSessions = computed(() => this.adminStatsState()?.totalQuizSessions ?? 0);
|
readonly totalQuizSessions = computed(() => this.adminStatsState()?.quizzes.totalSessions ?? 0);
|
||||||
readonly totalQuestions = computed(() => this.adminStatsState()?.totalQuestions ?? 0);
|
readonly totalQuestions = computed(() => this.adminStatsState()?.content.totalQuestions ?? 0);
|
||||||
readonly averageScore = computed(() => this.adminStatsState()?.averageQuizScore ?? 0);
|
readonly averageScore = computed(() => this.adminStatsState()?.quizzes.averageScore ?? 0);
|
||||||
|
|
||||||
// Computed signals - Guest Analytics
|
// Computed signals - Guest Analytics
|
||||||
readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null);
|
readonly hasAnalytics = computed(() => this.guestAnalyticsState() !== null);
|
||||||
readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.totalGuestSessions ?? 0);
|
readonly totalGuestSessions = computed(() => this.guestAnalyticsState()?.overview.totalGuestSessions ?? 0);
|
||||||
readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.activeGuestSessions ?? 0);
|
readonly activeGuestSessions = computed(() => this.guestAnalyticsState()?.overview.activeGuestSessions ?? 0);
|
||||||
readonly conversionRate = computed(() => this.guestAnalyticsState()?.conversionRate ?? 0);
|
readonly conversionRate = computed(() => this.guestAnalyticsState()?.overview.conversionRate ?? 0);
|
||||||
readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.averageQuizzesPerGuest ?? 0);
|
readonly avgQuizzesPerGuest = computed(() => this.guestAnalyticsState()?.quizActivity.avgQuizzesPerGuest ?? 0);
|
||||||
|
|
||||||
// Computed signals - Guest Settings
|
// Computed signals - Guest Settings
|
||||||
readonly hasSettings = computed(() => this.guestSettingsState() !== null);
|
readonly hasSettings = computed(() => this.guestSettingsState() !== null);
|
||||||
@@ -116,7 +118,7 @@ export class AdminService {
|
|||||||
|
|
||||||
// Computed signals - User Management
|
// Computed signals - User Management
|
||||||
readonly hasUsers = computed(() => this.adminUsersState().length > 0);
|
readonly hasUsers = computed(() => this.adminUsersState().length > 0);
|
||||||
readonly totalUsersCount = computed(() => this.usersPagination()?.totalUsers ?? 0);
|
readonly totalUsersCount = computed(() => this.usersPagination()?.totalItems ?? 0);
|
||||||
readonly currentPage = computed(() => this.usersPagination()?.currentPage ?? 1);
|
readonly currentPage = computed(() => this.usersPagination()?.currentPage ?? 1);
|
||||||
readonly totalPages = computed(() => this.usersPagination()?.totalPages ?? 1);
|
readonly totalPages = computed(() => this.usersPagination()?.totalPages ?? 1);
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export interface PaginationConfig {
|
|||||||
*/
|
*/
|
||||||
export interface PaginationState {
|
export interface PaginationState {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
pageSize: number;
|
itemsPerPage: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
startIndex: number;
|
startIndex: number;
|
||||||
endIndex: number;
|
endIndex: number;
|
||||||
hasPrev: boolean;
|
hasPreviousPage: boolean;
|
||||||
hasNext: boolean;
|
hasNextPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -61,13 +61,13 @@ export class PaginationService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
currentPage: validCurrentPage,
|
currentPage: validCurrentPage,
|
||||||
pageSize,
|
itemsPerPage:pageSize,
|
||||||
totalItems,
|
totalItems,
|
||||||
totalPages,
|
totalPages,
|
||||||
startIndex,
|
startIndex,
|
||||||
endIndex,
|
endIndex,
|
||||||
hasPrev,
|
hasNextPage:hasNext,
|
||||||
hasNext
|
hasPreviousPage:hasPrev
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,13 +208,13 @@ export class PaginationService {
|
|||||||
},
|
},
|
||||||
|
|
||||||
nextPage: () => {
|
nextPage: () => {
|
||||||
if (state().hasNext) {
|
if (state().hasNextPage) {
|
||||||
config.update(c => ({ ...c, currentPage: c.currentPage + 1 }));
|
config.update(c => ({ ...c, currentPage: c.currentPage + 1 }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
prevPage: () => {
|
prevPage: () => {
|
||||||
if (state().hasPrev) {
|
if (state().hasPreviousPage) {
|
||||||
config.update(c => ({ ...c, currentPage: c.currentPage - 1 }));
|
config.update(c => ({ ...c, currentPage: c.currentPage - 1 }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
<p class="subtitle">System-wide statistics and analytics</p>
|
<p class="subtitle">System-wide statistics and analytics</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button mat-icon-button (click)="refreshStats()" [disabled]="isLoading()" matTooltip="Refresh statistics">
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
(click)="refreshStats()"
|
||||||
|
[disabled]="isLoading()"
|
||||||
|
matTooltip="Refresh statistics"
|
||||||
|
>
|
||||||
<mat-icon>refresh</mat-icon>
|
<mat-icon>refresh</mat-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,20 +31,40 @@
|
|||||||
<div class="date-inputs">
|
<div class="date-inputs">
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>Start Date</mat-label>
|
<mat-label>Start Date</mat-label>
|
||||||
<input matInput [matDatepicker]="startPicker" formControlName="startDate">
|
<input
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="startPicker"></mat-datepicker-toggle>
|
matInput
|
||||||
|
[matDatepicker]="startPicker"
|
||||||
|
formControlName="startDate"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="startPicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
<mat-datepicker #startPicker></mat-datepicker>
|
<mat-datepicker #startPicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<mat-form-field appearance="outline">
|
<mat-form-field appearance="outline">
|
||||||
<mat-label>End Date</mat-label>
|
<mat-label>End Date</mat-label>
|
||||||
<input matInput [matDatepicker]="endPicker" formControlName="endDate">
|
<input
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="endPicker"></mat-datepicker-toggle>
|
matInput
|
||||||
|
[matDatepicker]="endPicker"
|
||||||
|
formControlName="endDate"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="endPicker"
|
||||||
|
></mat-datepicker-toggle>
|
||||||
<mat-datepicker #endPicker></mat-datepicker>
|
<mat-datepicker #endPicker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<button mat-raised-button color="primary" (click)="applyDateFilter()"
|
<button
|
||||||
[disabled]="!dateRangeForm.value.startDate || !dateRangeForm.value.endDate">
|
mat-raised-button
|
||||||
|
color="primary"
|
||||||
|
(click)="applyDateFilter()"
|
||||||
|
[disabled]="
|
||||||
|
!dateRangeForm.value.startDate || !dateRangeForm.value.endDate
|
||||||
|
"
|
||||||
|
>
|
||||||
Apply Filter
|
Apply Filter
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -90,8 +115,10 @@
|
|||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>Total Users</h3>
|
<h3>Total Users</h3>
|
||||||
<p class="stat-value">{{ formatNumber(totalUsers()) }}</p>
|
<p class="stat-value">{{ formatNumber(totalUsers()) }}</p>
|
||||||
@if (stats() && stats()!.stats.newUsersThisWeek) {
|
@if (stats() && stats()!.users.inactiveLast7Days) {
|
||||||
<p class="stat-detail">+{{ stats()!.stats.newUsersThisWeek }} this week</p>
|
<p class="stat-detail">
|
||||||
|
+{{ stats()!.users.inactiveLast7Days }} this week
|
||||||
|
</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@@ -118,8 +145,13 @@
|
|||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>Total Quizzes</h3>
|
<h3>Total Quizzes</h3>
|
||||||
<p class="stat-value">{{ formatNumber(totalQuizSessions()) }}</p>
|
<p class="stat-value">{{ formatNumber(totalQuizSessions()) }}</p>
|
||||||
@if (stats() && stats()!.stats.quizzesThisWeek) {
|
@if (stats() && stats()!.quizzes) {
|
||||||
<p class="stat-detail">+{{ stats()!.stats.quizzesThisWeek }} this week</p>
|
<p class="stat-detail">{{ stats()!.quizzes.averageScore }} Average score</p>
|
||||||
|
<p class="stat-detail">{{ stats()!.quizzes.averageScorePercentage }} Average score percentage</p>
|
||||||
|
<p class="stat-detail">{{ stats()!.quizzes.failedQuizzes }} Failed quizzes</p>
|
||||||
|
<p class="stat-detail">{{ stats()!.quizzes.passRate }} Pass rate</p>
|
||||||
|
<p class="stat-detail">{{ stats()!.quizzes.passedQuizzes }} Passed quizzes</p>
|
||||||
|
<p class="stat-detail">{{ stats()!.quizzes.totalSessions }} Total sessions</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@@ -150,11 +182,15 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="score-display">
|
<div class="score-display">
|
||||||
<div class="score-circle">
|
<div class="score-circle">
|
||||||
<span class="score-value">{{ formatPercentage(averageScore()) }}</span>
|
<span class="score-value">{{
|
||||||
|
formatPercentage(averageScore())
|
||||||
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="score-description">
|
<p class="score-description">
|
||||||
@if (averageScore() >= 80) {
|
@if (averageScore() >= 80) {
|
||||||
<span class="excellent">Excellent performance across all quizzes</span>
|
<span class="excellent"
|
||||||
|
>Excellent performance across all quizzes</span
|
||||||
|
>
|
||||||
} @else if (averageScore() >= 60) {
|
} @else if (averageScore() >= 60) {
|
||||||
<span class="good">Good performance overall</span>
|
<span class="good">Good performance overall</span>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -166,7 +202,7 @@
|
|||||||
</mat-card>
|
</mat-card>
|
||||||
|
|
||||||
<!-- User Growth Chart -->
|
<!-- User Growth Chart -->
|
||||||
@if (userGrowthData().length > 0) {
|
<!-- @if (userGrowthData().length > 0) {
|
||||||
<mat-card class="chart-card">
|
<mat-card class="chart-card">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>
|
<mat-card-title>
|
||||||
@@ -176,31 +212,85 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="line-chart">
|
<svg
|
||||||
|
[attr.width]="chartWidth"
|
||||||
|
[attr.height]="chartHeight"
|
||||||
|
class="line-chart"
|
||||||
|
> -->
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
<!-- <line
|
||||||
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
x1="40"
|
||||||
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
y1="40"
|
||||||
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
|
x2="760"
|
||||||
|
y2="40"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="120"
|
||||||
|
x2="760"
|
||||||
|
y2="120"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="200"
|
||||||
|
x2="760"
|
||||||
|
y2="200"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="260"
|
||||||
|
x2="760"
|
||||||
|
y2="260"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/> -->
|
||||||
|
|
||||||
<!-- Axes -->
|
<!-- Axes -->
|
||||||
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
<!-- <line
|
||||||
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
x1="40"
|
||||||
|
y1="40"
|
||||||
|
x2="40"
|
||||||
|
y2="260"
|
||||||
|
stroke="#333"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="260"
|
||||||
|
x2="760"
|
||||||
|
y2="260"
|
||||||
|
stroke="#333"
|
||||||
|
stroke-width="2"
|
||||||
|
/> -->
|
||||||
|
|
||||||
<!-- Data line -->
|
<!-- Data line -->
|
||||||
<path [attr.d]="getUserGrowthPath()" fill="none" stroke="#3f51b5" stroke-width="3"/>
|
<!-- <path
|
||||||
|
[attr.d]="getUserGrowthPath()"
|
||||||
|
fill="none"
|
||||||
|
stroke="#3f51b5"
|
||||||
|
stroke-width="3"
|
||||||
|
/> -->
|
||||||
|
|
||||||
<!-- Data points -->
|
<!-- Data points -->
|
||||||
@for (point of userGrowthData(); track point.date; let i = $index) {
|
<!-- @for (point of userGrowthData(); track point.date; let i = $index) {
|
||||||
<circle [attr.cx]="calculateChartX(i, userGrowthData().length)"
|
<circle
|
||||||
[attr.cy]="calculateChartY(point.count, i)"
|
[attr.cx]="calculateChartX(i, userGrowthData().length)"
|
||||||
r="4" fill="#3f51b5"/>
|
[attr.cy]="calculateChartY(point.newUsers, i)"
|
||||||
|
r="4"
|
||||||
|
fill="#3f51b5"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
} -->
|
||||||
|
|
||||||
<!-- Popular Categories Chart -->
|
<!-- Popular Categories Chart -->
|
||||||
@if (popularCategories().length > 0) {
|
@if (popularCategories().length > 0) {
|
||||||
@@ -213,24 +303,83 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="bar-chart">
|
<svg
|
||||||
|
[attr.width]="chartWidth"
|
||||||
|
[attr.height]="chartHeight"
|
||||||
|
class="bar-chart"
|
||||||
|
>
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
<line
|
||||||
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
x1="40"
|
||||||
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
y1="40"
|
||||||
|
x2="760"
|
||||||
|
y2="40"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="120"
|
||||||
|
x2="760"
|
||||||
|
y2="120"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="200"
|
||||||
|
x2="760"
|
||||||
|
y2="200"
|
||||||
|
stroke="#e0e0e0"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Axes -->
|
<!-- Axes -->
|
||||||
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
<line
|
||||||
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
x1="40"
|
||||||
|
y1="40"
|
||||||
|
x2="40"
|
||||||
|
y2="260"
|
||||||
|
stroke="#333"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="40"
|
||||||
|
y1="260"
|
||||||
|
x2="760"
|
||||||
|
y2="260"
|
||||||
|
stroke="#333"
|
||||||
|
stroke-width="2"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Bars -->
|
<!-- Bars -->
|
||||||
@for (bar of getCategoryBars(); track bar.label) {
|
@for (bar of getCategoryBars(); track bar.label) {
|
||||||
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
|
<rect
|
||||||
[attr.height]="bar.height" fill="#4caf50" opacity="0.8"/>
|
[attr.x]="bar.x"
|
||||||
<text [attr.x]="bar.x + bar.width / 2" [attr.y]="bar.y - 5"
|
[attr.y]="bar.y"
|
||||||
text-anchor="middle" font-size="12" fill="#333">{{ bar.value }}</text>
|
[attr.width]="bar.width"
|
||||||
<text [attr.x]="bar.x + bar.width / 2" y="280"
|
[attr.height]="bar.height"
|
||||||
text-anchor="middle" font-size="11" fill="#666">{{ bar.label }}</text>
|
fill="#4caf50"
|
||||||
|
opacity="0.8"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
[attr.x]="bar.x + bar.width / 2"
|
||||||
|
[attr.y]="bar.y - 5"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="12"
|
||||||
|
fill="#333"
|
||||||
|
>
|
||||||
|
{{ bar.value }}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
[attr.x]="bar.x + bar.width / 2"
|
||||||
|
y="280"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="11"
|
||||||
|
fill="#666"
|
||||||
|
>
|
||||||
|
{{ bar.label }}
|
||||||
|
</text>
|
||||||
}
|
}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
|
|||||||
getMaxUserCount(): number {
|
getMaxUserCount(): number {
|
||||||
const data = this.userGrowthData();
|
const data = this.userGrowthData();
|
||||||
if (data.length === 0) return 1;
|
if (data.length === 0) return 1;
|
||||||
return Math.max(...data.map(d => d.count), 1);
|
return Math.max(...data.map(d => d.newUsers), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,7 +197,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
|
|||||||
const data = this.userGrowthData();
|
const data = this.userGrowthData();
|
||||||
if (data.length === 0) return '';
|
if (data.length === 0) return '';
|
||||||
|
|
||||||
const maxCount = Math.max(...data.map(d => d.count), 1);
|
const maxCount = Math.max(...data.map(d => d.newUsers), 1);
|
||||||
const width = this.chartWidth;
|
const width = this.chartWidth;
|
||||||
const height = this.chartHeight;
|
const height = this.chartHeight;
|
||||||
const padding = 40;
|
const padding = 40;
|
||||||
@@ -206,7 +206,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
const points = data.map((d, i) => {
|
const points = data.map((d, i) => {
|
||||||
const x = padding + (i / (data.length - 1)) * plotWidth;
|
const x = padding + (i / (data.length - 1)) * plotWidth;
|
||||||
const y = height - padding - (d.count / maxCount) * plotHeight;
|
const y = height - padding - (d.newUsers / maxCount) * plotHeight;
|
||||||
return `${x},${y}`;
|
return `${x},${y}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,7 +235,7 @@ export class AdminDashboardComponent implements OnInit, OnDestroy {
|
|||||||
y: height - padding - barHeight,
|
y: height - padding - barHeight,
|
||||||
width: barWidth,
|
width: barWidth,
|
||||||
height: barHeight,
|
height: barHeight,
|
||||||
label: cat.categoryName,
|
label: cat.name,
|
||||||
value: cat.quizCount
|
value: cat.quizCount
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -84,8 +84,8 @@ export class AdminQuestionFormComponent implements OnInit {
|
|||||||
|
|
||||||
// Available options
|
// Available options
|
||||||
readonly questionTypes = [
|
readonly questionTypes = [
|
||||||
{ value: 'multiple_choice', label: 'Multiple Choice' },
|
{ value: 'multiple', label: 'Multiple Choice' },
|
||||||
{ value: 'true_false', label: 'True/False' },
|
{ value: 'trueFalse', label: 'True/False' },
|
||||||
{ value: 'written', label: 'Written Answer' }
|
{ value: 'written', label: 'Written Answer' }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ export class AdminQuestionFormComponent implements OnInit {
|
|||||||
private initializeForm(): void {
|
private initializeForm(): void {
|
||||||
this.questionForm = this.fb.group({
|
this.questionForm = this.fb.group({
|
||||||
questionText: ['', [Validators.required, Validators.minLength(10)]],
|
questionText: ['', [Validators.required, Validators.minLength(10)]],
|
||||||
questionType: ['multiple_choice', Validators.required],
|
questionType: ['multiple', Validators.required],
|
||||||
categoryId: ['', Validators.required],
|
categoryId: ['', Validators.required],
|
||||||
difficulty: ['medium', Validators.required],
|
difficulty: ['medium', Validators.required],
|
||||||
options: this.fb.array([
|
options: this.fb.array([
|
||||||
@@ -333,7 +333,7 @@ export class AdminQuestionFormComponent implements OnInit {
|
|||||||
const correctAnswer = formGroup.get('correctAnswer')?.value;
|
const correctAnswer = formGroup.get('correctAnswer')?.value;
|
||||||
const options = formGroup.get('options') as FormArray;
|
const options = formGroup.get('options') as FormArray;
|
||||||
|
|
||||||
if (questionType === 'multiple_choice' && correctAnswer && options) {
|
if (questionType === 'multiple' && correctAnswer && options) {
|
||||||
const optionTexts = options.controls.map(opt => opt.get('text')?.value);
|
const optionTexts = options.controls.map(opt => opt.get('text')?.value);
|
||||||
const isValid = optionTexts.includes(correctAnswer);
|
const isValid = optionTexts.includes(correctAnswer);
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ export class AdminQuestionFormComponent implements OnInit {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Add options for multiple choice
|
// Add options for multiple choice
|
||||||
if (formValue.questionType === 'multiple_choice') {
|
if (formValue.questionType === 'multiple') {
|
||||||
questionData.options = this.getOptionTexts();
|
questionData.options = this.getOptionTexts();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,8 +54,8 @@
|
|||||||
<mat-label>Type</mat-label>
|
<mat-label>Type</mat-label>
|
||||||
<mat-select formControlName="type">
|
<mat-select formControlName="type">
|
||||||
<mat-option value="all">All Types</mat-option>
|
<mat-option value="all">All Types</mat-option>
|
||||||
<mat-option value="multiple_choice">Multiple Choice</mat-option>
|
<mat-option value="multiple">Multiple Choice</mat-option>
|
||||||
<mat-option value="true_false">True/False</mat-option>
|
<mat-option value="trueFalse">True/False</mat-option>
|
||||||
<mat-option value="written">Written</mat-option>
|
<mat-option value="written">Written</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
@@ -137,10 +137,10 @@
|
|||||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||||
<td mat-cell *matCellDef="let question">
|
<td mat-cell *matCellDef="let question">
|
||||||
<mat-chip>
|
<mat-chip>
|
||||||
@if (question.questionType === 'multiple_choice') {
|
@if (question.questionType === 'multiple') {
|
||||||
<mat-icon>radio_button_checked</mat-icon>
|
<mat-icon>radio_button_checked</mat-icon>
|
||||||
<span>MCQ</span>
|
<span class="px-5"> MCQ</span>
|
||||||
} @else if (question.questionType === 'true_false') {
|
} @else if (question.questionType === 'trueFalse') {
|
||||||
<mat-icon>check_circle</mat-icon>
|
<mat-icon>check_circle</mat-icon>
|
||||||
<span> T/F</span>
|
<span> T/F</span>
|
||||||
} @else {
|
} @else {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
<h2>Users</h2>
|
<h2>Users</h2>
|
||||||
@if (pagination()) {
|
@if (pagination()) {
|
||||||
<span class="total-count">
|
<span class="total-count">
|
||||||
Total: {{ pagination()?.totalUsers }} user{{ pagination()?.totalUsers !== 1 ? 's' : '' }}
|
Total: {{ pagination()?.totalItems }} user{{ pagination()?.totalItems !== 1 ? 's' : '' }}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,8 +83,8 @@ export class AdminUsersComponent implements OnInit {
|
|||||||
|
|
||||||
return this.paginationService.calculatePaginationState({
|
return this.paginationService.calculatePaginationState({
|
||||||
currentPage: pag.currentPage,
|
currentPage: pag.currentPage,
|
||||||
pageSize: pag.limit,
|
pageSize: pag.itemsPerPage,
|
||||||
totalItems: pag.totalUsers
|
totalItems: pag.totalItems
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,8 @@
|
|||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>Total Guest Sessions</h3>
|
<h3>Total Guest Sessions</h3>
|
||||||
<p class="stat-value">{{ formatNumber(totalSessions()) }}</p>
|
<p class="stat-value">{{ formatNumber(totalSessions()) }}</p>
|
||||||
@if (analytics() && analytics()!.stats.sessionsThisWeek) {
|
@if (analytics() && analytics()!.recentActivity.last30Days) {
|
||||||
<p class="stat-detail">+{{ analytics()!.stats.sessionsThisWeek }} this week</p>
|
<p class="stat-detail">+{{ analytics()!.recentActivity.last30Days }} this 30 days</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@@ -89,8 +89,8 @@
|
|||||||
<div class="stat-info">
|
<div class="stat-info">
|
||||||
<h3>Conversion Rate</h3>
|
<h3>Conversion Rate</h3>
|
||||||
<p class="stat-value">{{ formatPercentage(conversionRate()) }}</p>
|
<p class="stat-value">{{ formatPercentage(conversionRate()) }}</p>
|
||||||
@if (analytics() && analytics()!.totalConversions) {
|
@if (analytics() && analytics()!.overview.conversionRate) {
|
||||||
<p class="stat-detail">{{ analytics()!.totalConversions }} conversions</p>
|
<p class="stat-detail">{{ analytics()!.overview.conversionRate }} conversions</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session Timeline Chart -->
|
<!-- Session Timeline Chart -->
|
||||||
@if (timelineData().length > 0) {
|
<!-- @if (timelineData().length > 0) {
|
||||||
<mat-card class="chart-card">
|
<mat-card class="chart-card">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>
|
<mat-card-title>
|
||||||
@@ -135,28 +135,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="timeline-chart">
|
<svg [attr.width]="chartWidth" [attr.height]="chartHeight" class="timeline-chart"> -->
|
||||||
<!-- Grid lines -->
|
<!-- Grid lines -->
|
||||||
<line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
<!-- <line x1="40" y1="40" x2="760" y2="40" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
<line x1="40" y1="120" x2="760" y2="120" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
<line x1="40" y1="200" x2="760" y2="200" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#e0e0e0" stroke-width="1"/>
|
||||||
|
-->
|
||||||
<!-- Axes -->
|
<!-- Axes -->
|
||||||
<line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
<!-- <line x1="40" y1="40" x2="40" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
<line x1="40" y1="260" x2="760" y2="260" stroke="#333" stroke-width="2"/>
|
||||||
|
-->
|
||||||
<!-- Active Sessions Line -->
|
<!-- Active Sessions Line -->
|
||||||
<path [attr.d]="getTimelinePath('activeSessions')" fill="none" stroke="#3f51b5" stroke-width="3"/>
|
<!-- <path [attr.d]="getTimelinePath('activeSessions')" fill="none" stroke="#3f51b5" stroke-width="3"/> -->
|
||||||
|
|
||||||
<!-- New Sessions Line -->
|
<!-- New Sessions Line -->
|
||||||
<path [attr.d]="getTimelinePath('newSessions')" fill="none" stroke="#4caf50" stroke-width="3"/>
|
<!-- <path [attr.d]="getTimelinePath('newSessions')" fill="none" stroke="#4caf50" stroke-width="3"/> -->
|
||||||
|
|
||||||
<!-- Converted Sessions Line -->
|
<!-- Converted Sessions Line -->
|
||||||
<path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/>
|
<!-- <path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/> -->
|
||||||
|
|
||||||
<!-- Data points -->
|
<!-- Data points -->
|
||||||
@for (point of timelineData(); track point.date; let i = $index) {
|
<!-- @for (point of timelineData(); track point.date; let i = $index) {
|
||||||
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||||
[attr.cy]="calculateTimelineY(point.activeSessions)"
|
[attr.cy]="calculateTimelineY(point.activeSessions)"
|
||||||
r="4" fill="#3f51b5"/>
|
r="4" fill="#3f51b5"/>
|
||||||
@@ -171,10 +171,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
} -->
|
||||||
|
|
||||||
<!-- Conversion Funnel Chart -->
|
<!-- Conversion Funnel Chart -->
|
||||||
@if (funnelData().length > 0) {
|
<!-- @if (funnelData().length > 0) {
|
||||||
<mat-card class="chart-card">
|
<mat-card class="chart-card">
|
||||||
<mat-card-header>
|
<mat-card-header>
|
||||||
<mat-card-title>
|
<mat-card-title>
|
||||||
@@ -184,21 +184,21 @@
|
|||||||
</mat-card-header>
|
</mat-card-header>
|
||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="chart-container">
|
<div class="chart-container">
|
||||||
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart">
|
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart"> -->
|
||||||
<!-- Funnel Bars -->
|
<!-- Funnel Bars -->
|
||||||
@for (bar of getFunnelBars(); track bar.label) {
|
<!-- @for (bar of getFunnelBars(); track bar.label) {
|
||||||
<g>
|
<g> -->
|
||||||
<!-- Bar -->
|
<!-- Bar -->
|
||||||
<rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
|
<!-- <rect [attr.x]="bar.x" [attr.y]="bar.y" [attr.width]="bar.width"
|
||||||
[attr.height]="bar.height" [attr.fill]="$index === 0 ? '#4caf50' : $index === getFunnelBars().length - 1 ? '#ff9800' : '#2196f3'"
|
[attr.height]="bar.height" [attr.fill]="$index === 0 ? '#4caf50' : $index === getFunnelBars().length - 1 ? '#ff9800' : '#2196f3'"
|
||||||
opacity="0.8"/>
|
opacity="0.8"/>
|
||||||
|
-->
|
||||||
<!-- Label -->
|
<!-- Label -->
|
||||||
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 - 5"
|
<!-- <text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 - 5"
|
||||||
font-size="14" font-weight="600" fill="#fff">{{ bar.label }}</text>
|
font-size="14" font-weight="600" fill="#fff">{{ bar.label }}</text>
|
||||||
|
-->
|
||||||
<!-- Count and Percentage -->
|
<!-- Count and Percentage -->
|
||||||
<text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 + 15"
|
<!-- <text [attr.x]="bar.x + 10" [attr.y]="bar.y + bar.height / 2 + 15"
|
||||||
font-size="12" fill="#fff">{{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }})</text>
|
font-size="12" fill="#fff">{{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }})</text>
|
||||||
</g>
|
</g>
|
||||||
}
|
}
|
||||||
@@ -216,7 +216,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
}
|
} -->
|
||||||
|
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="quick-actions">
|
<div class="quick-actions">
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
|
|||||||
readonly avgQuizzes = this.adminService.avgQuizzesPerGuest;
|
readonly avgQuizzes = this.adminService.avgQuizzesPerGuest;
|
||||||
|
|
||||||
// Chart data computed signals
|
// Chart data computed signals
|
||||||
readonly timelineData = computed(() => this.analytics()?.timeline ?? []);
|
// readonly timelineData = computed(() => this.analytics()?.timeline ?? []);
|
||||||
readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []);
|
// readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []);
|
||||||
|
|
||||||
// Chart dimensions
|
// Chart dimensions
|
||||||
readonly chartWidth = 800;
|
readonly chartWidth = 800;
|
||||||
@@ -100,25 +100,25 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Calculate max value for timeline chart
|
* Calculate max value for timeline chart
|
||||||
*/
|
*/
|
||||||
getMaxTimelineValue(): number {
|
// getMaxTimelineValue(): number {
|
||||||
const data = this.timelineData();
|
// const data = this.timelineData();
|
||||||
if (data.length === 0) return 1;
|
// if (data.length === 0) return 1;
|
||||||
return Math.max(
|
// return Math.max(
|
||||||
...data.map(d => Math.max(d.activeSessions, d.newSessions, d.convertedSessions)),
|
// ...data.map(d => Math.max(d.activeSessions, d.newSessions, d.convertedSessions)),
|
||||||
1
|
// 1
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate Y coordinate for timeline chart
|
* Calculate Y coordinate for timeline chart
|
||||||
*/
|
*/
|
||||||
calculateTimelineY(value: number): number {
|
// calculateTimelineY(value: number): number {
|
||||||
const maxValue = this.getMaxTimelineValue();
|
// const maxValue = this.getMaxTimelineValue();
|
||||||
const height = this.chartHeight;
|
// const height = this.chartHeight;
|
||||||
const padding = 40;
|
// const padding = 40;
|
||||||
const plotHeight = height - 2 * padding;
|
// const plotHeight = height - 2 * padding;
|
||||||
return height - padding - (value / maxValue) * plotHeight;
|
// return height - padding - (value / maxValue) * plotHeight;
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate X coordinate for timeline chart
|
* Calculate X coordinate for timeline chart
|
||||||
@@ -134,55 +134,55 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
|
|||||||
/**
|
/**
|
||||||
* Generate SVG path for timeline line
|
* Generate SVG path for timeline line
|
||||||
*/
|
*/
|
||||||
getTimelinePath(dataKey: 'activeSessions' | 'newSessions' | 'convertedSessions'): string {
|
// getTimelinePath(dataKey: 'activeSessions' | 'newSessions' | 'convertedSessions'): string {
|
||||||
const data = this.timelineData();
|
// const data = this.timelineData();
|
||||||
if (data.length === 0) return '';
|
// if (data.length === 0) return '';
|
||||||
|
|
||||||
const points = data.map((d, i) => {
|
// const points = data.map((d, i) => {
|
||||||
const x = this.calculateTimelineX(i, data.length);
|
// const x = this.calculateTimelineX(i, data.length);
|
||||||
const y = this.calculateTimelineY(d[dataKey]);
|
// const y = this.calculateTimelineY(d[dataKey]);
|
||||||
return `${x},${y}`;
|
// return `${x},${y}`;
|
||||||
});
|
// });
|
||||||
|
|
||||||
return `M ${points.join(' L ')}`;
|
// return `M ${points.join(' L ')}`;
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get conversion funnel bar data
|
* Get conversion funnel bar data
|
||||||
*/
|
*/
|
||||||
getFunnelBars(): Array<{
|
// getFunnelBars(): Array<{
|
||||||
x: number;
|
// x: number;
|
||||||
y: number;
|
// y: number;
|
||||||
width: number;
|
// width: number;
|
||||||
height: number;
|
// height: number;
|
||||||
label: string;
|
// label: string;
|
||||||
count: number;
|
// count: number;
|
||||||
percentage: number;
|
// percentage: number;
|
||||||
}> {
|
// }> {
|
||||||
const stages = this.funnelData();
|
// const stages = this.funnelData();
|
||||||
if (stages.length === 0) return [];
|
// if (stages.length === 0) return [];
|
||||||
|
|
||||||
const maxCount = Math.max(...stages.map(s => s.count), 1);
|
// const maxCount = Math.max(...stages.map(s => s.count), 1);
|
||||||
const width = this.chartWidth;
|
// const width = this.chartWidth;
|
||||||
const height = this.funnelHeight;
|
// const height = this.funnelHeight;
|
||||||
const padding = 60;
|
// const padding = 60;
|
||||||
const plotWidth = width - 2 * padding;
|
// const plotWidth = width - 2 * padding;
|
||||||
const plotHeight = height - 2 * padding;
|
// const plotHeight = height - 2 * padding;
|
||||||
const barHeight = plotHeight / stages.length - 20;
|
// const barHeight = plotHeight / stages.length - 20;
|
||||||
|
|
||||||
return stages.map((stage, i) => {
|
// return stages.map((stage, i) => {
|
||||||
const barWidth = (stage.count / maxCount) * plotWidth;
|
// const barWidth = (stage.count / maxCount) * plotWidth;
|
||||||
return {
|
// return {
|
||||||
x: padding,
|
// x: padding,
|
||||||
y: padding + i * (plotHeight / stages.length) + 10,
|
// y: padding + i * (plotHeight / stages.length) + 10,
|
||||||
width: barWidth,
|
// width: barWidth,
|
||||||
height: barHeight,
|
// height: barHeight,
|
||||||
label: stage.stage,
|
// label: stage.stage,
|
||||||
count: stage.count,
|
// count: stage.count,
|
||||||
percentage: stage.percentage
|
// percentage: stage.percentage
|
||||||
};
|
// };
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export analytics data to CSV
|
* Export analytics data to CSV
|
||||||
@@ -197,26 +197,26 @@ export class GuestAnalyticsComponent implements OnInit, OnDestroy {
|
|||||||
// Summary statistics
|
// Summary statistics
|
||||||
csvContent += 'Summary Statistics\n';
|
csvContent += 'Summary Statistics\n';
|
||||||
csvContent += 'Metric,Value\n';
|
csvContent += 'Metric,Value\n';
|
||||||
csvContent += `Total Guest Sessions,${analytics.totalGuestSessions}\n`;
|
csvContent += `Total Guest Sessions,${analytics.overview.activeGuestSessions}\n`;
|
||||||
csvContent += `Active Guest Sessions,${analytics.activeGuestSessions}\n`;
|
csvContent += `Active Guest Sessions,${analytics.overview.activeGuestSessions}\n`;
|
||||||
csvContent += `Conversion Rate,${analytics.conversionRate}%\n`;
|
csvContent += `Conversion Rate,${analytics.overview.conversionRate}%\n`;
|
||||||
csvContent += `Average Quizzes per Guest,${analytics.averageQuizzesPerGuest}\n`;
|
csvContent += `Average Quizzes per Guest,${analytics.quizActivity.avgQuizzesPerGuest}\n`;
|
||||||
csvContent += `Total Conversions,${analytics.totalConversions}\n\n`;
|
csvContent += `Total Conversions,${analytics.overview.conversionRate}\n\n`;
|
||||||
|
|
||||||
// Timeline data
|
// Timeline data
|
||||||
csvContent += 'Timeline Data\n';
|
csvContent += 'Timeline Data\n';
|
||||||
csvContent += 'Date,Active Sessions,New Sessions,Converted Sessions\n';
|
csvContent += 'Date,Active Sessions,New Sessions,Converted Sessions\n';
|
||||||
analytics.timeline.forEach(item => {
|
// analytics.timeline.forEach(item => {
|
||||||
csvContent += `${item.date},${item.activeSessions},${item.newSessions},${item.convertedSessions}\n`;
|
// csvContent += `${item.date},${item.activeSessions},${item.newSessions},${item.convertedSessions}\n`;
|
||||||
});
|
// });
|
||||||
csvContent += '\n';
|
csvContent += '\n';
|
||||||
|
|
||||||
// Funnel data
|
// Funnel data
|
||||||
csvContent += 'Conversion Funnel\n';
|
csvContent += 'Conversion Funnel\n';
|
||||||
csvContent += 'Stage,Count,Percentage,Dropoff\n';
|
csvContent += 'Stage,Count,Percentage,Dropoff\n';
|
||||||
analytics.conversionFunnel.forEach(stage => {
|
// analytics.conversionFunnel.forEach(stage => {
|
||||||
csvContent += `${stage.stage},${stage.count},${stage.percentage}%,${stage.dropoff ?? 'N/A'}\n`;
|
// csvContent += `${stage.stage},${stage.count},${stage.percentage}%,${stage.dropoff ?? 'N/A'}\n`;
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Create and download file
|
// Create and download file
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
|||||||
@@ -194,9 +194,9 @@ export class QuizResultsComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
getQuestionTypeText(type: string): string {
|
getQuestionTypeText(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'multiple_choice':
|
case 'multiple':
|
||||||
return 'Multiple Choice';
|
return 'Multiple Choice';
|
||||||
case 'true_false':
|
case 'trueFalse':
|
||||||
return 'True/False';
|
return 'True/False';
|
||||||
case 'written':
|
case 'written':
|
||||||
return 'Written';
|
return 'Written';
|
||||||
|
|||||||
@@ -215,9 +215,9 @@ export class QuizReviewComponent implements OnInit, OnDestroy {
|
|||||||
*/
|
*/
|
||||||
getQuestionTypeText(type: string): string {
|
getQuestionTypeText(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'multiple_choice':
|
case 'multiple':
|
||||||
return 'Multiple Choice';
|
return 'Multiple Choice';
|
||||||
case 'true_false':
|
case 'trueFalse':
|
||||||
return 'True/False';
|
return 'True/False';
|
||||||
case 'written':
|
case 'written':
|
||||||
return 'Written';
|
return 'Written';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
<div style="padding: 10px;">
|
||||||
<h2 mat-dialog-title>
|
<h2 mat-dialog-title>
|
||||||
@if (data.icon) {
|
@if (data.icon) {
|
||||||
<mat-icon>{{ data.icon }}</mat-icon>
|
<mat-icon>{{ data.icon }}</mat-icon>
|
||||||
@@ -22,3 +23,5 @@
|
|||||||
{{ data.confirmText || 'Confirm' }}
|
{{ data.confirmText || 'Confirm' }}
|
||||||
</button>
|
</button>
|
||||||
</mat-dialog-actions>
|
</mat-dialog-actions>
|
||||||
|
|
||||||
|
</div>
|
||||||
@@ -57,7 +57,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
|
|||||||
@if (showPageSizeSelector()) {
|
@if (showPageSizeSelector()) {
|
||||||
<mat-form-field class="page-size-selector" appearance="outline">
|
<mat-form-field class="page-size-selector" appearance="outline">
|
||||||
<mat-select
|
<mat-select
|
||||||
[value]="state()!.pageSize"
|
[value]="state()!.itemsPerPage"
|
||||||
(selectionChange)="onPageSizeChange($event.value)"
|
(selectionChange)="onPageSizeChange($event.value)"
|
||||||
aria-label="Items per page">
|
aria-label="Items per page">
|
||||||
@for (option of pageSizeOptions(); track option) {
|
@for (option of pageSizeOptions(); track option) {
|
||||||
@@ -74,7 +74,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
|
|||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="onPageChange(1)"
|
(click)="onPageChange(1)"
|
||||||
[disabled]="!state()!.hasPrev"
|
[disabled]="!state()!.hasPreviousPage"
|
||||||
matTooltip="First page"
|
matTooltip="First page"
|
||||||
aria-label="Go to first page">
|
aria-label="Go to first page">
|
||||||
<mat-icon>first_page</mat-icon>
|
<mat-icon>first_page</mat-icon>
|
||||||
@@ -85,7 +85,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
|
|||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="onPageChange(state()!.currentPage - 1)"
|
(click)="onPageChange(state()!.currentPage - 1)"
|
||||||
[disabled]="!state()!.hasPrev"
|
[disabled]="!state()!.hasPreviousPage"
|
||||||
matTooltip="Previous page"
|
matTooltip="Previous page"
|
||||||
aria-label="Go to previous page">
|
aria-label="Go to previous page">
|
||||||
<mat-icon>chevron_left</mat-icon>
|
<mat-icon>chevron_left</mat-icon>
|
||||||
@@ -114,7 +114,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
|
|||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="onPageChange(state()!.currentPage + 1)"
|
(click)="onPageChange(state()!.currentPage + 1)"
|
||||||
[disabled]="!state()!.hasNext"
|
[disabled]="!state()!.hasNextPage"
|
||||||
matTooltip="Next page"
|
matTooltip="Next page"
|
||||||
aria-label="Go to next page">
|
aria-label="Go to next page">
|
||||||
<mat-icon>chevron_right</mat-icon>
|
<mat-icon>chevron_right</mat-icon>
|
||||||
@@ -125,7 +125,7 @@ import { PaginationState } from '../../../core/services/pagination.service';
|
|||||||
<button
|
<button
|
||||||
mat-icon-button
|
mat-icon-button
|
||||||
(click)="onPageChange(state()!.totalPages)"
|
(click)="onPageChange(state()!.totalPages)"
|
||||||
[disabled]="!state()!.hasNext"
|
[disabled]="!state()!.hasNextPage"
|
||||||
matTooltip="Last page"
|
matTooltip="Last page"
|
||||||
aria-label="Go to last page">
|
aria-label="Go to last page">
|
||||||
<mat-icon>last_page</mat-icon>
|
<mat-icon>last_page</mat-icon>
|
||||||
|
|||||||
Reference in New Issue
Block a user