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