first commit
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
<div class="admin-category-list-container">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<div class="header-title">
|
||||
<h1>Manage Categories</h1>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="createCategory()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="60"></mat-spinner>
|
||||
<p>Loading categories...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !isLoading()) {
|
||||
<div class="error-container">
|
||||
<mat-icon class="error-icon">error_outline</mat-icon>
|
||||
<h2>Failed to load categories</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="retry()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Categories Table -->
|
||||
@if (!isLoading() && !error()) {
|
||||
@if (categories().length === 0) {
|
||||
<div class="empty-container">
|
||||
<mat-icon class="empty-icon">folder_open</mat-icon>
|
||||
<h2>No Categories Yet</h2>
|
||||
<p>Create your first category to get started.</p>
|
||||
<button mat-raised-button color="primary" (click)="createCategory()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Category
|
||||
</button>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="categories()" class="categories-table">
|
||||
|
||||
<!-- Icon Column -->
|
||||
<ng-container matColumnDef="icon">
|
||||
<th mat-header-cell *matHeaderCellDef>Icon</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
<div
|
||||
class="category-icon-cell"
|
||||
[style.background-color]="category.color || '#2196F3'">
|
||||
<mat-icon>{{ category.icon || 'category' }}</mat-icon>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Name Column -->
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
<div class="category-name">
|
||||
<strong>{{ category.name }}</strong>
|
||||
<span class="category-description">{{ category.description }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Slug Column -->
|
||||
<ng-container matColumnDef="slug">
|
||||
<th mat-header-cell *matHeaderCellDef>Slug</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
<code>{{ category.slug }}</code>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Question Count Column -->
|
||||
<ng-container matColumnDef="questionCount">
|
||||
<th mat-header-cell *matHeaderCellDef>Questions</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
<mat-chip>{{ category.questionCount || 0 }}</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Guest Accessible Column -->
|
||||
<ng-container matColumnDef="guestAccessible">
|
||||
<th mat-header-cell *matHeaderCellDef>Access</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
@if (category.guestAccessible) {
|
||||
<mat-chip class="access-chip guest">
|
||||
<mat-icon>public</mat-icon>
|
||||
Guest
|
||||
</mat-chip>
|
||||
} @else {
|
||||
<mat-chip class="access-chip auth">
|
||||
<mat-icon>lock</mat-icon>
|
||||
Auth
|
||||
</mat-chip>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Display Order Column -->
|
||||
<ng-container matColumnDef="displayOrder">
|
||||
<th mat-header-cell *matHeaderCellDef>Order</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
{{ category.displayOrder ?? '-' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let category">
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
mat-icon-button
|
||||
color="primary"
|
||||
(click)="editCategory(category)"
|
||||
matTooltip="Edit category"
|
||||
aria-label="Edit category">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button
|
||||
mat-icon-button
|
||||
color="warn"
|
||||
(click)="deleteCategory(category)"
|
||||
matTooltip="Delete category"
|
||||
aria-label="Delete category">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,236 @@
|
||||
.admin-category-list-container {
|
||||
max-width: 1400px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
|
||||
mat-card {
|
||||
mat-card-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: 20px;
|
||||
|
||||
p {
|
||||
font-size: 16px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Table Container
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
|
||||
.categories-table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.category-icon-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.category-name {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.category-description {
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
&.access-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.guest {
|
||||
background-color: rgba(76, 175, 80, 0.1) !important;
|
||||
color: #4CAF50 !important;
|
||||
}
|
||||
|
||||
&.auth {
|
||||
background-color: rgba(255, 152, 0, 0.1) !important;
|
||||
color: #FF9800 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Responsive table
|
||||
@media (max-width: 960px) {
|
||||
// Hide less important columns on smaller screens
|
||||
th:nth-child(3),
|
||||
td:nth-child(3),
|
||||
th:nth-child(6),
|
||||
td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
th:nth-child(4),
|
||||
td:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-category-list-container {
|
||||
.loading-container p,
|
||||
.error-container p,
|
||||
.empty-container p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.categories-table {
|
||||
.category-name .category-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component, inject, OnInit, OnDestroy, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { CategoryService } from '../../../core/services/category.service';
|
||||
import { Category } from '../../../core/models/category.model';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-admin-category-list',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDialogModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
templateUrl: './admin-category-list.html',
|
||||
styleUrls: ['./admin-category-list.scss']
|
||||
})
|
||||
export class AdminCategoryListComponent implements OnInit, OnDestroy {
|
||||
private categoryService = inject(CategoryService);
|
||||
private router = inject(Router);
|
||||
private dialog = inject(MatDialog);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
categories = this.categoryService.categories;
|
||||
isLoading = this.categoryService.isLoading;
|
||||
error = this.categoryService.error;
|
||||
|
||||
displayedColumns = ['icon', 'name', 'slug', 'questionCount', 'guestAccessible', 'displayOrder', 'actions'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
loadCategories(): void {
|
||||
this.categoryService.getCategories(true)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
createCategory(): void {
|
||||
this.router.navigate(['/admin/categories/new']);
|
||||
}
|
||||
|
||||
editCategory(category: Category): void {
|
||||
this.router.navigate(['/admin/categories/edit', category.id]);
|
||||
}
|
||||
|
||||
deleteCategory(category: Category): void {
|
||||
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
|
||||
width: '450px',
|
||||
data: {
|
||||
title: 'Delete Category',
|
||||
message: `Are you sure you want to delete "${category.name}"?`,
|
||||
warning: category.questionCount > 0
|
||||
? `This category has ${category.questionCount} question(s). Deleting it may affect existing quizzes.`
|
||||
: null,
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
confirmColor: 'warn'
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(confirmed => {
|
||||
if (confirmed) {
|
||||
this.performDelete(category);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private performDelete(category: Category): void {
|
||||
this.categoryService.deleteCategory(category.id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// Category is automatically removed from state by the service
|
||||
// Toast notification is also handled by the service
|
||||
},
|
||||
error: () => {
|
||||
// Error toast is handled by the service
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
retry(): void {
|
||||
this.loadCategories();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
<div class="admin-dashboard">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<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"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date Range Filter -->
|
||||
<mat-card class="filter-card">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="dateRangeForm" class="date-filter">
|
||||
<h3>
|
||||
<mat-icon>date_range</mat-icon>
|
||||
Filter by Date Range
|
||||
</h3>
|
||||
<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>
|
||||
<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>
|
||||
<mat-datepicker #endPicker></mat-datepicker>
|
||||
</mat-form-field>
|
||||
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<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>
|
||||
}
|
||||
|
||||
<!-- 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()!.users.inactiveLast7Days) {
|
||||
<p class="stat-detail">
|
||||
+{{ stats()!.users.inactiveLast7Days }} 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()!.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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,511 @@
|
||||
.admin-dashboard {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
// Header
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.header-content {
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1a237e;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover:not([disabled]) mat-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Date Filter Card
|
||||
.filter-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
mat-card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.date-filter {
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
|
||||
.date-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 56px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-card {
|
||||
margin-bottom: 2rem;
|
||||
border-left: 4px solid #f44336;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 4rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics Grid
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #4caf50;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.users-card .stat-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
&.active-card .stat-icon {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
&.quizzes-card .stat-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
&.questions-card .stat-icon {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average Score Card
|
||||
.score-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
mat-card-header {
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 2rem;
|
||||
|
||||
.score-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
|
||||
.score-circle {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
|
||||
|
||||
.score-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.score-description {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
|
||||
.excellent {
|
||||
color: #4caf50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: #ff9800;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.needs-improvement {
|
||||
color: #f44336;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chart Cards
|
||||
.chart-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
mat-card-header {
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 1.5rem;
|
||||
|
||||
.chart-container {
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
|
||||
&.line-chart path {
|
||||
transition: stroke-dashoffset 1s ease;
|
||||
stroke-dasharray: 2000;
|
||||
stroke-dashoffset: 2000;
|
||||
animation: drawLine 2s ease forwards;
|
||||
}
|
||||
|
||||
&.bar-chart rect {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
text {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Actions
|
||||
.quick-actions {
|
||||
margin-top: 3rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
height: 60px;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
margin-top: 2rem;
|
||||
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 5rem;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
color: #bdbdbd;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
padding: 1rem;
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-card .date-filter .date-inputs {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card mat-card-content .chart-container {
|
||||
svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions .actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-dashboard {
|
||||
.dashboard-header .header-content h1 {
|
||||
color: #e3f2fd;
|
||||
}
|
||||
|
||||
.filter-card .date-filter h3,
|
||||
.chart-card mat-card-title,
|
||||
.score-card mat-card-title,
|
||||
.quick-actions h2 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stats-grid .stat-card {
|
||||
mat-card-content .stat-info {
|
||||
h3 {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state mat-card-content h3 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatNativeDateModule } from '@angular/material/core';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { AdminStatistics } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* AdminDashboardComponent
|
||||
*
|
||||
* Main landing page for administrators featuring:
|
||||
* - System-wide statistics cards (users, quizzes, questions)
|
||||
* - User growth line chart
|
||||
* - Popular categories bar chart
|
||||
* - Average quiz scores display
|
||||
* - Date range filtering
|
||||
* - Responsive layout with loading skeletons
|
||||
*
|
||||
* Features:
|
||||
* - Real-time statistics with 5-min caching
|
||||
* - Interactive charts (using SVG for simplicity)
|
||||
* - Date range picker for filtering
|
||||
* - Auto-refresh capability
|
||||
* - Mobile-responsive grid layout
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-dashboard',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDatepickerModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatNativeDateModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
templateUrl: './admin-dashboard.component.html',
|
||||
styleUrls: ['./admin-dashboard.component.scss']
|
||||
})
|
||||
export class AdminDashboardComponent implements OnInit, OnDestroy {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
// State from service
|
||||
readonly stats = this.adminService.adminStatsState;
|
||||
readonly isLoading = this.adminService.isLoadingStats;
|
||||
readonly error = this.adminService.statsError;
|
||||
readonly dateFilter = this.adminService.dateRangeFilter;
|
||||
|
||||
// Date range form
|
||||
readonly dateRangeForm = new FormGroup({
|
||||
startDate: new FormControl<Date | null>(null),
|
||||
endDate: new FormControl<Date | null>(null)
|
||||
});
|
||||
|
||||
// Computed values for cards
|
||||
readonly totalUsers = this.adminService.totalUsers;
|
||||
readonly activeUsers = this.adminService.activeUsers;
|
||||
readonly totalQuizSessions = this.adminService.totalQuizSessions;
|
||||
readonly totalQuestions = this.adminService.totalQuestions;
|
||||
readonly averageScore = this.adminService.averageScore;
|
||||
|
||||
// Chart data computed signals
|
||||
readonly userGrowthData = computed(() => this.stats()?.userGrowth ?? []);
|
||||
readonly popularCategories = computed(() => this.stats()?.popularCategories ?? []);
|
||||
readonly hasDateFilter = computed(() => {
|
||||
const filter = this.dateFilter();
|
||||
return filter.startDate !== null && filter.endDate !== null;
|
||||
});
|
||||
|
||||
// Chart dimensions
|
||||
readonly chartWidth = 800;
|
||||
readonly chartHeight = 300;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadStatistics();
|
||||
this.setupDateRangeListener();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load statistics from service
|
||||
*/
|
||||
private loadStatistics(): void {
|
||||
this.adminService.getStatistics()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
console.error('Failed to load admin statistics:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup date range form listener
|
||||
*/
|
||||
private setupDateRangeListener(): void {
|
||||
this.dateRangeForm.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(value => {
|
||||
if (value.startDate && value.endDate) {
|
||||
this.applyDateFilter();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply date range filter
|
||||
*/
|
||||
applyDateFilter(): void {
|
||||
const startDate = this.dateRangeForm.value.startDate;
|
||||
const endDate = this.dateRangeForm.value.endDate;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startDate > endDate) {
|
||||
alert('Start date must be before end date');
|
||||
return;
|
||||
}
|
||||
|
||||
this.adminService.getStatisticsWithDateRange(startDate, endDate)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear date filter and reload all-time stats
|
||||
*/
|
||||
clearDateFilter(): void {
|
||||
this.dateRangeForm.reset();
|
||||
this.adminService.clearDateFilter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh statistics (force reload)
|
||||
*/
|
||||
refreshStats(): void {
|
||||
this.adminService.refreshStatistics()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max count from user growth data
|
||||
*/
|
||||
getMaxUserCount(): number {
|
||||
const data = this.userGrowthData();
|
||||
if (data.length === 0) return 1;
|
||||
return Math.max(...data.map(d => d.newUsers), 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Y coordinate for a data point
|
||||
*/
|
||||
calculateChartY(count: number, index: number): number {
|
||||
const maxCount = this.getMaxUserCount();
|
||||
const height = this.chartHeight;
|
||||
const padding = 40;
|
||||
const plotHeight = height - 2 * padding;
|
||||
return height - padding - (count / maxCount) * plotHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate X coordinate for a data point
|
||||
*/
|
||||
calculateChartX(index: number, totalPoints: number): number {
|
||||
const width = this.chartWidth;
|
||||
const padding = 40;
|
||||
const plotWidth = width - 2 * padding;
|
||||
return padding + (index / (totalPoints - 1)) * plotWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG path for user growth line chart
|
||||
*/
|
||||
getUserGrowthPath(): string {
|
||||
const data = this.userGrowthData();
|
||||
if (data.length === 0) return '';
|
||||
|
||||
const maxCount = Math.max(...data.map(d => d.newUsers), 1);
|
||||
const width = this.chartWidth;
|
||||
const height = this.chartHeight;
|
||||
const padding = 40;
|
||||
const plotWidth = width - 2 * padding;
|
||||
const plotHeight = height - 2 * padding;
|
||||
|
||||
const points = data.map((d, i) => {
|
||||
const x = padding + (i / (data.length - 1)) * plotWidth;
|
||||
const y = height - padding - (d.newUsers / maxCount) * plotHeight;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
return `M ${points.join(' L ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bar chart data for popular categories
|
||||
*/
|
||||
getCategoryBars(): Array<{ x: number; y: number; width: number; height: number; label: string; value: number }> {
|
||||
const categories = this.popularCategories();
|
||||
if (categories.length === 0) return [];
|
||||
|
||||
const maxCount = Math.max(...categories.map(c => c.quizCount), 1);
|
||||
const width = this.chartWidth;
|
||||
const height = this.chartHeight;
|
||||
const padding = 40;
|
||||
const plotWidth = width - 2 * padding;
|
||||
const plotHeight = height - 2 * padding;
|
||||
const barWidth = plotWidth / categories.length - 10;
|
||||
|
||||
return categories.map((cat, i) => {
|
||||
const barHeight = (cat.quizCount / maxCount) * plotHeight;
|
||||
return {
|
||||
x: padding + i * (plotWidth / categories.length) + 5,
|
||||
y: height - padding - barHeight,
|
||||
width: barWidth,
|
||||
height: barHeight,
|
||||
label: cat.name,
|
||||
value: cat.quizCount
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage
|
||||
*/
|
||||
formatPercentage(num: number): string {
|
||||
return `${num.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to user management
|
||||
*/
|
||||
goToUsers(): void {
|
||||
this.router.navigate(['/admin/users']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to question management
|
||||
*/
|
||||
goToQuestions(): void {
|
||||
this.router.navigate(['/admin/questions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to analytics
|
||||
*/
|
||||
goToAnalytics(): void {
|
||||
this.router.navigate(['/admin/analytics']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to settings
|
||||
*/
|
||||
goToSettings(): void {
|
||||
this.router.navigate(['/admin/settings']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
<div class="question-form-container">
|
||||
<!-- Header -->
|
||||
<div class="form-header">
|
||||
@if (isEditMode()) {
|
||||
<h1>
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit Question
|
||||
</h1>
|
||||
<p class="subtitle">Update the details below to modify the quiz question</p>
|
||||
@if (questionId()) {
|
||||
<p class="question-id">Question ID: {{ questionId() }}</p>
|
||||
}
|
||||
} @else {
|
||||
<h1>
|
||||
<mat-icon>add_circle</mat-icon>
|
||||
Create New Question
|
||||
</h1>
|
||||
<p class="subtitle">Fill in the details below to create a new quiz question</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="form-layout">
|
||||
<!-- Loading State -->
|
||||
@if (isLoadingQuestion()) {
|
||||
<mat-card class="form-card loading-card">
|
||||
<mat-card-content>
|
||||
<div class="loading-container">
|
||||
<mat-icon class="loading-icon">hourglass_empty</mat-icon>
|
||||
<p>Loading question data...</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} @else {
|
||||
<!-- Form Section -->
|
||||
<mat-card class="form-card">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="questionForm" (ngSubmit)="onSubmit()">
|
||||
<!-- Form-level Error -->
|
||||
@if (getFormError()) {
|
||||
<div class="form-error">
|
||||
<mat-icon>error</mat-icon>
|
||||
<span>{{ getFormError() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Question Text -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Question Text</mat-label>
|
||||
<textarea matInput formControlName="questionText" placeholder="Enter your question here..." rows="4"
|
||||
required>
|
||||
</textarea>
|
||||
<mat-hint>Minimum 10 characters</mat-hint>
|
||||
@if (getErrorMessage('questionText')) {
|
||||
<mat-error>{{ getErrorMessage('questionText') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Question Type & Category Row -->
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Question Type</mat-label>
|
||||
<mat-select formControlName="questionType" required>
|
||||
@for (type of questionTypes; track type.value) {
|
||||
<mat-option [value]="type.value">
|
||||
{{ type.label }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (getErrorMessage('questionType')) {
|
||||
<mat-error>{{ getErrorMessage('questionType') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Category</mat-label>
|
||||
<mat-select formControlName="categoryId" required>
|
||||
@if (isLoadingCategories()) {
|
||||
<mat-option disabled>Loading categories...</mat-option>
|
||||
} @else {
|
||||
@for (category of categories(); track category.id) {
|
||||
<mat-option [value]="category.id">
|
||||
{{ category.name }}
|
||||
</mat-option>
|
||||
}
|
||||
}
|
||||
</mat-select>
|
||||
@if (getErrorMessage('categoryId')) {
|
||||
<mat-error>{{ getErrorMessage('categoryId') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty & Points Row -->
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Difficulty</mat-label>
|
||||
<mat-select formControlName="difficulty" required>
|
||||
@for (level of difficultyLevels; track level.value) {
|
||||
<mat-option [value]="level.value">
|
||||
{{ level.label }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (getErrorMessage('difficulty')) {
|
||||
<mat-error>{{ getErrorMessage('difficulty') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Points</mat-label>
|
||||
<input matInput type="number" formControlName="points" min="1" max="100" placeholder="10" required>
|
||||
<mat-hint>Between 1 and 100</mat-hint>
|
||||
@if (getErrorMessage('points')) {
|
||||
<mat-error>{{ getErrorMessage('points') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<!-- Multiple Choice Options -->
|
||||
@if (showOptions()) {
|
||||
<div class="options-section">
|
||||
<h3>
|
||||
<mat-icon>list</mat-icon>
|
||||
Answer Options
|
||||
</h3>
|
||||
|
||||
<div formArrayName="options" class="options-list">
|
||||
@for (option of optionsArray.controls; track $index) {
|
||||
<div [formGroupName]="$index" class="option-row">
|
||||
<span class="option-label">Option {{ $index + 1 }}</span>
|
||||
<mat-form-field appearance="outline" class="option-input">
|
||||
<input matInput formControlName="text" [placeholder]="'Enter option ' + ($index + 1)" required>
|
||||
</mat-form-field>
|
||||
@if (optionsArray.length > 2) {
|
||||
<button mat-icon-button type="button" color="warn" (click)="removeOption($index)"
|
||||
matTooltip="Remove option">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (optionsArray.length < 10) { <button mat-stroked-button type="button" (click)="addOption()"
|
||||
class="add-option-btn">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add Option
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<!-- Correct Answer Selection -->
|
||||
<div class="correct-answer-section">
|
||||
<h3>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Correct Answer
|
||||
</h3>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Select Correct Answer</mat-label>
|
||||
<mat-select formControlName="correctAnswer" required>
|
||||
@for (optionText of getOptionTexts(); track $index) {
|
||||
<mat-option [value]="optionText">
|
||||
{{ optionText }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (getErrorMessage('correctAnswer')) {
|
||||
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- True/False Options -->
|
||||
@if (showTrueFalse()) {
|
||||
<div class="correct-answer-section">
|
||||
<h3>
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
Correct Answer
|
||||
</h3>
|
||||
<mat-radio-group formControlName="correctAnswer" class="radio-group">
|
||||
<mat-radio-button value="true">True</mat-radio-button>
|
||||
<mat-radio-button value="false">False</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Written Answer -->
|
||||
@if (selectedQuestionType() === 'written') {
|
||||
<div class="correct-answer-section">
|
||||
<h3>
|
||||
<mat-icon>edit</mat-icon>
|
||||
Sample Correct Answer
|
||||
</h3>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Expected Answer</mat-label>
|
||||
<textarea matInput formControlName="correctAnswer" placeholder="Enter a sample correct answer..." rows="3"
|
||||
required>
|
||||
</textarea>
|
||||
<mat-hint>This is a reference answer for grading</mat-hint>
|
||||
@if (getErrorMessage('correctAnswer')) {
|
||||
<mat-error>{{ getErrorMessage('correctAnswer') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
}
|
||||
|
||||
<mat-divider></mat-divider>
|
||||
|
||||
<!-- Explanation -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Explanation</mat-label>
|
||||
<textarea matInput formControlName="explanation" placeholder="Explain why this is the correct answer..."
|
||||
rows="4" required>
|
||||
</textarea>
|
||||
<mat-hint>Minimum 10 characters</mat-hint>
|
||||
@if (getErrorMessage('explanation')) {
|
||||
<mat-error>{{ getErrorMessage('explanation') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="tags-section">
|
||||
<h3>
|
||||
<mat-icon>label</mat-icon>
|
||||
Tags (Optional)
|
||||
</h3>
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Add Tags</mat-label>
|
||||
<mat-chip-grid #chipGrid>
|
||||
@for (tag of tagsArray; track tag) {
|
||||
<mat-chip-row (removed)="removeTag(tag)">
|
||||
{{ tag }}
|
||||
<button matChipRemove>
|
||||
<mat-icon>cancel</mat-icon>
|
||||
</button>
|
||||
</mat-chip-row>
|
||||
}
|
||||
</mat-chip-grid>
|
||||
<input placeholder="Type tag and press Enter..." [matChipInputFor]="chipGrid"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes" (matChipInputTokenEnd)="addTag($event)">
|
||||
<mat-hint>Press Enter or comma to add tags</mat-hint>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Accessibility Checkboxes -->
|
||||
<div class="checkbox-group">
|
||||
<mat-checkbox formControlName="isPublic">
|
||||
Make question public
|
||||
</mat-checkbox>
|
||||
<mat-checkbox formControlName="isGuestAccessible">
|
||||
Allow guest access
|
||||
</mat-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="form-actions">
|
||||
<button mat-button type="button" (click)="onCancel()">
|
||||
<mat-icon>close</mat-icon>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button mat-raised-button color="primary" type="submit"
|
||||
[disabled]="!isFormValid() || isSubmitting() || isLoadingQuestion()">
|
||||
@if (isSubmitting()) {
|
||||
<ng-container>
|
||||
<mat-icon>hourglass_empty</mat-icon>
|
||||
<span>{{ isEditMode() ? 'Updating...' : 'Creating...' }}</span>
|
||||
</ng-container>
|
||||
} @else {
|
||||
<ng-container>
|
||||
<mat-icon>save</mat-icon>
|
||||
<span>{{ isEditMode() ? 'Update Question' : 'Save Question' }}</span>
|
||||
</ng-container>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Preview Panel -->
|
||||
<mat-card class="preview-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>visibility</mat-icon>
|
||||
Preview
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="preview-content">
|
||||
<!-- Question Preview -->
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Question:</div>
|
||||
<div class="preview-text">
|
||||
{{ questionForm.get('questionText')?.value || 'Your question will appear here...' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type & Difficulty -->
|
||||
<div class="preview-meta">
|
||||
<span class="preview-badge type-badge">
|
||||
{{ questionForm.get('questionType')?.value | titlecase }}
|
||||
</span>
|
||||
<span class="preview-badge difficulty-badge"
|
||||
[class]="'difficulty-' + questionForm.get('difficulty')?.value">
|
||||
{{ questionForm.get('difficulty')?.value | titlecase }}
|
||||
</span>
|
||||
<span class="preview-badge points-badge">
|
||||
{{ questionForm.get('points')?.value || 10 }} Points
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Options Preview (MCQ) -->
|
||||
@if (showOptions() && getOptionTexts().length > 0) {
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Options:</div>
|
||||
<div class="preview-options">
|
||||
@for (optionText of getOptionTexts(); track $index) {
|
||||
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === optionText">
|
||||
<mat-icon>{{ questionForm.get('correctAnswer')?.value === optionText ? 'check_circle' :
|
||||
'radio_button_unchecked' }}</mat-icon>
|
||||
<span>{{ optionText }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- True/False Preview -->
|
||||
@if (showTrueFalse()) {
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Options:</div>
|
||||
<div class="preview-options">
|
||||
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'true'">
|
||||
<mat-icon>{{ questionForm.get('correctAnswer')?.value === 'true' ? 'check_circle' :
|
||||
'radio_button_unchecked' }}</mat-icon>
|
||||
<span>True</span>
|
||||
</div>
|
||||
<div class="preview-option" [class.correct]="questionForm.get('correctAnswer')?.value === 'false'">
|
||||
<mat-icon>{{ questionForm.get('correctAnswer')?.value === 'false' ? 'check_circle' :
|
||||
'radio_button_unchecked' }}</mat-icon>
|
||||
<span>False</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Explanation Preview -->
|
||||
@if (questionForm.get('explanation')?.value) {
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Explanation:</div>
|
||||
<div class="preview-explanation">
|
||||
{{ questionForm.get('explanation')?.value }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Tags Preview -->
|
||||
@if (tagsArray.length > 0) {
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Tags:</div>
|
||||
<div class="preview-tags">
|
||||
@for (tag of tagsArray; track tag) {
|
||||
<span class="preview-tag">{{ tag }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Accessibility Preview -->
|
||||
<div class="preview-section">
|
||||
<div class="preview-label">Access:</div>
|
||||
<div class="preview-access">
|
||||
@if (questionForm.get('isPublic')?.value) {
|
||||
<span class="access-badge public">Public</span>
|
||||
} @else {
|
||||
<span class="access-badge private">Private</span>
|
||||
}
|
||||
@if (questionForm.get('isGuestAccessible')?.value) {
|
||||
<span class="access-badge guest">Guest Accessible</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,535 @@
|
||||
.question-form-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Header
|
||||
// ===========================
|
||||
.form-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
}
|
||||
|
||||
.question-id {
|
||||
margin: 8px 0 0 0;
|
||||
padding: 6px 12px;
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Layout
|
||||
// ===========================
|
||||
.form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 24px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-card,
|
||||
.preview-card {
|
||||
height: fit-content;
|
||||
|
||||
mat-card-content {
|
||||
padding: 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
position: static;
|
||||
order: -1;
|
||||
}
|
||||
|
||||
mat-card-header {
|
||||
padding: 16px 24px 0;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Form Elements
|
||||
// ===========================
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.half-width {
|
||||
width: calc(50% - 8px);
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
mat-divider {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Form Sections
|
||||
// ===========================
|
||||
.options-section,
|
||||
.correct-answer-section,
|
||||
.tags-section {
|
||||
margin-bottom: 24px;
|
||||
|
||||
h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
|
||||
mat-icon {
|
||||
font-size: 22px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Options List
|
||||
// ===========================
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.option-label {
|
||||
min-width: 70px;
|
||||
font-weight: 500;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
}
|
||||
|
||||
.option-input {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.option-label {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.option-input {
|
||||
width: calc(100% - 48px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-option-btn {
|
||||
width: 100%;
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Radio Group
|
||||
// ===========================
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
mat-radio-button {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Checkbox Group
|
||||
// ===========================
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Form Actions
|
||||
// ===========================
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--mat-app-outline-variant, #e0e0e0);
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Form Error
|
||||
// ===========================
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
border-left: 4px solid var(--mat-warn-main, #f44336);
|
||||
border-radius: 4px;
|
||||
color: var(--mat-warn-dark, #d32f2f);
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Preview Panel
|
||||
// ===========================
|
||||
.preview-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.preview-section {
|
||||
.preview-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.preview-text {
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.preview-explanation {
|
||||
padding: 12px;
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border-left: 3px solid var(--mat-app-primary, #1976d2);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.type-badge {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
}
|
||||
|
||||
&.difficulty-badge {
|
||||
&.difficulty-easy {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.difficulty-medium {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
&.difficulty-hard {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
color: #f44336;
|
||||
}
|
||||
}
|
||||
|
||||
&.points-badge {
|
||||
background-color: rgba(156, 39, 176, 0.1);
|
||||
color: #9c27b0;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--mat-app-surface-variant, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
|
||||
&.correct {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
|
||||
mat-icon {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
span {
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.preview-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background-color: var(--mat-app-surface-variant, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
|
||||
.preview-access {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.access-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
&.public {
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #4caf50;
|
||||
border: 1px solid rgba(76, 175, 80, 0.3);
|
||||
}
|
||||
|
||||
&.private {
|
||||
background-color: rgba(158, 158, 158, 0.1);
|
||||
color: #9e9e9e;
|
||||
border: 1px solid rgba(158, 158, 158, 0.3);
|
||||
}
|
||||
|
||||
&.guest {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Loading State
|
||||
// ===========================
|
||||
.loading-card {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
|
||||
.loading-icon {
|
||||
font-size: 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dark Mode Support
|
||||
// ===========================
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.preview-option {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
|
||||
&.correct {
|
||||
background-color: rgba(76, 175, 80, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.preview-explanation {
|
||||
background-color: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
|
||||
.preview-tag,
|
||||
.access-badge.private {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
background-color: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, FormArray, Validators, ReactiveFormsModule, AbstractControl, ValidationErrors } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule, MatChipInputEvent } from '@angular/material/chips';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { CategoryService } from '../../../core/services/category.service';
|
||||
import { Question, QuestionFormData } from '../../../core/models/question.model';
|
||||
import { QuestionType, Difficulty } from '../../../core/models/category.model';
|
||||
|
||||
/**
|
||||
* AdminQuestionFormComponent
|
||||
*
|
||||
* Comprehensive form for creating new quiz questions.
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic form based on question type
|
||||
* - Real-time validation
|
||||
* - Question preview panel
|
||||
* - Tag input with chips
|
||||
* - Dynamic options for MCQ
|
||||
* - Correct answer validation
|
||||
* - Category selection
|
||||
* - Difficulty levels
|
||||
* - Guest accessibility toggle
|
||||
*
|
||||
* Question Types:
|
||||
* - Multiple Choice: Radio options with dynamic add/remove
|
||||
* - True/False: Pre-defined boolean options
|
||||
* - Written: Text-based answer
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-question-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatCheckboxModule,
|
||||
MatRadioModule,
|
||||
MatDividerModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
templateUrl: './admin-question-form.component.html',
|
||||
styleUrl: './admin-question-form.component.scss'
|
||||
})
|
||||
export class AdminQuestionFormComponent implements OnInit {
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
|
||||
// Form state
|
||||
questionForm!: FormGroup;
|
||||
isSubmitting = signal(false);
|
||||
isEditMode = signal(false);
|
||||
questionId = signal<string | null>(null);
|
||||
isLoadingQuestion = signal(false);
|
||||
|
||||
// Categories from service
|
||||
readonly categories = this.categoryService.categories;
|
||||
readonly isLoadingCategories = this.categoryService.isLoading;
|
||||
|
||||
// Chip input config
|
||||
readonly separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
|
||||
// Available options
|
||||
readonly questionTypes = [
|
||||
{ value: 'multiple', label: 'Multiple Choice' },
|
||||
{ value: 'trueFalse', label: 'True/False' },
|
||||
{ value: 'written', label: 'Written Answer' }
|
||||
];
|
||||
|
||||
readonly difficultyLevels = [
|
||||
{ value: 'easy', label: 'Easy' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'hard', label: 'Hard' }
|
||||
];
|
||||
|
||||
// Computed properties
|
||||
readonly selectedQuestionType = computed(() => {
|
||||
return this.questionForm?.get('questionType')?.value as QuestionType;
|
||||
});
|
||||
|
||||
readonly showOptions = computed(() => {
|
||||
const type = this.selectedQuestionType();
|
||||
return type === 'multiple';
|
||||
});
|
||||
|
||||
readonly showTrueFalse = computed(() => {
|
||||
const type = this.selectedQuestionType();
|
||||
return type === 'trueFalse';
|
||||
});
|
||||
|
||||
readonly isFormValid = computed(() => {
|
||||
return this.questionForm?.valid ?? false;
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize form
|
||||
this.initializeForm();
|
||||
|
||||
// Load categories
|
||||
this.categoryService.getCategories().subscribe();
|
||||
|
||||
// Check if we're in edit mode
|
||||
this.route.params.subscribe(params => {
|
||||
const id = params['id'];
|
||||
if (id) {
|
||||
// Defer signal updates to avoid ExpressionChangedAfterItHasBeenCheckedError
|
||||
setTimeout(() => {
|
||||
this.isEditMode.set(true);
|
||||
this.questionId.set(id);
|
||||
this.loadQuestion(id);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for question type changes
|
||||
this.questionForm.get('questionType')?.valueChanges.subscribe((type: QuestionType) => {
|
||||
this.onQuestionTypeChange(type);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing question data
|
||||
*/
|
||||
private loadQuestion(id: string): void {
|
||||
this.isLoadingQuestion.set(true);
|
||||
|
||||
this.adminService.getQuestion(id).subscribe({
|
||||
next: (response) => {
|
||||
this.isLoadingQuestion.set(false);
|
||||
this.populateForm(response.data);
|
||||
},
|
||||
error: (error) => {
|
||||
this.isLoadingQuestion.set(false);
|
||||
console.error('Error loading question:', error);
|
||||
// Redirect back if question not found
|
||||
this.router.navigate(['/admin/questions']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate form with existing question data
|
||||
*/
|
||||
private populateForm(question: Question): void {
|
||||
// Clear existing options
|
||||
this.optionsArray.clear();
|
||||
|
||||
// Populate basic fields
|
||||
this.questionForm.patchValue({
|
||||
questionText: question.questionText,
|
||||
questionType: question.questionType,
|
||||
categoryId: question.categoryId,
|
||||
difficulty: question.difficulty,
|
||||
correctAnswer: Array.isArray(question.correctAnswer) ? question.correctAnswer[0] : question.correctAnswer,
|
||||
explanation: question.explanation,
|
||||
points: question.points,
|
||||
tags: question.tags || [],
|
||||
isPublic: question.isPublic,
|
||||
isGuestAccessible: question.isPublic // Map isPublic to isGuestAccessible
|
||||
});
|
||||
|
||||
// Populate options for multiple choice
|
||||
if (question.questionType === 'multiple' && question.options) {
|
||||
question.options.forEach((option: string | { text: string, id: string }) => {
|
||||
this.optionsArray.push(this.createOption(option));
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger question type change to update form state
|
||||
this.onQuestionTypeChange(question.questionType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize form with all fields
|
||||
*/
|
||||
private initializeForm(): void {
|
||||
this.questionForm = this.fb.group({
|
||||
questionText: ['', [Validators.required, Validators.minLength(10)]],
|
||||
questionType: ['multiple', Validators.required],
|
||||
categoryId: ['', Validators.required],
|
||||
difficulty: ['medium', Validators.required],
|
||||
options: this.fb.array([
|
||||
this.createOption(''),
|
||||
this.createOption(''),
|
||||
this.createOption(''),
|
||||
this.createOption('')
|
||||
]),
|
||||
correctAnswer: ['', Validators.required],
|
||||
explanation: ['', [Validators.required, Validators.minLength(10)]],
|
||||
points: [10, [Validators.required, Validators.min(1), Validators.max(100)]],
|
||||
tags: [[] as string[]],
|
||||
isPublic: [true],
|
||||
isGuestAccessible: [false]
|
||||
});
|
||||
|
||||
// Add custom validator for correct answer
|
||||
this.questionForm.setValidators(this.correctAnswerValidator.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create option form control
|
||||
*/
|
||||
private createOption(value: string | { text: string, id: string } = ''): FormGroup {
|
||||
return this.fb.group({
|
||||
text: [value, Validators.required]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get options form array
|
||||
*/
|
||||
get optionsArray(): FormArray {
|
||||
return this.questionForm.get('options') as FormArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tags array
|
||||
*/
|
||||
get tagsArray(): string[] {
|
||||
return this.questionForm.get('tags')?.value || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle question type change
|
||||
*/
|
||||
private onQuestionTypeChange(type: QuestionType): void {
|
||||
const correctAnswerControl = this.questionForm.get('correctAnswer');
|
||||
|
||||
if (type === 'multiple') {
|
||||
// Ensure at least 2 options
|
||||
while (this.optionsArray.length < 2) {
|
||||
this.addOption();
|
||||
}
|
||||
correctAnswerControl?.setValidators([Validators.required]);
|
||||
} else if (type === 'trueFalse') {
|
||||
// Clear options for True/False
|
||||
this.optionsArray.clear();
|
||||
correctAnswerControl?.setValidators([Validators.required]);
|
||||
// Set default to True if empty
|
||||
if (!correctAnswerControl?.value) {
|
||||
correctAnswerControl?.setValue('true');
|
||||
}
|
||||
} else {
|
||||
// Written answer
|
||||
this.optionsArray.clear();
|
||||
correctAnswerControl?.setValidators([Validators.required, Validators.minLength(1)]);
|
||||
}
|
||||
|
||||
correctAnswerControl?.updateValueAndValidity();
|
||||
this.questionForm.updateValueAndValidity();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new option
|
||||
*/
|
||||
addOption(): void {
|
||||
if (this.optionsArray.length < 10) {
|
||||
this.optionsArray.push(this.createOption(''));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove option at index
|
||||
*/
|
||||
removeOption(index: number): void {
|
||||
if (this.optionsArray.length > 2) {
|
||||
this.optionsArray.removeAt(index);
|
||||
|
||||
// Clear correct answer if it matches the removed option
|
||||
const correctAnswer = this.questionForm.get('correctAnswer')?.value;
|
||||
const removedOption = this.optionsArray.at(index)?.get('text')?.value;
|
||||
if (correctAnswer === removedOption) {
|
||||
this.questionForm.get('correctAnswer')?.setValue('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tag
|
||||
*/
|
||||
addTag(event: MatChipInputEvent): void {
|
||||
const value = (event.value || '').trim();
|
||||
const tags = this.tagsArray;
|
||||
|
||||
if (value && !tags.includes(value)) {
|
||||
this.questionForm.get('tags')?.setValue([...tags, value]);
|
||||
}
|
||||
|
||||
event.chipInput!.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tag
|
||||
*/
|
||||
removeTag(tag: string): void {
|
||||
const tags = this.tagsArray;
|
||||
const index = tags.indexOf(tag);
|
||||
|
||||
if (index >= 0) {
|
||||
tags.splice(index, 1);
|
||||
this.questionForm.get('tags')?.setValue([...tags]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom validator for correct answer
|
||||
*/
|
||||
private correctAnswerValidator(control: AbstractControl): ValidationErrors | null {
|
||||
const formGroup = control as FormGroup;
|
||||
const questionType = formGroup.get('questionType')?.value;
|
||||
const correctAnswer = formGroup.get('correctAnswer')?.value;
|
||||
const options = formGroup.get('options') as FormArray;
|
||||
|
||||
if (questionType === 'multiple' && correctAnswer && options) {
|
||||
const optionTexts = options.controls.map(opt => opt.get('text')?.value);
|
||||
const isValid = optionTexts.includes(correctAnswer);
|
||||
|
||||
if (!isValid) {
|
||||
return { correctAnswerMismatch: true };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get option text values
|
||||
*/
|
||||
getOptionTexts(): string[] {
|
||||
return this.optionsArray.controls.map(opt => opt.get('text')?.value).filter(text => text.trim() !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form
|
||||
*/
|
||||
onSubmit(): void {
|
||||
if (this.questionForm.invalid || this.isSubmitting()) {
|
||||
this.markFormGroupTouched(this.questionForm);
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
|
||||
const formValue = this.questionForm.value;
|
||||
const questionData: QuestionFormData = {
|
||||
questionText: formValue.questionText,
|
||||
questionType: formValue.questionType,
|
||||
difficulty: formValue.difficulty,
|
||||
categoryId: formValue.categoryId,
|
||||
correctAnswer: formValue.correctAnswer,
|
||||
explanation: formValue.explanation,
|
||||
points: formValue.points || 10,
|
||||
tags: formValue.tags || [],
|
||||
isPublic: formValue.isPublic,
|
||||
isGuestAccessible: formValue.isGuestAccessible
|
||||
};
|
||||
|
||||
// Add options for multiple choice
|
||||
if (formValue.questionType === 'multiple') {
|
||||
questionData.options = this.getOptionTexts();
|
||||
}
|
||||
|
||||
// Determine if create or update
|
||||
const serviceCall = this.isEditMode() && this.questionId()
|
||||
? this.adminService.updateQuestion(this.questionId()!, questionData)
|
||||
: this.adminService.createQuestion(questionData);
|
||||
|
||||
serviceCall.subscribe({
|
||||
next: (response) => {
|
||||
this.isSubmitting.set(false);
|
||||
this.router.navigate(['/admin/questions']);
|
||||
},
|
||||
error: (error) => {
|
||||
this.isSubmitting.set(false);
|
||||
console.error(`Error ${this.isEditMode() ? 'updating' : 'creating'} question:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and go back
|
||||
*/
|
||||
onCancel(): void {
|
||||
this.router.navigate(['/admin/questions']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark all fields as touched to show validation errors
|
||||
*/
|
||||
private markFormGroupTouched(formGroup: FormGroup | FormArray): void {
|
||||
Object.keys(formGroup.controls).forEach(key => {
|
||||
const control = formGroup.get(key);
|
||||
control?.markAsTouched();
|
||||
|
||||
if (control instanceof FormGroup || control instanceof FormArray) {
|
||||
this.markFormGroupTouched(control);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for field
|
||||
*/
|
||||
getErrorMessage(fieldName: string): string {
|
||||
const control = this.questionForm.get(fieldName);
|
||||
|
||||
if (!control || !control.errors || !control.touched) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (control.errors['required']) {
|
||||
return `${this.getFieldLabel(fieldName)} is required`;
|
||||
}
|
||||
if (control.errors['minlength']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['minlength'].requiredLength} characters`;
|
||||
}
|
||||
if (control.errors['min']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at least ${control.errors['min'].min}`;
|
||||
}
|
||||
if (control.errors['max']) {
|
||||
return `${this.getFieldLabel(fieldName)} must be at most ${control.errors['max'].max}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get field label for error messages
|
||||
*/
|
||||
private getFieldLabel(fieldName: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
questionText: 'Question text',
|
||||
questionType: 'Question type',
|
||||
categoryId: 'Category',
|
||||
difficulty: 'Difficulty',
|
||||
correctAnswer: 'Correct answer',
|
||||
explanation: 'Explanation',
|
||||
points: 'Points'
|
||||
};
|
||||
return labels[fieldName] || fieldName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form-level error message
|
||||
*/
|
||||
getFormError(): string | null {
|
||||
if (this.questionForm.errors?.['correctAnswerMismatch']) {
|
||||
return 'Correct answer must match one of the options';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
<div class="admin-questions-container">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-content">
|
||||
<div class="title-section">
|
||||
<mat-icon class="header-icon">quiz</mat-icon>
|
||||
<div>
|
||||
<h1>Question Management</h1>
|
||||
<p class="subtitle">Create, edit, and manage quiz questions</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="createQuestion()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create Question
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Card -->
|
||||
<mat-card class="filters-card">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="filterForm" class="filters-form">
|
||||
<!-- Search -->
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search Questions</mat-label>
|
||||
<input matInput formControlName="search" placeholder="Search by question text...">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Category Filter -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Category</mat-label>
|
||||
<mat-select formControlName="category">
|
||||
<mat-option value="all">All Categories</mat-option>
|
||||
@for (category of categories(); track category.id) {
|
||||
<mat-option [value]="category.id">{{ category.name }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Difficulty Filter -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Difficulty</mat-label>
|
||||
<mat-select formControlName="difficulty">
|
||||
<mat-option value="all">All Difficulties</mat-option>
|
||||
<mat-option value="easy">Easy</mat-option>
|
||||
<mat-option value="medium">Medium</mat-option>
|
||||
<mat-option value="hard">Hard</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Type Filter -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select formControlName="type">
|
||||
<mat-option value="all">All Types</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>
|
||||
|
||||
<!-- Sort By -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sort By</mat-label>
|
||||
<mat-select formControlName="sortBy">
|
||||
<mat-option value="createdAt">Date Created</mat-option>
|
||||
<mat-option value="questionText">Question Text</mat-option>
|
||||
<mat-option value="difficulty">Difficulty</mat-option>
|
||||
<mat-option value="points">Points</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Order</mat-label>
|
||||
<mat-select formControlName="sortOrder">
|
||||
<mat-option value="asc">Ascending</mat-option>
|
||||
<mat-option value="desc">Descending</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Results Card -->
|
||||
<mat-card class="results-card">
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="50"></mat-spinner>
|
||||
<p>Loading questions...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@else if (error()) {
|
||||
<div class="error-container">
|
||||
<mat-icon color="warn">error</mat-icon>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="loadQuestions()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@else if (questions().length === 0) {
|
||||
<div class="empty-container">
|
||||
<mat-icon>quiz</mat-icon>
|
||||
<h3>No Questions Found</h3>
|
||||
<p>No questions match your current filters. Try adjusting your search criteria.</p>
|
||||
<button mat-raised-button color="primary" (click)="createQuestion()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Create First Question
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Questions Table (Desktop) -->
|
||||
@else {
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="questions()" class="questions-table">
|
||||
<!-- Question Text Column -->
|
||||
<ng-container matColumnDef="questionText">
|
||||
<th mat-header-cell *matHeaderCellDef>Question</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<div class="question-text-cell">
|
||||
{{ question.questionText.substring(0, 100) }}{{ question.questionText.length > 100 ? '...' : '' }}
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Type Column -->
|
||||
<ng-container matColumnDef="type">
|
||||
<th mat-header-cell *matHeaderCellDef>Type</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<mat-chip>
|
||||
@if (question.questionType === 'multiple') {
|
||||
<mat-icon>radio_button_checked</mat-icon>
|
||||
<span class="px-5"> MCQ</span>
|
||||
} @else if (question.questionType === 'trueFalse') {
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<span> T/F</span>
|
||||
} @else {
|
||||
<mat-icon>edit_note</mat-icon>
|
||||
<span> Written</span>
|
||||
}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Category Column -->
|
||||
<ng-container matColumnDef="category">
|
||||
<th mat-header-cell *matHeaderCellDef>Category</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
{{ getCategoryName(question) }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Difficulty Column -->
|
||||
<ng-container matColumnDef="difficulty">
|
||||
<th mat-header-cell *matHeaderCellDef>Difficulty</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<mat-chip [color]="getDifficultyColor(question.difficulty)">
|
||||
{{ question.difficulty }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Points Column -->
|
||||
<ng-container matColumnDef="points">
|
||||
<th mat-header-cell *matHeaderCellDef>Points</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<span class="points-badge">{{ question.points }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<mat-chip [color]="getStatusColor(question.isActive)">
|
||||
{{ question.isActive ? 'Active' : 'Inactive' }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let question">
|
||||
<div class="action-buttons">
|
||||
<button mat-icon-button color="primary"
|
||||
(click)="editQuestion(question)"
|
||||
matTooltip="Edit Question">
|
||||
<mat-icon>edit</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button color="warn"
|
||||
(click)="deleteQuestion(question)"
|
||||
matTooltip="Delete Question">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<app-pagination
|
||||
[state]="paginationState()"
|
||||
[pageNumbers]="pageNumbers()"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
[showFirstLast]="true"
|
||||
[itemLabel]="'questions'"
|
||||
(pageChange)="goToPage($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)">
|
||||
</app-pagination>
|
||||
}
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,341 @@
|
||||
.admin-questions-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Page Header
|
||||
.page-header {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.header-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
height: 42px;
|
||||
padding: 0 1.5rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters Card
|
||||
.filters-card {
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
.filters-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
grid-column: span 2;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Results Card
|
||||
.results-card {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 4rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 5rem;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Questions Table
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin: -1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.questions-table {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 1rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
tr {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--hover-background);
|
||||
}
|
||||
}
|
||||
|
||||
.question-text-cell {
|
||||
max-width: 400px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.points-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
|
||||
button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin-top: 1rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
button {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
|
||||
&.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-questions-container {
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-disabled: #606060;
|
||||
--divider-color: #404040;
|
||||
--hover-background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.questions-table {
|
||||
tr:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light Mode Support
|
||||
@media (prefers-color-scheme: light) {
|
||||
.admin-questions-container {
|
||||
--text-primary: #212121;
|
||||
--text-secondary: #757575;
|
||||
--text-disabled: #bdbdbd;
|
||||
--divider-color: #e0e0e0;
|
||||
--hover-background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.questions-table {
|
||||
tr:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { CategoryService } from '../../../core/services/category.service';
|
||||
import { Question } from '../../../core/models/question.model';
|
||||
import { Category } from '../../../core/models/category.model';
|
||||
import { DeleteConfirmDialogComponent } from '../delete-confirm-dialog/delete-confirm-dialog.component';
|
||||
import { PaginationService, PaginationState } from '../../../core/services/pagination.service';
|
||||
import { PaginationComponent } from '../../../shared/components/pagination/pagination.component';
|
||||
|
||||
/**
|
||||
* AdminQuestionsComponent
|
||||
*
|
||||
* Displays and manages all questions with pagination, filtering, and sorting.
|
||||
*
|
||||
* Features:
|
||||
* - Question table with key columns
|
||||
* - Search by question text
|
||||
* - Filter by category, difficulty, and type
|
||||
* - Sort by various fields
|
||||
* - Pagination controls
|
||||
* - Action buttons (Edit, Delete, View)
|
||||
* - Delete confirmation dialog
|
||||
* - Responsive design (cards on mobile)
|
||||
* - Loading and error states
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-questions',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatChipsModule,
|
||||
MatMenuModule,
|
||||
MatDialogModule,
|
||||
PaginationComponent
|
||||
],
|
||||
templateUrl: './admin-questions.component.html',
|
||||
styleUrl: './admin-questions.component.scss'
|
||||
})
|
||||
export class AdminQuestionsComponent implements OnInit {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly categoryService = inject(CategoryService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly paginationService = inject(PaginationService);
|
||||
|
||||
// State signals
|
||||
readonly questions = signal<Question[]>([]);
|
||||
readonly isLoading = signal<boolean>(false);
|
||||
readonly error = signal<string | null>(null);
|
||||
readonly categories = this.categoryService.categories;
|
||||
|
||||
// Pagination
|
||||
readonly currentPage = signal<number>(1);
|
||||
readonly pageSize = signal<number>(10);
|
||||
readonly totalQuestions = signal<number>(0);
|
||||
readonly totalPages = computed(() => Math.ceil(this.totalQuestions() / this.pageSize()));
|
||||
|
||||
// Computed pagination state for reusable component
|
||||
readonly paginationState = computed<PaginationState>(() => {
|
||||
return this.paginationService.calculatePaginationState({
|
||||
currentPage: this.currentPage(),
|
||||
pageSize: this.pageSize(),
|
||||
totalItems: this.totalQuestions()
|
||||
});
|
||||
});
|
||||
|
||||
// Computed page numbers
|
||||
readonly pageNumbers = computed(() => {
|
||||
return this.paginationService.calculatePageNumbers(
|
||||
this.currentPage(),
|
||||
this.totalPages(),
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = ['questionText', 'type', 'category', 'difficulty', 'points', 'status', 'actions'];
|
||||
|
||||
// Filter form
|
||||
filterForm!: FormGroup;
|
||||
|
||||
// Expose Math for template
|
||||
Math = Math;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeFilterForm();
|
||||
this.setupSearchDebounce();
|
||||
this.loadCategories();
|
||||
this.loadQuestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize filter form
|
||||
*/
|
||||
private initializeFilterForm(): void {
|
||||
this.filterForm = this.fb.group({
|
||||
search: [''],
|
||||
category: ['all'],
|
||||
difficulty: ['all'],
|
||||
type: ['all'],
|
||||
sortBy: ['createdAt'],
|
||||
sortOrder: ['desc']
|
||||
});
|
||||
|
||||
// Subscribe to filter changes (except search which is debounced)
|
||||
this.filterForm.valueChanges
|
||||
.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.currentPage.set(1);
|
||||
this.loadQuestions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup search field debounce
|
||||
*/
|
||||
private setupSearchDebounce(): void {
|
||||
this.filterForm.get('search')?.valueChanges
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.currentPage.set(1);
|
||||
this.loadQuestions();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load categories for filter dropdown
|
||||
*/
|
||||
private loadCategories(): void {
|
||||
if (this.categories().length === 0) {
|
||||
this.categoryService.getCategories().subscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load questions with current filters
|
||||
*/
|
||||
loadQuestions(): void {
|
||||
this.isLoading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const filters = this.filterForm.value;
|
||||
const params: any = {
|
||||
page: this.currentPage(),
|
||||
limit: this.pageSize(),
|
||||
search: filters.search || undefined,
|
||||
category: filters.category !== 'all' ? filters.category : undefined,
|
||||
difficulty: filters.difficulty !== 'all' ? filters.difficulty : undefined,
|
||||
sortBy: filters.sortBy,
|
||||
order: filters.sortOrder
|
||||
};
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
|
||||
|
||||
this.adminService.getAllQuestions(params)
|
||||
.pipe(finalize(() => this.isLoading.set(false)))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.questions.set(response.data);
|
||||
this.totalQuestions.set(response.total);
|
||||
this.currentPage.set(response.page);
|
||||
},
|
||||
error: (error) => {
|
||||
this.error.set(error.message || 'Failed to load questions');
|
||||
this.questions.set([]);
|
||||
this.totalQuestions.set(0);
|
||||
console.error('Load questions error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to create question page
|
||||
*/
|
||||
createQuestion(): void {
|
||||
this.router.navigate(['/admin/questions/new']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to edit question page
|
||||
*/
|
||||
editQuestion(question: Question): void {
|
||||
this.router.navigate(['/admin/questions', question.id, 'edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open delete confirmation dialog
|
||||
*/
|
||||
deleteQuestion(question: Question): void {
|
||||
const dialogRef = this.dialog.open(DeleteConfirmDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
title: 'Delete Question',
|
||||
message: 'Are you sure you want to delete this question? This action cannot be undone.',
|
||||
itemName: question.questionText.substring(0, 100) + (question.questionText.length > 100 ? '...' : ''),
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel'
|
||||
}
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(confirmed => {
|
||||
if (confirmed && question.id) {
|
||||
this.performDelete(question.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform delete operation
|
||||
*/
|
||||
private performDelete(id: string): void {
|
||||
this.isLoading.set(true);
|
||||
|
||||
this.adminService.deleteQuestion(id)
|
||||
.pipe(finalize(() => this.isLoading.set(false)))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// Reload questions after deletion
|
||||
this.loadQuestions();
|
||||
},
|
||||
error: (error) => {
|
||||
this.error.set('Failed to delete question');
|
||||
console.error('Delete error:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category name from question
|
||||
* The API returns a nested category object with the question
|
||||
*/
|
||||
getCategoryName(question: Question): string {
|
||||
// First try to get from nested category object (API response)
|
||||
if (question.category?.name) {
|
||||
return question.category.name;
|
||||
}
|
||||
|
||||
// Fallback: try to find by categoryId in loaded categories
|
||||
if (question.categoryId) {
|
||||
const category = this.categories().find(
|
||||
c => c.id === question.categoryId || c.id === question.categoryId.toString()
|
||||
);
|
||||
if (category) {
|
||||
return category.name;
|
||||
}
|
||||
}
|
||||
|
||||
// Last fallback: use categoryName property if available
|
||||
return question.categoryName || 'Unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status chip color
|
||||
*/
|
||||
getStatusColor(isActive: boolean): string {
|
||||
return isActive ? 'primary' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get difficulty chip color
|
||||
*/
|
||||
getDifficultyColor(difficulty: string): string {
|
||||
switch (difficulty.toLowerCase()) {
|
||||
case 'easy':
|
||||
return 'primary';
|
||||
case 'medium':
|
||||
return 'accent';
|
||||
case 'hard':
|
||||
return 'warn';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Go to specific page
|
||||
*/
|
||||
goToPage(page: number): void {
|
||||
if (page >= 1 && page <= this.totalPages()) {
|
||||
this.currentPage.set(page);
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle page size change
|
||||
*/
|
||||
onPageSizeChange(pageSize: number): void {
|
||||
this.pageSize.set(pageSize);
|
||||
this.currentPage.set(1); // Reset to first page
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
<div class="admin-user-detail-container">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="goBack()" class="back-button" aria-label="Go back to users list">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1 class="page-title">User Details</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button mat-icon-button (click)="refreshUser()" [disabled]="isLoading()"
|
||||
matTooltip="Refresh user details" aria-label="Refresh">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="breadcrumb" aria-label="Breadcrumb navigation">
|
||||
<a routerLink="/admin" class="breadcrumb-link">Admin</a>
|
||||
<mat-icon class="breadcrumb-separator">chevron_right</mat-icon>
|
||||
<a routerLink="/admin/users" class="breadcrumb-link">Users</a>
|
||||
<mat-icon class="breadcrumb-separator">chevron_right</mat-icon>
|
||||
<span class="breadcrumb-current">{{ user()?.username || 'User Detail' }}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="48"></mat-spinner>
|
||||
<p class="loading-text">Loading user details...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !isLoading()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<div class="error-content">
|
||||
<mat-icon class="error-icon">error</mat-icon>
|
||||
<h2>Error Loading User</h2>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="goBack()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back to Users
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- User Detail Content -->
|
||||
@if (user() && !isLoading()) {
|
||||
<div class="detail-content">
|
||||
<!-- User Profile Card -->
|
||||
<mat-card class="profile-card">
|
||||
<mat-card-header>
|
||||
<div class="profile-header">
|
||||
<div class="user-avatar">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<h2 class="user-name">{{ user()!.username }}</h2>
|
||||
<p class="user-email">{{ user()!.email }}</p>
|
||||
<div class="user-badges">
|
||||
<mat-chip [class]="'chip-' + getRoleColor(user()!.role)">
|
||||
<mat-icon>{{ user()!.role === 'admin' ? 'admin_panel_settings' : 'person' }}</mat-icon>
|
||||
{{ user()!.role | titlecase }}
|
||||
</mat-chip>
|
||||
<mat-chip [class]="'chip-' + getStatusColor(user()!.isActive)">
|
||||
<mat-icon>{{ user()!.isActive ? 'check_circle' : 'cancel' }}</mat-icon>
|
||||
{{ user()!.isActive ? 'Active' : 'Inactive' }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="profile-details">
|
||||
<div class="detail-row">
|
||||
<mat-icon>event</mat-icon>
|
||||
<div class="detail-info">
|
||||
<span class="detail-label">Member Since</span>
|
||||
<span class="detail-value">{{ memberSince() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
<div class="detail-info">
|
||||
<span class="detail-label">Last Active</span>
|
||||
<span class="detail-value">{{ lastActive() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@if (user()!.metadata?.registrationMethod) {
|
||||
<div class="detail-row">
|
||||
<mat-icon>how_to_reg</mat-icon>
|
||||
<div class="detail-info">
|
||||
<span class="detail-label">Registration Method</span>
|
||||
<span class="detail-value">{{ user()!.metadata!.registrationMethod === 'guest_conversion' ? 'Guest Conversion' : 'Direct' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions class="profile-actions">
|
||||
<button mat-raised-button color="primary" (click)="editUserRole()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit Role
|
||||
</button>
|
||||
<button mat-raised-button [color]="user()!.isActive ? 'warn' : 'accent'" (click)="toggleUserStatus()">
|
||||
<mat-icon>{{ user()!.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||
{{ user()!.isActive ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="stats-grid">
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon primary">
|
||||
<mat-icon>quiz</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ formatNumber(user()!.statistics.totalQuizzes) }}</h3>
|
||||
<p class="stat-label">Total Quizzes</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon success">
|
||||
<mat-icon>grade</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ user()!.statistics.averageScore.toFixed(1) }}%</h3>
|
||||
<p class="stat-label">Average Score</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon accent">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ user()!.statistics.accuracy.toFixed(1) }}%</h3>
|
||||
<p class="stat-label">Accuracy</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon warn">
|
||||
<mat-icon>local_fire_department</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ user()!.statistics.currentStreak }}</h3>
|
||||
<p class="stat-label">Current Streak</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon primary">
|
||||
<mat-icon>help_outline</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ formatNumber(user()!.statistics.totalQuestionsAnswered) }}</h3>
|
||||
<p class="stat-label">Questions Answered</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon success">
|
||||
<mat-icon>timer</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3 class="stat-value">{{ formatDuration(user()!.statistics.totalTimeSpent) }}</h3>
|
||||
<p class="stat-label">Time Spent</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Additional Stats Card -->
|
||||
<mat-card class="additional-stats-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>analytics</mat-icon>
|
||||
Additional Statistics
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="stats-details">
|
||||
<div class="stat-detail-row">
|
||||
<span class="stat-detail-label">Correct Answers:</span>
|
||||
<span class="stat-detail-value">{{ formatNumber(user()!.statistics.correctAnswers) }}</span>
|
||||
</div>
|
||||
<div class="stat-detail-row">
|
||||
<span class="stat-detail-label">Longest Streak:</span>
|
||||
<span class="stat-detail-value">{{ user()!.statistics.longestStreak }} days</span>
|
||||
</div>
|
||||
@if (user()!.statistics.favoriteCategory) {
|
||||
<div class="stat-detail-row">
|
||||
<span class="stat-detail-label">Favorite Category:</span>
|
||||
<span class="stat-detail-value">
|
||||
{{ user()!.statistics.favoriteCategory!.name }}
|
||||
({{ user()!.statistics.favoriteCategory!.quizCount }} quizzes)
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<div class="stat-detail-row">
|
||||
<span class="stat-detail-label">Quizzes This Week:</span>
|
||||
<span class="stat-detail-value">{{ user()!.statistics.recentActivity.quizzesThisWeek }}</span>
|
||||
</div>
|
||||
<div class="stat-detail-row">
|
||||
<span class="stat-detail-label">Quizzes This Month:</span>
|
||||
<span class="stat-detail-value">{{ user()!.statistics.recentActivity.quizzesThisMonth }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Quiz History -->
|
||||
<mat-card class="quiz-history-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>history</mat-icon>
|
||||
Quiz History
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
@if (hasQuizHistory()) {
|
||||
<div class="quiz-history-list">
|
||||
@for (quiz of user()!.quizHistory; track quiz.id) {
|
||||
<div class="quiz-history-item">
|
||||
<div class="quiz-history-header">
|
||||
<div class="quiz-category">
|
||||
<mat-icon>category</mat-icon>
|
||||
<span>{{ quiz.categoryName }}</span>
|
||||
</div>
|
||||
<div class="quiz-date">{{ formatDateTime(quiz.completedAt) }}</div>
|
||||
</div>
|
||||
<div class="quiz-history-stats">
|
||||
<div class="quiz-stat">
|
||||
<mat-icon [class]="'score-icon-' + getScoreColor(quiz.percentage)">grade</mat-icon>
|
||||
<span class="quiz-stat-label">Score:</span>
|
||||
<span [class]="'quiz-stat-value-' + getScoreColor(quiz.percentage)">
|
||||
{{ quiz.score }}/{{ quiz.totalQuestions }} ({{ quiz.percentage.toFixed(1) }}%)
|
||||
</span>
|
||||
</div>
|
||||
<div class="quiz-stat">
|
||||
<mat-icon>timer</mat-icon>
|
||||
<span class="quiz-stat-label">Time:</span>
|
||||
<span class="quiz-stat-value">{{ formatDuration(quiz.timeTaken) }}</span>
|
||||
</div>
|
||||
<button mat-icon-button (click)="viewQuizDetails(quiz.id)"
|
||||
matTooltip="View quiz details" class="quiz-action-btn">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<mat-icon>quiz</mat-icon>
|
||||
<p>No quiz history available</p>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Activity Timeline -->
|
||||
<mat-card class="activity-timeline-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>timeline</mat-icon>
|
||||
Activity Timeline
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
@if (hasActivity()) {
|
||||
<mat-list class="activity-list">
|
||||
@for (activity of user()!.activityTimeline; track activity.id) {
|
||||
<mat-list-item class="activity-item">
|
||||
<mat-icon [class]="'activity-icon-' + getActivityColor(activity.type)" matListItemIcon>
|
||||
{{ getActivityIcon(activity.type) }}
|
||||
</mat-icon>
|
||||
<div matListItemTitle class="activity-description">{{ activity.description }}</div>
|
||||
<div matListItemLine class="activity-time">{{ formatRelativeTime(activity.timestamp) }}</div>
|
||||
@if (activity.metadata) {
|
||||
<div matListItemLine class="activity-metadata">
|
||||
@if (activity.metadata.categoryName) {
|
||||
<span class="metadata-item">
|
||||
<mat-icon>category</mat-icon>
|
||||
{{ activity.metadata.categoryName }}
|
||||
</span>
|
||||
}
|
||||
@if (activity.metadata.score !== undefined) {
|
||||
<span class="metadata-item">
|
||||
<mat-icon>grade</mat-icon>
|
||||
{{ activity.metadata.score }}%
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-list-item>
|
||||
<mat-divider></mat-divider>
|
||||
}
|
||||
</mat-list>
|
||||
} @else {
|
||||
<div class="empty-state">
|
||||
<mat-icon>timeline</mat-icon>
|
||||
<p>No activity recorded</p>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,752 @@
|
||||
.admin-user-detail-container {
|
||||
padding: 24px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Page Header
|
||||
// ===========================
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Breadcrumb
|
||||
// ===========================
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb-separator {
|
||||
font-size: 18px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Loading State
|
||||
// ===========================
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 64px 24px;
|
||||
gap: 16px;
|
||||
|
||||
.loading-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Error State
|
||||
// ===========================
|
||||
.error-card {
|
||||
margin-top: 24px;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
|
||||
.error-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--error-color);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 24px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Detail Content
|
||||
// ===========================
|
||||
.detail-content {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Profile Card
|
||||
// ===========================
|
||||
.profile-card {
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
|
||||
.user-avatar {
|
||||
mat-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
|
||||
.user-name {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-email {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.user-badges {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
mat-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
&.chip-primary {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.chip-warn {
|
||||
background-color: var(--warn-light);
|
||||
color: var(--warn-color);
|
||||
}
|
||||
|
||||
&.chip-success {
|
||||
background-color: var(--success-light);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
&.chip-default {
|
||||
background-color: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-details {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
> mat-icon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.detail-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Statistics Grid
|
||||
// ===========================
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
|
||||
.stat-card {
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
|
||||
}
|
||||
|
||||
&.success {
|
||||
background: linear-gradient(135deg, var(--success-color), var(--success-dark));
|
||||
}
|
||||
|
||||
&.accent {
|
||||
background: linear-gradient(135deg, var(--accent-color), var(--accent-dark));
|
||||
}
|
||||
|
||||
&.warn {
|
||||
background: linear-gradient(135deg, var(--warn-color), var(--warn-dark));
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
.stat-value {
|
||||
margin: 0 0 4px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Additional Stats Card
|
||||
// ===========================
|
||||
.additional-stats-card {
|
||||
mat-card-header {
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.stat-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
|
||||
.stat-detail-label {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-detail-value {
|
||||
font-size: 16px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Quiz History Card
|
||||
// ===========================
|
||||
.quiz-history-card {
|
||||
mat-card-header {
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.quiz-history-item {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--divider-color);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.quiz-history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.quiz-category {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-date {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-history-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.quiz-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
&.score-icon-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
&.score-icon-primary {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.score-icon-accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
&.score-icon-warn {
|
||||
color: var(--warn-color);
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-stat-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quiz-stat-value {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
|
||||
&.quiz-stat-value-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
&.quiz-stat-value-primary {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.quiz-stat-value-accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
&.quiz-stat-value-warn {
|
||||
color: var(--warn-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-action-btn {
|
||||
margin-left: auto;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Activity Timeline Card
|
||||
// ===========================
|
||||
.activity-timeline-card {
|
||||
mat-card-header {
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
padding: 0;
|
||||
|
||||
.activity-item {
|
||||
padding: 16px 0;
|
||||
|
||||
.activity-description {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.activity-metadata {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
|
||||
.metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-icon[matListItemIcon] {
|
||||
&.activity-icon-primary {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.activity-icon-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
&.activity-icon-accent {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
&.activity-icon-warn {
|
||||
color: var(--warn-color);
|
||||
}
|
||||
|
||||
&.activity-icon-default {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Empty State
|
||||
// ===========================
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
color: var(--text-disabled);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Responsive Design
|
||||
// ===========================
|
||||
|
||||
// Tablet (768px - 1023px)
|
||||
@media (max-width: 1023px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.profile-card .profile-header {
|
||||
.user-avatar mat-icon {
|
||||
font-size: 64px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.user-info .user-name {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile (< 768px)
|
||||
@media (max-width: 767px) {
|
||||
.admin-user-detail-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
|
||||
.user-avatar mat-icon {
|
||||
font-size: 56px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
width: 100%;
|
||||
|
||||
.user-name {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card mat-card-content {
|
||||
padding: 16px;
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quiz-history-item {
|
||||
.quiz-history-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.quiz-history-stats {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.quiz-action-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dark Mode Support
|
||||
// ===========================
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.admin-user-detail-container {
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-disabled: #606060;
|
||||
--bg-primary: #1e1e1e;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--divider-color: #404040;
|
||||
}
|
||||
|
||||
.quiz-history-item:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { Component, OnInit, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { AdminUserDetail } from '../../../core/models/admin.model';
|
||||
import { RoleUpdateDialogComponent } from '../role-update-dialog/role-update-dialog.component';
|
||||
import { StatusUpdateDialogComponent } from '../status-update-dialog/status-update-dialog.component';
|
||||
|
||||
/**
|
||||
* AdminUserDetailComponent
|
||||
*
|
||||
* Displays comprehensive user profile for admin management:
|
||||
* - User information (username, email, role, status)
|
||||
* - Statistics (quizzes, scores, accuracy, streaks)
|
||||
* - Quiz history with detailed breakdown
|
||||
* - Activity timeline showing all user actions
|
||||
* - Action buttons (Edit Role, Deactivate/Activate)
|
||||
* - Breadcrumb navigation
|
||||
*
|
||||
* Features:
|
||||
* - Signal-based reactive state
|
||||
* - Real-time loading states
|
||||
* - Error handling with user feedback
|
||||
* - Responsive design (desktop + mobile)
|
||||
* - Formatted dates and numbers
|
||||
* - Color-coded status indicators
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-user-detail',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatChipsModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatDividerModule,
|
||||
MatListModule,
|
||||
MatTooltipModule,
|
||||
MatMenuModule,
|
||||
MatDialogModule
|
||||
],
|
||||
templateUrl: './admin-user-detail.component.html',
|
||||
styleUrl: './admin-user-detail.component.scss'
|
||||
})
|
||||
export class AdminUserDetailComponent implements OnInit {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
// Expose Math for template
|
||||
Math = Math;
|
||||
|
||||
// State from service
|
||||
readonly user = this.adminService.selectedUserDetail;
|
||||
readonly isLoading = this.adminService.isLoadingUserDetail;
|
||||
readonly error = this.adminService.userDetailError;
|
||||
|
||||
// Component state
|
||||
readonly userId = signal<string>('');
|
||||
|
||||
// Computed properties
|
||||
readonly hasQuizHistory = computed(() => {
|
||||
const userDetail = this.user();
|
||||
return userDetail && userDetail.quizHistory.length > 0;
|
||||
});
|
||||
|
||||
readonly hasActivity = computed(() => {
|
||||
const userDetail = this.user();
|
||||
return userDetail && userDetail.activityTimeline.length > 0;
|
||||
});
|
||||
|
||||
readonly memberSince = computed(() => {
|
||||
const userDetail = this.user();
|
||||
if (!userDetail) return '';
|
||||
return this.formatDate(userDetail.createdAt);
|
||||
});
|
||||
|
||||
readonly lastActive = computed(() => {
|
||||
const userDetail = this.user();
|
||||
if (!userDetail || !userDetail.lastLoginAt) return 'Never';
|
||||
return this.formatRelativeTime(userDetail.lastLoginAt);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Clean up user detail when component is destroyed
|
||||
takeUntilDestroyed()(this.route.params);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Get userId from route params
|
||||
this.route.params.pipe(takeUntilDestroyed()).subscribe(params => {
|
||||
const id = params['id'];
|
||||
if (id) {
|
||||
this.userId.set(id);
|
||||
this.loadUserDetail(id);
|
||||
} else {
|
||||
this.router.navigate(['/admin/users']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load user detail from API
|
||||
*/
|
||||
private loadUserDetail(userId: string): void {
|
||||
this.adminService.getUserDetails(userId).subscribe({
|
||||
error: () => {
|
||||
// Error is handled by service
|
||||
// Navigate back after 3 seconds
|
||||
setTimeout(() => {
|
||||
this.router.navigate(['/admin/users']);
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to users list
|
||||
*/
|
||||
goBack(): void {
|
||||
this.router.navigate(['/admin/users']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh user details
|
||||
*/
|
||||
refreshUser(): void {
|
||||
const id = this.userId();
|
||||
if (id) {
|
||||
this.loadUserDetail(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit user role - Opens role update dialog
|
||||
*/
|
||||
editUserRole(): void {
|
||||
const userDetail = this.user();
|
||||
if (!userDetail) return;
|
||||
|
||||
const dialogRef = this.dialog.open(RoleUpdateDialogComponent, {
|
||||
width: '600px',
|
||||
maxWidth: '95vw',
|
||||
data: { user: userDetail },
|
||||
disableClose: false
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(newRole => {
|
||||
if (newRole && newRole !== userDetail.role) {
|
||||
this.adminService.updateUserRole(userDetail.id, newRole).subscribe({
|
||||
next: () => {
|
||||
// User detail is automatically updated in the service
|
||||
this.refreshUser();
|
||||
},
|
||||
error: () => {
|
||||
// Error is handled by service
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user active status
|
||||
*/
|
||||
toggleUserStatus(): void {
|
||||
const userDetail = this.user();
|
||||
if (!userDetail) return;
|
||||
|
||||
const action = userDetail.isActive ? 'deactivate' : 'activate';
|
||||
|
||||
// Convert AdminUserDetail to AdminUser for dialog
|
||||
const dialogData = {
|
||||
user: {
|
||||
id: userDetail.id,
|
||||
username: userDetail.username,
|
||||
email: userDetail.email,
|
||||
role: userDetail.role,
|
||||
isActive: userDetail.isActive,
|
||||
createdAt: userDetail.createdAt
|
||||
},
|
||||
action: action as 'activate' | 'deactivate'
|
||||
};
|
||||
|
||||
const dialogRef = this.dialog.open(StatusUpdateDialogComponent, {
|
||||
width: '500px',
|
||||
data: dialogData,
|
||||
disableClose: false,
|
||||
autoFocus: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe((confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
|
||||
// Call appropriate service method based on action
|
||||
const serviceCall = action === 'activate'
|
||||
? this.adminService.activateUser(userDetail.id)
|
||||
: this.adminService.deactivateUser(userDetail.id);
|
||||
|
||||
serviceCall
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// Refresh user detail to show updated status
|
||||
this.loadUserDetail(userDetail.id);
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error updating user status:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* View quiz details (navigate to quiz review)
|
||||
*/
|
||||
viewQuizDetails(quizId: string): void {
|
||||
// Navigate to quiz review page
|
||||
this.router.navigate(['/quiz', quizId, 'review']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon for activity type
|
||||
*/
|
||||
getActivityIcon(type: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
login: 'login',
|
||||
quiz_start: 'play_arrow',
|
||||
quiz_complete: 'check_circle',
|
||||
bookmark: 'bookmark',
|
||||
profile_update: 'edit',
|
||||
role_change: 'admin_panel_settings'
|
||||
};
|
||||
return icons[type] || 'info';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for activity type
|
||||
*/
|
||||
getActivityColor(type: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
login: 'primary',
|
||||
quiz_start: 'accent',
|
||||
quiz_complete: 'success',
|
||||
bookmark: 'warn',
|
||||
profile_update: 'primary',
|
||||
role_change: 'warn'
|
||||
};
|
||||
return colors[type] || 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role badge color
|
||||
*/
|
||||
getRoleColor(role: string): string {
|
||||
return role === 'admin' ? 'warn' : 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status badge color
|
||||
*/
|
||||
getStatusColor(isActive: boolean): string {
|
||||
return isActive ? 'success' : 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
formatDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
*/
|
||||
formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'Just now';
|
||||
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
|
||||
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${Math.floor(diffDays / 7) > 1 ? 's' : ''} ago`;
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} month${Math.floor(diffDays / 30) > 1 ? 's' : ''} ago`;
|
||||
return `${Math.floor(diffDays / 365)} year${Math.floor(diffDays / 365) > 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time duration in seconds to readable string
|
||||
*/
|
||||
formatDuration(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`;
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${secs}s`;
|
||||
} else {
|
||||
return `${secs}s`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format large numbers with commas
|
||||
*/
|
||||
formatNumber(num: number): string {
|
||||
return num.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get score color based on percentage
|
||||
*/
|
||||
getScoreColor(percentage: number): string {
|
||||
if (percentage >= 80) return 'success';
|
||||
if (percentage >= 60) return 'primary';
|
||||
if (percentage >= 40) return 'accent';
|
||||
return 'warn';
|
||||
}
|
||||
}
|
||||
283
src/app/features/admin/admin-users/admin-users.component.html
Normal file
283
src/app/features/admin/admin-users/admin-users.component.html
Normal file
@@ -0,0 +1,283 @@
|
||||
<div class="admin-users-container">
|
||||
<!-- Header -->
|
||||
<div class="users-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="header-title">
|
||||
<h1>User Management</h1>
|
||||
<p class="subtitle">Manage all users and their permissions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button mat-stroked-button (click)="refreshUsers()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Section -->
|
||||
<mat-card class="filters-card">
|
||||
<mat-card-content>
|
||||
<form [formGroup]="filterForm" class="filters-form">
|
||||
<!-- Search -->
|
||||
<mat-form-field appearance="outline" class="search-field">
|
||||
<mat-label>Search</mat-label>
|
||||
<input matInput formControlName="search" placeholder="Search by username or email">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Role Filter -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Role</mat-label>
|
||||
<mat-select formControlName="role" (selectionChange)="applyFilters()">
|
||||
<mat-option value="all">All Roles</mat-option>
|
||||
<mat-option value="user">User</mat-option>
|
||||
<mat-option value="admin">Admin</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matPrefix>badge</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select formControlName="isActive" (selectionChange)="applyFilters()">
|
||||
<mat-option value="all">All Status</mat-option>
|
||||
<mat-option value="active">Active</mat-option>
|
||||
<mat-option value="inactive">Inactive</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matPrefix>toggle_on</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Sort By -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Sort By</mat-label>
|
||||
<mat-select formControlName="sortBy" (selectionChange)="applyFilters()">
|
||||
<mat-option value="username">Username</mat-option>
|
||||
<mat-option value="email">Email</mat-option>
|
||||
<mat-option value="createdAt">Join Date</mat-option>
|
||||
<mat-option value="lastLoginAt">Last Login</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matPrefix>sort</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Sort Order -->
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Order</mat-label>
|
||||
<mat-select formControlName="sortOrder" (selectionChange)="applyFilters()">
|
||||
<mat-option value="asc">Ascending</mat-option>
|
||||
<mat-option value="desc">Descending</mat-option>
|
||||
</mat-select>
|
||||
<mat-icon matPrefix>swap_vert</mat-icon>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Reset Button -->
|
||||
<button mat-stroked-button type="button" (click)="resetFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Reset
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading() && users().length === 0) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="60"></mat-spinner>
|
||||
<p>Loading users...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !isLoading() && users().length === 0) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<div class="error-content">
|
||||
<mat-icon color="warn">error_outline</mat-icon>
|
||||
<div class="error-text">
|
||||
<h3>Failed to Load Users</h3>
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="refreshUsers()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Users Table (Desktop) -->
|
||||
@if (users().length > 0) {
|
||||
<mat-card class="table-card desktop-table">
|
||||
<div class="table-header">
|
||||
<h2>Users</h2>
|
||||
@if (pagination()) {
|
||||
<span class="total-count">
|
||||
Total: {{ pagination()?.totalItems }} user{{ pagination()?.totalItems !== 1 ? 's' : '' }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table mat-table [dataSource]="users()" class="users-table">
|
||||
<!-- Username Column -->
|
||||
<ng-container matColumnDef="username">
|
||||
<th mat-header-cell *matHeaderCellDef>Username</th>
|
||||
<td mat-cell *matCellDef="let user">
|
||||
<div class="username-cell">
|
||||
<mat-icon class="user-icon">account_circle</mat-icon>
|
||||
<span>{{ user.username }}</span>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Email Column -->
|
||||
<ng-container matColumnDef="email">
|
||||
<th mat-header-cell *matHeaderCellDef>Email</th>
|
||||
<td mat-cell *matCellDef="let user">{{ user.email }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Role Column -->
|
||||
<ng-container matColumnDef="role">
|
||||
<th mat-header-cell *matHeaderCellDef>Role</th>
|
||||
<td mat-cell *matCellDef="let user">
|
||||
<mat-chip [color]="getRoleColor(user.role)" highlighted>
|
||||
{{ user.role | uppercase }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Status Column -->
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let user">
|
||||
<mat-chip [color]="getStatusColor(user.isActive)" highlighted>
|
||||
{{ getStatusText(user.isActive) }}
|
||||
</mat-chip>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Joined Date Column -->
|
||||
<ng-container matColumnDef="joinedDate">
|
||||
<th mat-header-cell *matHeaderCellDef>Joined</th>
|
||||
<td mat-cell *matCellDef="let user">{{ formatDate(user.createdAt) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Last Login Column -->
|
||||
<ng-container matColumnDef="lastLogin">
|
||||
<th mat-header-cell *matHeaderCellDef>Last Login</th>
|
||||
<td mat-cell *matCellDef="let user">{{ formatDateTime(user.lastLoginAt) }}</td>
|
||||
</ng-container>
|
||||
|
||||
<!-- Actions Column -->
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef>Actions</th>
|
||||
<td mat-cell *matCellDef="let user">
|
||||
<button mat-icon-button [matMenuTriggerFor]="actionMenu">
|
||||
<mat-icon>more_vert</mat-icon>
|
||||
</button>
|
||||
<mat-menu #actionMenu="matMenu">
|
||||
<button mat-menu-item (click)="viewUserDetails(user.id)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>View Details</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="editUserRole(user)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
<span>Edit Role</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="toggleUserStatus(user)">
|
||||
<mat-icon>{{ user.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||
<span>{{ user.isActive ? 'Deactivate' : 'Activate' }}</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Users Cards (Mobile) -->
|
||||
<div class="mobile-cards">
|
||||
@for (user of users(); track user.id) {
|
||||
<mat-card class="user-card">
|
||||
<mat-card-header>
|
||||
<mat-icon mat-card-avatar class="card-avatar">account_circle</mat-icon>
|
||||
<mat-card-title>{{ user.username }}</mat-card-title>
|
||||
<mat-card-subtitle>{{ user.email }}</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="card-info">
|
||||
<div class="info-row">
|
||||
<span class="label">Role:</span>
|
||||
<mat-chip [color]="getRoleColor(user.role)" highlighted>
|
||||
{{ user.role | uppercase }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Status:</span>
|
||||
<mat-chip [color]="getStatusColor(user.isActive)" highlighted>
|
||||
{{ getStatusText(user.isActive) }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Joined:</span>
|
||||
<span>{{ formatDate(user.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="label">Last Login:</span>
|
||||
<span>{{ formatDateTime(user.lastLoginAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
<mat-card-actions>
|
||||
<button mat-button (click)="viewUserDetails(user.id)">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
View
|
||||
</button>
|
||||
<button mat-button (click)="editUserRole(user)">
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit Role
|
||||
</button>
|
||||
<button mat-button [color]="user.isActive ? 'warn' : 'primary'" (click)="toggleUserStatus(user)">
|
||||
<mat-icon>{{ user.isActive ? 'block' : 'check_circle' }}</mat-icon>
|
||||
{{ user.isActive ? 'Deactivate' : 'Activate' }}
|
||||
</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
@if (paginationState()) {
|
||||
<app-pagination
|
||||
[state]="paginationState()"
|
||||
[pageNumbers]="pageNumbers()"
|
||||
[pageSizeOptions]="[10, 25, 50, 100]"
|
||||
[showFirstLast]="true"
|
||||
[itemLabel]="'users'"
|
||||
(pageChange)="goToPage($event)"
|
||||
(pageSizeChange)="onPageSizeChange($event)">
|
||||
</app-pagination>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@if (!isLoading() && !error() && users().length === 0) {
|
||||
<mat-card class="empty-card">
|
||||
<mat-card-content>
|
||||
<mat-icon>people_outline</mat-icon>
|
||||
<h3>No Users Found</h3>
|
||||
<p>No users match your current filters.</p>
|
||||
<button mat-raised-button color="primary" (click)="resetFilters()">
|
||||
<mat-icon>clear</mat-icon>
|
||||
Clear Filters
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
466
src/app/features/admin/admin-users/admin-users.component.scss
Normal file
466
src/app/features/admin/admin-users/admin-users.component.scss
Normal file
@@ -0,0 +1,466 @@
|
||||
.admin-users-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
// Header Section
|
||||
.users-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
button mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Filters Card
|
||||
.filters-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.filters-form {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr repeat(4, 1fr) auto;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
|
||||
.search-field {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
width: 100%;
|
||||
|
||||
mat-icon[matPrefix] {
|
||||
margin-right: 0.5rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #d32f2f;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Table Card
|
||||
.table-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
|
||||
th {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
td, th {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.username-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.user-icon {
|
||||
color: #666;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.75rem;
|
||||
min-height: 24px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile Cards
|
||||
.mobile-cards {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.user-card {
|
||||
mat-card-header {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.card-avatar {
|
||||
font-size: 40px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.card-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0.25rem;
|
||||
font-size: 18px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.pagination-info {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
&.active {
|
||||
background: #3f51b5;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-card {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
|
||||
mat-card-content {
|
||||
mat-icon {
|
||||
font-size: 80px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
color: #999;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.admin-users-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.users-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.filters-card .filters-form {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
.search-field {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.desktop-table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-cards {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.pagination-info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) and (min-width: 769px) {
|
||||
.filters-card .filters-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
.search-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
button {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.users-table {
|
||||
font-size: 0.9rem;
|
||||
|
||||
td, th {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.users-table {
|
||||
th:nth-child(6), // Last Login
|
||||
td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.users-header .header-title h1 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.users-header .subtitle {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.error-card .error-content .error-text p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
.table-header {
|
||||
border-bottom-color: #444;
|
||||
|
||||
h2 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.total-count {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.users-table {
|
||||
th {
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #2a2a2a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-cards .user-card {
|
||||
mat-card-actions {
|
||||
border-top-color: #444;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-container {
|
||||
background: #1a1a1a;
|
||||
|
||||
.pagination-info {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-card mat-card-content {
|
||||
mat-icon {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
}
|
||||
400
src/app/features/admin/admin-users/admin-users.component.ts
Normal file
400
src/app/features/admin/admin-users/admin-users.component.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
import { Component, OnInit, inject, DestroyRef, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { AdminUser, UserListParams } from '../../../core/models/admin.model';
|
||||
import { RoleUpdateDialogComponent } from '../role-update-dialog/role-update-dialog.component';
|
||||
import { StatusUpdateDialogComponent } from '../status-update-dialog/status-update-dialog.component';
|
||||
import { PaginationService, PaginationState } from '../../../core/services/pagination.service';
|
||||
import { PaginationComponent } from '../../../shared/components/pagination/pagination.component';
|
||||
|
||||
/**
|
||||
* AdminUsersComponent
|
||||
*
|
||||
* Displays and manages all users with pagination, filtering, and sorting.
|
||||
*
|
||||
* Features:
|
||||
* - User table with key columns
|
||||
* - Search by username/email
|
||||
* - Filter by role and status
|
||||
* - Sort by username, email, or date
|
||||
* - Pagination controls
|
||||
* - Action buttons for each user
|
||||
* - Responsive design (cards on mobile)
|
||||
* - Loading and error states
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-admin-users',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSelectModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatChipsModule,
|
||||
MatMenuModule,
|
||||
MatDialogModule,
|
||||
PaginationComponent
|
||||
],
|
||||
templateUrl: './admin-users.component.html',
|
||||
styleUrl: './admin-users.component.scss'
|
||||
})
|
||||
export class AdminUsersComponent implements OnInit {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
private readonly paginationService = inject(PaginationService);
|
||||
|
||||
// Service signals
|
||||
readonly users = this.adminService.adminUsersState;
|
||||
readonly isLoading = this.adminService.isLoadingUsers;
|
||||
readonly error = this.adminService.usersError;
|
||||
readonly pagination = this.adminService.usersPagination;
|
||||
|
||||
// Computed pagination state for reusable component
|
||||
readonly paginationState = computed<PaginationState | null>(() => {
|
||||
const pag = this.pagination();
|
||||
if (!pag) return null;
|
||||
|
||||
return this.paginationService.calculatePaginationState({
|
||||
currentPage: pag.currentPage,
|
||||
pageSize: pag.itemsPerPage,
|
||||
totalItems: pag.totalItems
|
||||
});
|
||||
});
|
||||
|
||||
// Computed page numbers
|
||||
readonly pageNumbers = computed(() => {
|
||||
const state = this.paginationState();
|
||||
if (!state) return [];
|
||||
|
||||
return this.paginationService.calculatePageNumbers(
|
||||
state.currentPage,
|
||||
state.totalPages,
|
||||
5
|
||||
);
|
||||
});
|
||||
|
||||
// Table configuration
|
||||
displayedColumns: string[] = ['username', 'email', 'role', 'status', 'joinedDate', 'lastLogin', 'actions'];
|
||||
|
||||
// Filter form
|
||||
filterForm!: FormGroup;
|
||||
|
||||
// Current params
|
||||
currentParams: UserListParams = {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
role: 'all',
|
||||
isActive: 'all',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
search: ''
|
||||
};
|
||||
|
||||
// Expose Math for template
|
||||
Math = Math;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeFilterForm();
|
||||
this.setupSearchDebounce();
|
||||
this.loadUsersFromRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize filter form
|
||||
*/
|
||||
private initializeFilterForm(): void {
|
||||
this.filterForm = this.fb.group({
|
||||
search: [''],
|
||||
role: ['all'],
|
||||
isActive: ['all'],
|
||||
sortBy: ['createdAt'],
|
||||
sortOrder: ['desc']
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup search field debounce
|
||||
*/
|
||||
private setupSearchDebounce(): void {
|
||||
this.filterForm.get('search')?.valueChanges
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load users based on route query params
|
||||
*/
|
||||
private loadUsersFromRoute(): void {
|
||||
this.route.queryParams
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(params => {
|
||||
this.currentParams = {
|
||||
page: +(params['page'] || 1),
|
||||
limit: +(params['limit'] || 10),
|
||||
role: params['role'] || 'all',
|
||||
isActive: params['isActive'] || 'all',
|
||||
sortBy: params['sortBy'] || 'createdAt',
|
||||
sortOrder: params['sortOrder'] || 'desc',
|
||||
search: params['search'] || ''
|
||||
};
|
||||
|
||||
// Update form with current params
|
||||
this.filterForm.patchValue({
|
||||
search: this.currentParams.search,
|
||||
role: this.currentParams.role,
|
||||
isActive: this.currentParams.isActive,
|
||||
sortBy: this.currentParams.sortBy,
|
||||
sortOrder: this.currentParams.sortOrder
|
||||
}, { emitEvent: false });
|
||||
|
||||
this.loadUsers();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load users from API
|
||||
*/
|
||||
private loadUsers(): void {
|
||||
this.adminService.getUsers(this.currentParams)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters and reset to page 1
|
||||
*/
|
||||
applyFilters(): void {
|
||||
const formValue = this.filterForm.value;
|
||||
this.currentParams = {
|
||||
...this.currentParams,
|
||||
page: 1, // Reset to first page
|
||||
search: formValue.search || '',
|
||||
role: formValue.role || 'all',
|
||||
isActive: formValue.isActive || 'all',
|
||||
sortBy: formValue.sortBy || 'createdAt',
|
||||
sortOrder: formValue.sortOrder || 'desc'
|
||||
};
|
||||
|
||||
this.updateRouteParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Change page
|
||||
*/
|
||||
goToPage(page: number): void {
|
||||
if (page < 1 || page > (this.pagination()?.totalPages ?? 1)) return;
|
||||
|
||||
this.currentParams = {
|
||||
...this.currentParams,
|
||||
page
|
||||
};
|
||||
|
||||
this.updateRouteParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle page size change
|
||||
*/
|
||||
onPageSizeChange(pageSize: number): void {
|
||||
this.currentParams = {
|
||||
...this.currentParams,
|
||||
page: 1,
|
||||
limit: pageSize
|
||||
};
|
||||
|
||||
this.updateRouteParams();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route query parameters
|
||||
*/
|
||||
private updateRouteParams(): void {
|
||||
this.router.navigate([], {
|
||||
relativeTo: this.route,
|
||||
queryParams: this.currentParams,
|
||||
queryParamsHandling: 'merge'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh users list
|
||||
*/
|
||||
refreshUsers(): void {
|
||||
this.loadUsers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all filters
|
||||
*/
|
||||
resetFilters(): void {
|
||||
this.filterForm.reset({
|
||||
search: '',
|
||||
role: 'all',
|
||||
isActive: 'all',
|
||||
sortBy: 'createdAt',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
this.applyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* View user details
|
||||
*/
|
||||
viewUserDetails(userId: string): void {
|
||||
this.router.navigate(['/admin/users', userId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit user role - Opens role update dialog
|
||||
*/
|
||||
editUserRole(user: AdminUser): void {
|
||||
const dialogRef = this.dialog.open(RoleUpdateDialogComponent, {
|
||||
width: '600px',
|
||||
maxWidth: '95vw',
|
||||
data: { user },
|
||||
disableClose: false
|
||||
});
|
||||
|
||||
dialogRef.afterClosed().subscribe(newRole => {
|
||||
if (newRole && newRole !== user.role) {
|
||||
this.adminService.updateUserRole(user.id, newRole).subscribe({
|
||||
next: () => {
|
||||
// User list is automatically updated in the service
|
||||
},
|
||||
error: () => {
|
||||
// Error is handled by service
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle user active status
|
||||
*/
|
||||
toggleUserStatus(user: AdminUser): void {
|
||||
const action = user.isActive ? 'deactivate' : 'activate';
|
||||
|
||||
const dialogRef = this.dialog.open(StatusUpdateDialogComponent, {
|
||||
width: '500px',
|
||||
data: {
|
||||
user: user,
|
||||
action: action
|
||||
},
|
||||
disableClose: false,
|
||||
autoFocus: true
|
||||
});
|
||||
|
||||
dialogRef.afterClosed()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((confirmed: boolean) => {
|
||||
if (!confirmed) return;
|
||||
|
||||
// Call appropriate service method based on action
|
||||
const serviceCall = action === 'activate'
|
||||
? this.adminService.activateUser(user.id)
|
||||
: this.adminService.deactivateUser(user.id);
|
||||
|
||||
serviceCall
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
// Signal update happens automatically in service
|
||||
// No need to manually refresh the list
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('Error updating user status:', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role chip color
|
||||
*/
|
||||
getRoleColor(role: string): string {
|
||||
return role === 'admin' ? 'primary' : 'accent';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status chip color
|
||||
*/
|
||||
getStatusColor(isActive: boolean): string {
|
||||
return isActive ? 'primary' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text
|
||||
*/
|
||||
getStatusText(isActive: boolean): string {
|
||||
return isActive ? 'Active' : 'Inactive';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
formatDate(date: string | undefined): string {
|
||||
if (!date) return 'Never';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date with time for display
|
||||
*/
|
||||
formatDateTime(date: string | undefined): string {
|
||||
if (!date) return 'Never';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to admin dashboard
|
||||
*/
|
||||
goBack(): void {
|
||||
this.router.navigate(['/admin']);
|
||||
}
|
||||
}
|
||||
187
src/app/features/admin/category-form/category-form.html
Normal file
187
src/app/features/admin/category-form/category-form.html
Normal file
@@ -0,0 +1,187 @@
|
||||
<div class="category-form-container">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<div class="header-title">
|
||||
<button mat-icon-button (click)="cancel()" aria-label="Go back">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<h1>{{ pageTitle() }}</h1>
|
||||
</div>
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="categoryForm" (ngSubmit)="onSubmit()">
|
||||
<!-- Name Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Category Name</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="name"
|
||||
placeholder="e.g., JavaScript Fundamentals"
|
||||
required>
|
||||
<mat-icon matPrefix>label</mat-icon>
|
||||
<mat-error>{{ getErrorMessage('name') }}</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Slug Field with Preview -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Slug (URL-friendly)</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="slug"
|
||||
placeholder="e.g., javascript-fundamentals"
|
||||
required>
|
||||
<mat-icon matPrefix>link</mat-icon>
|
||||
<mat-hint>Preview: /categories/{{ slugPreview() }}</mat-hint>
|
||||
<mat-error>{{ getErrorMessage('slug') }}</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Description Field -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="description"
|
||||
rows="4"
|
||||
placeholder="Brief description of the category..."
|
||||
required>
|
||||
</textarea>
|
||||
<mat-icon matPrefix>description</mat-icon>
|
||||
<mat-hint align="end">
|
||||
{{ categoryForm.get('description')?.value?.length || 0 }} / 500
|
||||
</mat-hint>
|
||||
<mat-error>{{ getErrorMessage('description') }}</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Icon and Color Row -->
|
||||
<div class="form-row">
|
||||
<!-- Icon Selector -->
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Icon</mat-label>
|
||||
<mat-select formControlName="icon" required>
|
||||
@for (icon of iconOptions; track icon.value) {
|
||||
<mat-option [value]="icon.value">
|
||||
<mat-icon>{{ icon.value }}</mat-icon>
|
||||
<span>{{ icon.label }}</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<mat-icon matPrefix>{{ categoryForm.get('icon')?.value }}</mat-icon>
|
||||
<mat-error>{{ getErrorMessage('icon') }}</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Color Picker -->
|
||||
<mat-form-field appearance="outline" class="half-width">
|
||||
<mat-label>Color</mat-label>
|
||||
<mat-select formControlName="color" required>
|
||||
@for (color of colorOptions; track color.value) {
|
||||
<mat-option [value]="color.value">
|
||||
<span class="color-option">
|
||||
<span
|
||||
class="color-preview"
|
||||
[style.background-color]="color.value">
|
||||
</span>
|
||||
{{ color.label }}
|
||||
</span>
|
||||
</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
<span
|
||||
matPrefix
|
||||
class="color-preview"
|
||||
[style.background-color]="categoryForm.get('color')?.value">
|
||||
</span>
|
||||
<mat-error>{{ getErrorMessage('color') }}</mat-error>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Display Order -->
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Display Order</mat-label>
|
||||
<input
|
||||
matInput
|
||||
type="number"
|
||||
formControlName="displayOrder"
|
||||
placeholder="0"
|
||||
min="0">
|
||||
<mat-icon matPrefix>sort</mat-icon>
|
||||
<mat-hint>Lower numbers appear first in the category list</mat-hint>
|
||||
<mat-error>{{ getErrorMessage('displayOrder') }}</mat-error>
|
||||
</mat-form-field>
|
||||
|
||||
<!-- Guest Accessible Checkbox -->
|
||||
<div class="checkbox-field">
|
||||
<mat-checkbox formControlName="guestAccessible">
|
||||
<strong>Guest Accessible</strong>
|
||||
</mat-checkbox>
|
||||
<p class="checkbox-hint">
|
||||
Allow guest users to access this category without authentication
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Preview Card -->
|
||||
<div class="preview-section">
|
||||
<h3>Preview</h3>
|
||||
<div class="preview-card">
|
||||
<div
|
||||
class="preview-icon"
|
||||
[style.background-color]="categoryForm.get('color')?.value">
|
||||
<mat-icon>{{ categoryForm.get('icon')?.value }}</mat-icon>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
<h4>{{ categoryForm.get('name')?.value || 'Category Name' }}</h4>
|
||||
<p>{{ categoryForm.get('description')?.value || 'Category description will appear here...' }}</p>
|
||||
@if (categoryForm.get('guestAccessible')?.value) {
|
||||
<span class="preview-badge">
|
||||
<mat-icon>public</mat-icon>
|
||||
Guest Accessible
|
||||
</span>
|
||||
} @else {
|
||||
<span class="preview-badge locked">
|
||||
<mat-icon>lock</mat-icon>
|
||||
Login Required
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
[disabled]="isSubmitting()">
|
||||
<mat-icon>close</mat-icon>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="categoryForm.invalid || isSubmitting()">
|
||||
@if (isSubmitting()) {
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
}
|
||||
<span>
|
||||
@if (isSubmitting()) {
|
||||
Saving...
|
||||
} @else if (isEditMode()) {
|
||||
Save Changes
|
||||
} @else {
|
||||
Create Category
|
||||
}
|
||||
</span>
|
||||
@if (!isSubmitting()) {
|
||||
<mat-icon>{{ isEditMode() ? 'save' : 'add' }}</mat-icon>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
243
src/app/features/admin/category-form/category-form.scss
Normal file
243
src/app/features/admin/category-form/category-form.scss
Normal file
@@ -0,0 +1,243 @@
|
||||
.category-form-container {
|
||||
max-width: 800px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
|
||||
mat-card {
|
||||
mat-card-header {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.half-width {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Icon prefix styling
|
||||
mat-form-field {
|
||||
mat-icon[matPrefix] {
|
||||
margin-right: 8px;
|
||||
color: rgba(0, 0, 0, 0.54);
|
||||
}
|
||||
|
||||
.color-preview {
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.12);
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
// Color option styling
|
||||
.color-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// Checkbox field
|
||||
.checkbox-field {
|
||||
padding: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 4px;
|
||||
|
||||
mat-checkbox {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.checkbox-hint {
|
||||
margin: 0;
|
||||
padding-left: 32px;
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
// Preview section
|
||||
.preview-section {
|
||||
margin-top: 24px;
|
||||
padding: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-radius: 8px;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
|
||||
mat-icon {
|
||||
font-size: 36px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
background-color: rgba(76, 175, 80, 0.1);
|
||||
color: #4CAF50;
|
||||
border-radius: 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&.locked {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #FF9800;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form actions
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
@media (max-width: 480px) {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select option with icon styling
|
||||
::ng-deep .mat-mdc-option {
|
||||
mat-icon {
|
||||
vertical-align: middle;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// Dark mode support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.category-form-container {
|
||||
.checkbox-field,
|
||||
.preview-section {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.preview-section .preview-card {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
mat-form-field mat-icon[matPrefix] {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.checkbox-field .checkbox-hint {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.preview-section .preview-card .preview-content p {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/app/features/admin/category-form/category-form.ts
Normal file
230
src/app/features/admin/category-form/category-form.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { Component, inject, OnInit, OnDestroy, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router, ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { CategoryService } from '../../../core/services/category.service';
|
||||
import { CategoryFormData } from '../../../core/models/category.model';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category-form',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatCheckboxModule,
|
||||
MatSelectModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './category-form.html',
|
||||
styleUrls: ['./category-form.scss']
|
||||
})
|
||||
export class CategoryFormComponent implements OnInit, OnDestroy {
|
||||
private fb = inject(FormBuilder);
|
||||
private categoryService = inject(CategoryService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
categoryForm!: FormGroup;
|
||||
isEditMode = signal<boolean>(false);
|
||||
categoryId = signal<string | null>(null);
|
||||
isSubmitting = signal<boolean>(false);
|
||||
|
||||
// Icon options for dropdown
|
||||
iconOptions = [
|
||||
{ value: 'code', label: 'Code' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'language', label: 'Language' },
|
||||
{ value: 'web', label: 'Web' },
|
||||
{ value: 'storage', label: 'Storage' },
|
||||
{ value: 'cloud', label: 'Cloud' },
|
||||
{ value: 'category', label: 'Category' },
|
||||
{ value: 'folder', label: 'Folder' },
|
||||
{ value: 'description', label: 'Description' },
|
||||
{ value: 'psychology', label: 'Psychology' },
|
||||
{ value: 'science', label: 'Science' },
|
||||
{ value: 'school', label: 'School' }
|
||||
];
|
||||
|
||||
// Color options
|
||||
colorOptions = [
|
||||
{ value: '#2196F3', label: 'Blue' },
|
||||
{ value: '#4CAF50', label: 'Green' },
|
||||
{ value: '#FF9800', label: 'Orange' },
|
||||
{ value: '#F44336', label: 'Red' },
|
||||
{ value: '#9C27B0', label: 'Purple' },
|
||||
{ value: '#00BCD4', label: 'Cyan' },
|
||||
{ value: '#FFEB3B', label: 'Yellow' },
|
||||
{ value: '#607D8B', label: 'Blue Grey' }
|
||||
];
|
||||
|
||||
// Computed slug preview
|
||||
slugPreview = computed(() => {
|
||||
const name = this.categoryForm?.get('name')?.value || '';
|
||||
return this.generateSlug(name);
|
||||
});
|
||||
|
||||
pageTitle = computed(() => {
|
||||
return this.isEditMode() ? 'Edit Category' : 'Create New Category';
|
||||
});
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeForm();
|
||||
|
||||
// Check if we're in edit mode
|
||||
this.route.params
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(params => {
|
||||
if (params['id']) {
|
||||
this.isEditMode.set(true);
|
||||
this.categoryId.set(params['id']);
|
||||
this.loadCategoryData(params['id']);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-generate slug from name
|
||||
this.categoryForm.get('name')?.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(name => {
|
||||
if (!this.isEditMode() && !this.categoryForm.get('slug')?.touched) {
|
||||
this.categoryForm.patchValue({ slug: this.generateSlug(name) }, { emitEvent: false });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initializeForm(): void {
|
||||
this.categoryForm = this.fb.group({
|
||||
name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(100)]],
|
||||
slug: ['', [Validators.required, Validators.pattern(/^[a-z0-9-]+$/)]],
|
||||
description: ['', [Validators.required, Validators.minLength(10), Validators.maxLength(500)]],
|
||||
icon: ['category', Validators.required],
|
||||
color: ['#2196F3', Validators.required],
|
||||
displayOrder: [0, [Validators.min(0)]],
|
||||
guestAccessible: [false]
|
||||
});
|
||||
}
|
||||
|
||||
private loadCategoryData(id: string): void {
|
||||
this.categoryService.getCategoryById(id)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: (category) => {
|
||||
this.categoryForm.patchValue({
|
||||
name: category.name,
|
||||
slug: category.slug,
|
||||
description: category.description,
|
||||
icon: category.icon || 'category',
|
||||
color: category.color || '#2196F3',
|
||||
displayOrder: category.displayOrder || 0,
|
||||
guestAccessible: category.guestAccessible
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.router.navigate(['/admin/categories']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.categoryForm.invalid || this.isSubmitting()) {
|
||||
this.categoryForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting.set(true);
|
||||
const formData: CategoryFormData = this.categoryForm.value;
|
||||
|
||||
const request$ = this.isEditMode()
|
||||
? this.categoryService.updateCategory(this.categoryId()!, formData)
|
||||
: this.categoryService.createCategory(formData);
|
||||
|
||||
request$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting.set(false);
|
||||
this.router.navigate(['/admin/categories']);
|
||||
},
|
||||
error: () => {
|
||||
this.isSubmitting.set(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.router.navigate(['/admin/categories']);
|
||||
}
|
||||
|
||||
getErrorMessage(controlName: string): string {
|
||||
const control = this.categoryForm.get(controlName);
|
||||
|
||||
if (!control || !control.touched) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (control.hasError('required')) {
|
||||
return `${this.getFieldLabel(controlName)} is required`;
|
||||
}
|
||||
|
||||
if (control.hasError('minlength')) {
|
||||
const minLength = control.getError('minlength').requiredLength;
|
||||
return `Must be at least ${minLength} characters`;
|
||||
}
|
||||
|
||||
if (control.hasError('maxlength')) {
|
||||
const maxLength = control.getError('maxlength').requiredLength;
|
||||
return `Must not exceed ${maxLength} characters`;
|
||||
}
|
||||
|
||||
if (control.hasError('pattern') && controlName === 'slug') {
|
||||
return 'Slug must contain only lowercase letters, numbers, and hyphens';
|
||||
}
|
||||
|
||||
if (control.hasError('min')) {
|
||||
return 'Must be a positive number';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private getFieldLabel(controlName: string): string {
|
||||
const labels: { [key: string]: string } = {
|
||||
name: 'Category name',
|
||||
slug: 'Slug',
|
||||
description: 'Description',
|
||||
icon: 'Icon',
|
||||
color: 'Color',
|
||||
displayOrder: 'Display order'
|
||||
};
|
||||
return labels[controlName] || controlName;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
|
||||
export interface DeleteConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
itemName?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DeleteConfirmDialogComponent
|
||||
*
|
||||
* Reusable confirmation dialog for delete operations.
|
||||
*
|
||||
* Features:
|
||||
* - Customizable title, message, and button text
|
||||
* - Shows item name being deleted
|
||||
* - Warning icon for visual emphasis
|
||||
* - Accessible with keyboard navigation
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-delete-confirm-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
template: `
|
||||
<div class="delete-dialog">
|
||||
<div class="dialog-header">
|
||||
<mat-icon class="warning-icon">warning</mat-icon>
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<p class="dialog-message">{{ data.message }}</p>
|
||||
|
||||
@if (data.itemName) {
|
||||
<div class="item-preview">
|
||||
<strong>Item:</strong>
|
||||
<p>{{ data.itemName }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="warning-box">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>This action cannot be undone.</span>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">
|
||||
{{ data.cancelText || 'Cancel' }}
|
||||
</button>
|
||||
<button mat-raised-button color="warn" (click)="onConfirm()" cdkFocusInitial>
|
||||
<mat-icon>delete</mat-icon>
|
||||
{{ data.confirmText || 'Delete' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
.delete-dialog {
|
||||
min-width: 400px;
|
||||
|
||||
@media (max-width: 600px) {
|
||||
min-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.warning-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
padding: 0 1rem 1.5rem 1rem;
|
||||
|
||||
.dialog-message {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
background-color: var(--background-light);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #fff3e0;
|
||||
border-radius: 6px;
|
||||
border-left: 4px solid #ff9800;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.875rem;
|
||||
color: #e65100;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
padding: 1rem;
|
||||
gap: 0.75rem;
|
||||
|
||||
button {
|
||||
min-width: 100px;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.delete-dialog {
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--background-light: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
mat-dialog-content {
|
||||
.warning-box {
|
||||
background-color: rgba(255, 152, 0, 0.15);
|
||||
border-left-color: #ff9800;
|
||||
|
||||
mat-icon {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #ffb74d;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light Mode Support
|
||||
@media (prefers-color-scheme: light) {
|
||||
.delete-dialog {
|
||||
--text-primary: #212121;
|
||||
--text-secondary: #757575;
|
||||
--background-light: #f5f5f5;
|
||||
}
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class DeleteConfirmDialogComponent {
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<DeleteConfirmDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: DeleteConfirmDialogData
|
||||
) {}
|
||||
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
<div class="guest-analytics">
|
||||
<!-- Header -->
|
||||
<div class="analytics-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<mat-icon>people_outline</mat-icon>
|
||||
Guest Analytics
|
||||
</h1>
|
||||
<p class="subtitle">Guest user behavior and conversion insights</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button mat-raised-button color="accent" (click)="exportToCSV()" [disabled]="!analytics()">
|
||||
<mat-icon>download</mat-icon>
|
||||
Export CSV
|
||||
</button>
|
||||
<button mat-icon-button (click)="refreshAnalytics()" [disabled]="isLoading()" matTooltip="Refresh analytics">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="60"></mat-spinner>
|
||||
<p>Loading guest analytics...</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 Analytics</h3>
|
||||
<p>{{ error() }}</p>
|
||||
<button mat-raised-button color="primary" (click)="refreshAnalytics()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Analytics Content -->
|
||||
@if (analytics() && !isLoading()) {
|
||||
<!-- Statistics Cards -->
|
||||
<div class="stats-grid">
|
||||
<mat-card class="stat-card sessions-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon">
|
||||
<mat-icon>group_add</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>Total Guest Sessions</h3>
|
||||
<p class="stat-value">{{ formatNumber(totalSessions()) }}</p>
|
||||
@if (analytics() && analytics()!.recentActivity.last30Days) {
|
||||
<p class="stat-detail">+{{ analytics()!.recentActivity.last30Days }} this 30 days</p>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card active-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon">
|
||||
<mat-icon>online_prediction</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>Active Sessions</h3>
|
||||
<p class="stat-value">{{ formatNumber(activeSessions()) }}</p>
|
||||
<p class="stat-detail">Currently active</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<mat-card class="stat-card conversion-card">
|
||||
<mat-card-content>
|
||||
<div class="stat-icon">
|
||||
<mat-icon>trending_up</mat-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>Conversion Rate</h3>
|
||||
<p class="stat-value">{{ formatPercentage(conversionRate()) }}</p>
|
||||
@if (analytics() && analytics()!.overview.conversionRate) {
|
||||
<p class="stat-detail">{{ analytics()!.overview.conversionRate }} conversions</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>Avg Quizzes per Guest</h3>
|
||||
<p class="stat-value">{{ avgQuizzes().toFixed(1) }}</p>
|
||||
<p class="stat-detail">Per guest session</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<!-- Session Timeline Chart -->
|
||||
<!-- @if (timelineData().length > 0) {
|
||||
<mat-card class="chart-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>show_chart</mat-icon>
|
||||
Guest Session Timeline
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="chart-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color active"></span>
|
||||
<span>Active Sessions</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color new"></span>
|
||||
<span>New Sessions</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color converted"></span>
|
||||
<span>Converted Sessions</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<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="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"/>
|
||||
-->
|
||||
<!-- Active Sessions Line -->
|
||||
<!-- <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"/> -->
|
||||
|
||||
<!-- Converted Sessions Line -->
|
||||
<!-- <path [attr.d]="getTimelinePath('convertedSessions')" fill="none" stroke="#ff9800" stroke-width="3"/> -->
|
||||
|
||||
<!-- Data points -->
|
||||
<!-- @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"/>
|
||||
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||
[attr.cy]="calculateTimelineY(point.newSessions)"
|
||||
r="4" fill="#4caf50"/>
|
||||
<circle [attr.cx]="calculateTimelineX(i, timelineData().length)"
|
||||
[attr.cy]="calculateTimelineY(point.convertedSessions)"
|
||||
r="4" fill="#ff9800"/>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} -->
|
||||
|
||||
<!-- Conversion Funnel Chart -->
|
||||
<!-- @if (funnelData().length > 0) {
|
||||
<mat-card class="chart-card">
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>filter_alt</mat-icon>
|
||||
Conversion Funnel
|
||||
</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="chart-container">
|
||||
<svg [attr.width]="chartWidth" [attr.height]="funnelHeight" class="funnel-chart"> -->
|
||||
<!-- Funnel Bars -->
|
||||
<!-- @for (bar of getFunnelBars(); track bar.label) {
|
||||
<g> -->
|
||||
<!-- Bar -->
|
||||
<!-- <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"
|
||||
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"
|
||||
font-size="12" fill="#fff">{{ formatNumber(bar.count) }} ({{ formatPercentage(bar.percentage) }})</text>
|
||||
</g>
|
||||
}
|
||||
</svg>
|
||||
</div>
|
||||
<div class="funnel-insights">
|
||||
<p><strong>Conversion Insights:</strong></p>
|
||||
<ul>
|
||||
@for (stage of funnelData(); track stage.stage) {
|
||||
@if (stage.dropoff !== undefined) {
|
||||
<li>{{ formatPercentage(stage.dropoff) }} dropoff from {{ stage.stage }}</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
} -->
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
<h2>Guest Management</h2>
|
||||
<div class="actions-grid">
|
||||
<button mat-raised-button color="primary" (click)="goToSettings()">
|
||||
<mat-icon>settings</mat-icon>
|
||||
Guest Settings
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="refreshAnalytics()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh Data
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="goBack()">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
Admin Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Empty State -->
|
||||
@if (!analytics() && !isLoading() && !error()) {
|
||||
<mat-card class="empty-state">
|
||||
<mat-card-content>
|
||||
<mat-icon>people_outline</mat-icon>
|
||||
<h3>No Analytics Available</h3>
|
||||
<p>Guest analytics will appear here once guests start using the platform</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,474 @@
|
||||
.guest-analytics {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
|
||||
// Header
|
||||
.analytics-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
|
||||
.header-content {
|
||||
h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #1a237e;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
button mat-icon {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
button:hover:not([disabled]) mat-icon {
|
||||
&:first-child {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-card {
|
||||
margin-bottom: 2rem;
|
||||
border-left: 4px solid #f44336;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 4rem;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Statistics Grid
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.stat-card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
.stat-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 12px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.stat-detail {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #4caf50;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.sessions-card .stat-icon {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
&.active-card .stat-icon {
|
||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||
}
|
||||
|
||||
&.conversion-card .stat-icon {
|
||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||
}
|
||||
|
||||
&.quizzes-card .stat-icon {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chart Cards
|
||||
.chart-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
mat-card-header {
|
||||
padding: 1.5rem 1.5rem 0;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1.3rem;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding: 1.5rem;
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
|
||||
.legend-color {
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
|
||||
&.active {
|
||||
background: #3f51b5;
|
||||
}
|
||||
|
||||
&.new {
|
||||
background: #4caf50;
|
||||
}
|
||||
|
||||
&.converted {
|
||||
background: #ff9800;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
overflow-x: auto;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
|
||||
&.timeline-chart path {
|
||||
transition: stroke-dashoffset 1s ease;
|
||||
stroke-dasharray: 2000;
|
||||
stroke-dashoffset: 2000;
|
||||
animation: drawLine 2s ease forwards;
|
||||
}
|
||||
|
||||
&.funnel-chart rect {
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
text {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.funnel-insights {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
|
||||
li {
|
||||
color: #666;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Actions
|
||||
.quick-actions {
|
||||
margin-top: 3rem;
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
button {
|
||||
height: 60px;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.5rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
.empty-state {
|
||||
margin-top: 2rem;
|
||||
|
||||
mat-card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 5rem;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
color: #bdbdbd;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
padding: 1rem;
|
||||
|
||||
.analytics-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.header-left {
|
||||
width: 100%;
|
||||
|
||||
.header-content h1 {
|
||||
font-size: 1.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 2rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-card mat-card-content {
|
||||
.chart-legend {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.chart-container svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions .actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.guest-analytics {
|
||||
.analytics-header .header-left .header-content h1 {
|
||||
color: #e3f2fd;
|
||||
}
|
||||
|
||||
.chart-card mat-card-title,
|
||||
.quick-actions h2 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.stats-grid .stat-card {
|
||||
mat-card-content .stat-info {
|
||||
h3 {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state mat-card-content h3 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.chart-card mat-card-content {
|
||||
.chart-legend,
|
||||
.funnel-insights {
|
||||
background: #424242;
|
||||
|
||||
.legend-item,
|
||||
p, li {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { Component, OnInit, OnDestroy, signal, computed, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { GuestAnalytics } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* GuestAnalyticsComponent
|
||||
*
|
||||
* Admin page for viewing guest user analytics featuring:
|
||||
* - Guest session statistics (total, active, conversions)
|
||||
* - Conversion rate and funnel visualization
|
||||
* - Guest session timeline chart
|
||||
* - Average quizzes per guest metric
|
||||
* - CSV export functionality
|
||||
*
|
||||
* Features:
|
||||
* - Real-time analytics with 10-min caching
|
||||
* - Interactive SVG charts
|
||||
* - Export data to CSV
|
||||
* - Auto-refresh capability
|
||||
* - Mobile-responsive layout
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-guest-analytics',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule
|
||||
],
|
||||
templateUrl: './guest-analytics.component.html',
|
||||
styleUrls: ['./guest-analytics.component.scss']
|
||||
})
|
||||
export class GuestAnalyticsComponent implements OnInit, OnDestroy {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroy$ = new Subject<void>();
|
||||
|
||||
// State from service
|
||||
readonly analytics = this.adminService.guestAnalyticsState;
|
||||
readonly isLoading = this.adminService.isLoadingAnalytics;
|
||||
readonly error = this.adminService.analyticsError;
|
||||
|
||||
// Computed values for cards
|
||||
readonly totalSessions = this.adminService.totalGuestSessions;
|
||||
readonly activeSessions = this.adminService.activeGuestSessions;
|
||||
readonly conversionRate = this.adminService.conversionRate;
|
||||
readonly avgQuizzes = this.adminService.avgQuizzesPerGuest;
|
||||
|
||||
// Chart data computed signals
|
||||
// readonly timelineData = computed(() => this.analytics()?.timeline ?? []);
|
||||
// readonly funnelData = computed(() => this.analytics()?.conversionFunnel ?? []);
|
||||
|
||||
// Chart dimensions
|
||||
readonly chartWidth = 800;
|
||||
readonly chartHeight = 300;
|
||||
readonly funnelHeight = 400;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadAnalytics();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load guest analytics from service
|
||||
*/
|
||||
private loadAnalytics(): void {
|
||||
this.adminService.getGuestAnalytics()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe({
|
||||
error: (error) => {
|
||||
console.error('Failed to load guest analytics:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh analytics (force reload)
|
||||
*/
|
||||
refreshAnalytics(): void {
|
||||
this.adminService.refreshGuestAnalytics()
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
// );
|
||||
// }
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Calculate X coordinate for timeline chart
|
||||
*/
|
||||
calculateTimelineX(index: number, totalPoints: number): number {
|
||||
const width = this.chartWidth;
|
||||
const padding = 40;
|
||||
const plotWidth = width - 2 * padding;
|
||||
if (totalPoints <= 1) return padding;
|
||||
return padding + (index / (totalPoints - 1)) * plotWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SVG path for timeline line
|
||||
*/
|
||||
// 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}`;
|
||||
// });
|
||||
|
||||
// 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 [];
|
||||
|
||||
// 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
|
||||
// };
|
||||
// });
|
||||
// }
|
||||
|
||||
/**
|
||||
* Export analytics data to CSV
|
||||
*/
|
||||
exportToCSV(): void {
|
||||
const analytics = this.analytics();
|
||||
if (!analytics) return;
|
||||
|
||||
// Prepare CSV content
|
||||
let csvContent = 'Guest Analytics Report\n\n';
|
||||
|
||||
// Summary statistics
|
||||
csvContent += 'Summary Statistics\n';
|
||||
csvContent += 'Metric,Value\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`;
|
||||
// });
|
||||
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`;
|
||||
// });
|
||||
|
||||
// Create and download file
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `guest-analytics-${new Date().toISOString().split('T')[0]}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number with commas
|
||||
*/
|
||||
formatNumber(num: number): string {
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format percentage
|
||||
*/
|
||||
formatPercentage(num: number): string {
|
||||
return `${num.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to admin dashboard
|
||||
*/
|
||||
goBack(): void {
|
||||
this.router.navigate(['/admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to guest settings
|
||||
*/
|
||||
goToSettings(): void {
|
||||
this.router.navigate(['/admin/settings']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<div class="guest-settings-edit-container">
|
||||
<!-- Header -->
|
||||
<div class="settings-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="onCancel()" matTooltip="Back to Settings">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="header-title">
|
||||
<h1>Edit Guest Settings</h1>
|
||||
<p class="subtitle">Configure guest user access and limitations</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading() && !settings()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="60"></mat-spinner>
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Error State -->
|
||||
@if (error() && !isLoading() && !settings()) {
|
||||
<mat-card class="error-card">
|
||||
<mat-card-content>
|
||||
<div class="error-content">
|
||||
<mat-icon color="warn">error_outline</mat-icon>
|
||||
<div class="error-text">
|
||||
<h3>Failed to Load Settings</h3>
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="onCancel()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Go Back
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Settings Form -->
|
||||
@if (settings() || (!isLoading() && settingsForm)) {
|
||||
<form [formGroup]="settingsForm" (ngSubmit)="onSubmit()" class="settings-form">
|
||||
<!-- Access Control Section -->
|
||||
<mat-card class="form-section">
|
||||
<mat-card-header>
|
||||
<div class="section-icon access">
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Access Control</mat-card-title>
|
||||
<mat-card-subtitle>Enable or disable guest access to the platform</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="toggle-field">
|
||||
<div class="toggle-info">
|
||||
<label>Guest Access Enabled</label>
|
||||
<p class="field-description">Allow users to access the platform without registering</p>
|
||||
</div>
|
||||
<mat-slide-toggle formControlName="guestAccessEnabled" color="primary">
|
||||
</mat-slide-toggle>
|
||||
</div>
|
||||
@if (!settingsForm.get('guestAccessEnabled')?.value) {
|
||||
<div class="warning-banner">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>When disabled, all users must register and login to access the platform.</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Quiz Limits Section -->
|
||||
<mat-card class="form-section">
|
||||
<mat-card-header>
|
||||
<div class="section-icon limits">
|
||||
<mat-icon>rule</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Quiz Limits</mat-card-title>
|
||||
<mat-card-subtitle>Set daily and per-quiz restrictions for guests</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Max Quizzes Per Day</mat-label>
|
||||
<input matInput type="number" formControlName="maxQuizzesPerDay" min="1" max="100">
|
||||
<mat-icon matPrefix>calendar_today</mat-icon>
|
||||
<mat-hint>Number of quizzes a guest can take per day (1-100)</mat-hint>
|
||||
@if (hasError('maxQuizzesPerDay')) {
|
||||
<mat-error>{{ getErrorMessage('maxQuizzesPerDay') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Max Questions Per Quiz</mat-label>
|
||||
<input matInput type="number" formControlName="maxQuestionsPerQuiz" min="1" max="50">
|
||||
<mat-icon matPrefix>quiz</mat-icon>
|
||||
<mat-hint>Maximum questions allowed in a single quiz (1-50)</mat-hint>
|
||||
@if (hasError('maxQuestionsPerQuiz')) {
|
||||
<mat-error>{{ getErrorMessage('maxQuestionsPerQuiz') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Session Configuration Section -->
|
||||
<mat-card class="form-section">
|
||||
<mat-card-header>
|
||||
<div class="section-icon session">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Session Configuration</mat-card-title>
|
||||
<mat-card-subtitle>Configure guest session duration</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Session Expiry Hours</mat-label>
|
||||
<input matInput type="number" formControlName="sessionExpiryHours" min="1" max="168">
|
||||
<mat-icon matPrefix>timer</mat-icon>
|
||||
<mat-hint>
|
||||
How long guest sessions remain active (1-168 hours / 7 days)
|
||||
@if (settingsForm.get('sessionExpiryHours')?.value) {
|
||||
- {{ formatExpiryTime(settingsForm.get('sessionExpiryHours')?.value) }}
|
||||
}
|
||||
</mat-hint>
|
||||
@if (hasError('sessionExpiryHours')) {
|
||||
<mat-error>{{ getErrorMessage('sessionExpiryHours') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Upgrade Prompt Section -->
|
||||
<mat-card class="form-section">
|
||||
<mat-card-header>
|
||||
<div class="section-icon message">
|
||||
<mat-icon>message</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Upgrade Prompt</mat-card-title>
|
||||
<mat-card-subtitle>Message shown when guests reach their limit</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="form-row">
|
||||
<mat-form-field appearance="outline" class="full-width">
|
||||
<mat-label>Upgrade Prompt Message</mat-label>
|
||||
<textarea
|
||||
matInput
|
||||
formControlName="upgradePromptMessage"
|
||||
rows="4"
|
||||
maxlength="500"
|
||||
></textarea>
|
||||
<mat-icon matPrefix>format_quote</mat-icon>
|
||||
<mat-hint align="end">
|
||||
{{ settingsForm.get('upgradePromptMessage')?.value?.length || 0 }} / 500 characters
|
||||
</mat-hint>
|
||||
@if (hasError('upgradePromptMessage')) {
|
||||
<mat-error>{{ getErrorMessage('upgradePromptMessage') }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<!-- Message Preview -->
|
||||
@if (settingsForm.get('upgradePromptMessage')?.value) {
|
||||
<div class="message-preview">
|
||||
<div class="preview-label">
|
||||
<mat-icon>visibility</mat-icon>
|
||||
<span>Preview:</span>
|
||||
</div>
|
||||
<div class="preview-content">
|
||||
{{ settingsForm.get('upgradePromptMessage')?.value }}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Changes Preview -->
|
||||
@if (hasUnsavedChanges()) {
|
||||
<mat-card class="changes-preview">
|
||||
<mat-card-header>
|
||||
<div class="section-icon changes">
|
||||
<mat-icon>pending_actions</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Pending Changes</mat-card-title>
|
||||
<mat-card-subtitle>Review changes before saving</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="changes-list">
|
||||
@for (change of getChangesPreview(); track change.label) {
|
||||
<div class="change-item">
|
||||
<div class="change-label">{{ change.label }}</div>
|
||||
<div class="change-values">
|
||||
<span class="old-value">{{ change.old }}</span>
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
<span class="new-value">{{ change.new }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="form-actions">
|
||||
<div class="actions-left">
|
||||
<button
|
||||
mat-stroked-button
|
||||
type="button"
|
||||
(click)="onReset()"
|
||||
[disabled]="isSubmitting || !hasUnsavedChanges()"
|
||||
>
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions-right">
|
||||
<button
|
||||
mat-button
|
||||
type="button"
|
||||
(click)="onCancel()"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@if (isSubmitting) {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
disabled
|
||||
>
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
Saving...
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
type="submit"
|
||||
[disabled]="settingsForm.invalid || !hasUnsavedChanges()"
|
||||
>
|
||||
<mat-icon>save</mat-icon>
|
||||
Save Changes
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,468 @@
|
||||
.guest-settings-edit-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
// Header Section
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #d32f2f;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Form
|
||||
.settings-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
// Form Section Card
|
||||
.form-section {
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.section-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.access {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
|
||||
}
|
||||
|
||||
&.limits {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||
}
|
||||
|
||||
&.session {
|
||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||
}
|
||||
|
||||
&.message {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
&.changes {
|
||||
background: linear-gradient(135deg, #ffa726 0%, #fb8c00 100%);
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mat-card-subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle Field
|
||||
.toggle-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
.toggle-info {
|
||||
flex: 1;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warning Banner
|
||||
.warning-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
|
||||
mat-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #856404;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Form Fields
|
||||
.form-row {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
mat-form-field {
|
||||
mat-icon[matPrefix] {
|
||||
margin-right: 0.5rem;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// Message Preview
|
||||
.message-preview {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #3f51b5;
|
||||
|
||||
.preview-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #3f51b5;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
color: #333;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
// Changes Preview
|
||||
.changes-preview {
|
||||
border: 2px solid #ffa726;
|
||||
|
||||
.changes-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.change-item {
|
||||
padding: 1rem;
|
||||
background: #fff3e0;
|
||||
border-radius: 8px;
|
||||
|
||||
.change-label {
|
||||
font-weight: 500;
|
||||
color: #e65100;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.change-values {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
|
||||
.old-value {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
color: #999;
|
||||
text-decoration: line-through;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
color: #ff9800;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.new-value {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
color: #4caf50;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form Actions
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
gap: 1rem;
|
||||
|
||||
.actions-left,
|
||||
.actions-right {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.guest-settings-edit-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.actions-left,
|
||||
.actions-right {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.changes-preview {
|
||||
.change-item .change-values {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
mat-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.form-section {
|
||||
mat-card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.settings-header .header-title h1 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.settings-header .subtitle {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.error-card .error-content .error-text p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.toggle-field {
|
||||
background: #2a2a2a;
|
||||
|
||||
label {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.field-description {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: #4a3f2a;
|
||||
|
||||
span {
|
||||
color: #ffd54f;
|
||||
}
|
||||
}
|
||||
|
||||
mat-form-field mat-icon[matPrefix] {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.message-preview {
|
||||
background: #2a2a2a;
|
||||
|
||||
.preview-content {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.changes-preview {
|
||||
.changes-list .change-item {
|
||||
background: #3a3a2a;
|
||||
|
||||
.change-label {
|
||||
color: #ffb74d;
|
||||
}
|
||||
|
||||
.change-values {
|
||||
.old-value,
|
||||
.new-value {
|
||||
background: #1a1a1a;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
border-top-color: #444;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Component, OnInit, inject, DestroyRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatDividerModule } from '@angular/material/divider';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { GuestSettings } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* GuestSettingsEditComponent
|
||||
*
|
||||
* Form component for editing guest access settings.
|
||||
* Allows administrators to configure guest user limitations and features.
|
||||
*
|
||||
* Features:
|
||||
* - Reactive form with validation
|
||||
* - Real-time validation errors
|
||||
* - Settings preview before save
|
||||
* - Form reset functionality
|
||||
* - Success/error handling
|
||||
* - Navigation back to view mode
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-guest-settings-edit',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatInputModule,
|
||||
MatFormFieldModule,
|
||||
MatSlideToggleModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatDividerModule
|
||||
],
|
||||
templateUrl: './guest-settings-edit.component.html',
|
||||
styleUrl: './guest-settings-edit.component.scss'
|
||||
})
|
||||
export class GuestSettingsEditComponent implements OnInit {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(FormBuilder);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// Service signals
|
||||
readonly settings = this.adminService.guestSettingsState;
|
||||
readonly isLoading = this.adminService.isLoadingSettings;
|
||||
readonly error = this.adminService.settingsError;
|
||||
|
||||
// Form
|
||||
settingsForm!: FormGroup;
|
||||
isSubmitting = false;
|
||||
originalSettings: GuestSettings | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.initializeForm();
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the form with validation
|
||||
*/
|
||||
private initializeForm(): void {
|
||||
this.settingsForm = this.fb.group({
|
||||
guestAccessEnabled: [false],
|
||||
maxQuizzesPerDay: [3, [Validators.required, Validators.min(1), Validators.max(100)]],
|
||||
maxQuestionsPerQuiz: [10, [Validators.required, Validators.min(1), Validators.max(50)]],
|
||||
sessionExpiryHours: [24, [Validators.required, Validators.min(1), Validators.max(168)]],
|
||||
upgradePromptMessage: [
|
||||
'You\'ve reached your quiz limit. Sign up for unlimited access!',
|
||||
[Validators.required, Validators.minLength(10), Validators.maxLength(500)]
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load existing settings and populate form
|
||||
*/
|
||||
private loadSettings(): void {
|
||||
// If settings already loaded, use them
|
||||
if (this.settings()) {
|
||||
this.populateForm(this.settings()!);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch settings
|
||||
this.adminService.getGuestSettings()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(settings => {
|
||||
this.populateForm(settings);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate form with existing settings
|
||||
*/
|
||||
private populateForm(settings: GuestSettings): void {
|
||||
this.originalSettings = settings;
|
||||
this.settingsForm.patchValue({
|
||||
guestAccessEnabled: settings.guestAccessEnabled,
|
||||
maxQuizzesPerDay: settings.maxQuizzesPerDay,
|
||||
maxQuestionsPerQuiz: settings.maxQuestionsPerQuiz,
|
||||
sessionExpiryHours: settings.sessionExpiryHours,
|
||||
upgradePromptMessage: settings.upgradePromptMessage
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit form and update settings
|
||||
*/
|
||||
onSubmit(): void {
|
||||
if (this.settingsForm.invalid || this.isSubmitting) {
|
||||
this.settingsForm.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
const formData = this.settingsForm.value;
|
||||
|
||||
this.adminService.updateGuestSettings(formData)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting = false;
|
||||
// Navigate back to view page after short delay
|
||||
setTimeout(() => {
|
||||
this.router.navigate(['/admin/guest-settings']);
|
||||
}, 1500);
|
||||
},
|
||||
error: () => {
|
||||
this.isSubmitting = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel editing and return to view page
|
||||
*/
|
||||
onCancel(): void {
|
||||
if (this.hasUnsavedChanges()) {
|
||||
if (confirm('You have unsaved changes. Are you sure you want to cancel?')) {
|
||||
this.router.navigate(['/admin/guest-settings']);
|
||||
}
|
||||
} else {
|
||||
this.router.navigate(['/admin/guest-settings']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form to original values
|
||||
*/
|
||||
onReset(): void {
|
||||
if (this.originalSettings) {
|
||||
this.populateForm(this.originalSettings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if form has unsaved changes
|
||||
*/
|
||||
hasUnsavedChanges(): boolean {
|
||||
if (!this.originalSettings) return false;
|
||||
|
||||
const formValue = this.settingsForm.value;
|
||||
return (
|
||||
formValue.guestAccessEnabled !== this.originalSettings.guestAccessEnabled ||
|
||||
formValue.maxQuizzesPerDay !== this.originalSettings.maxQuizzesPerDay ||
|
||||
formValue.maxQuestionsPerQuiz !== this.originalSettings.maxQuestionsPerQuiz ||
|
||||
formValue.sessionExpiryHours !== this.originalSettings.sessionExpiryHours ||
|
||||
formValue.upgradePromptMessage !== this.originalSettings.upgradePromptMessage
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for a form field
|
||||
*/
|
||||
getErrorMessage(fieldName: string): string {
|
||||
const field = this.settingsForm.get(fieldName);
|
||||
if (!field?.errors || !field.touched) return '';
|
||||
|
||||
if (field.errors['required']) return 'This field is required';
|
||||
if (field.errors['min']) return `Minimum value is ${field.errors['min'].min}`;
|
||||
if (field.errors['max']) return `Maximum value is ${field.errors['max'].max}`;
|
||||
if (field.errors['minlength']) return `Minimum length is ${field.errors['minlength'].requiredLength} characters`;
|
||||
if (field.errors['maxlength']) return `Maximum length is ${field.errors['maxlength'].requiredLength} characters`;
|
||||
|
||||
return 'Invalid value';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field has an error
|
||||
*/
|
||||
hasError(fieldName: string): boolean {
|
||||
const field = this.settingsForm.get(fieldName);
|
||||
return !!(field?.invalid && field?.touched);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preview of changes
|
||||
*/
|
||||
getChangesPreview(): Array<{label: string, old: any, new: any}> {
|
||||
if (!this.originalSettings || !this.hasUnsavedChanges()) return [];
|
||||
|
||||
const changes: Array<{label: string, old: any, new: any}> = [];
|
||||
const formValue = this.settingsForm.value;
|
||||
|
||||
if (formValue.guestAccessEnabled !== this.originalSettings.guestAccessEnabled) {
|
||||
changes.push({
|
||||
label: 'Guest Access',
|
||||
old: this.originalSettings.guestAccessEnabled ? 'Enabled' : 'Disabled',
|
||||
new: formValue.guestAccessEnabled ? 'Enabled' : 'Disabled'
|
||||
});
|
||||
}
|
||||
|
||||
if (formValue.maxQuizzesPerDay !== this.originalSettings.maxQuizzesPerDay) {
|
||||
changes.push({
|
||||
label: 'Max Quizzes Per Day',
|
||||
old: this.originalSettings.maxQuizzesPerDay,
|
||||
new: formValue.maxQuizzesPerDay
|
||||
});
|
||||
}
|
||||
|
||||
if (formValue.maxQuestionsPerQuiz !== this.originalSettings.maxQuestionsPerQuiz) {
|
||||
changes.push({
|
||||
label: 'Max Questions Per Quiz',
|
||||
old: this.originalSettings.maxQuestionsPerQuiz,
|
||||
new: formValue.maxQuestionsPerQuiz
|
||||
});
|
||||
}
|
||||
|
||||
if (formValue.sessionExpiryHours !== this.originalSettings.sessionExpiryHours) {
|
||||
changes.push({
|
||||
label: 'Session Expiry Hours',
|
||||
old: this.originalSettings.sessionExpiryHours,
|
||||
new: formValue.sessionExpiryHours
|
||||
});
|
||||
}
|
||||
|
||||
if (formValue.upgradePromptMessage !== this.originalSettings.upgradePromptMessage) {
|
||||
changes.push({
|
||||
label: 'Upgrade Prompt Message',
|
||||
old: this.originalSettings.upgradePromptMessage,
|
||||
new: formValue.upgradePromptMessage
|
||||
});
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format expiry time for display
|
||||
*/
|
||||
formatExpiryTime(hours: number): string {
|
||||
if (hours < 24) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours === 0) {
|
||||
return `${days} day${days !== 1 ? 's' : ''}`;
|
||||
}
|
||||
return `${days} day${days !== 1 ? 's' : ''} and ${remainingHours} hour${remainingHours !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<div class="guest-settings-container">
|
||||
<!-- Header -->
|
||||
<div class="settings-header">
|
||||
<div class="header-left">
|
||||
<button mat-icon-button (click)="goBack()" matTooltip="Back to Dashboard">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
</button>
|
||||
<div class="header-title">
|
||||
<h1>Guest Access Settings</h1>
|
||||
<p class="subtitle">View and manage guest user access configuration</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button mat-stroked-button (click)="refreshSettings()" [disabled]="isLoading()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
<button mat-raised-button color="primary" (click)="editSettings()" [disabled]="isLoading()">
|
||||
<mat-icon>edit</mat-icon>
|
||||
Edit Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@if (isLoading()) {
|
||||
<div class="loading-container">
|
||||
<mat-spinner diameter="60"></mat-spinner>
|
||||
<p>Loading guest settings...</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>
|
||||
<div class="error-text">
|
||||
<h3>Failed to Load Settings</h3>
|
||||
<p>{{ error() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button mat-raised-button color="primary" (click)="loadSettings()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Try Again
|
||||
</button>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Settings Display -->
|
||||
@if (settings() && !isLoading()) {
|
||||
<div class="settings-content">
|
||||
<!-- Access Control Section -->
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon" [class.enabled]="settings()?.guestAccessEnabled">
|
||||
<mat-icon>lock_open</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Access Control</mat-card-title>
|
||||
<mat-card-subtitle>Guest access configuration</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<mat-icon>toggle_on</mat-icon>
|
||||
<span>Guest Access</span>
|
||||
</div>
|
||||
<div class="setting-value">
|
||||
<mat-chip [color]="getStatusColor(settings()?.guestAccessEnabled ?? false)" highlighted>
|
||||
{{ getStatusText(settings()?.guestAccessEnabled ?? false) }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
@if (!settings()?.guestAccessEnabled) {
|
||||
<div class="info-banner">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>Guest access is currently disabled. Users must register to access the platform.</span>
|
||||
</div>
|
||||
}
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Quiz Limits Section -->
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon limits">
|
||||
<mat-icon>rule</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Quiz Limits</mat-card-title>
|
||||
<mat-card-subtitle>Daily and per-quiz restrictions</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<mat-icon>calendar_today</mat-icon>
|
||||
<span>Max Quizzes Per Day</span>
|
||||
</div>
|
||||
<div class="setting-value">
|
||||
<span class="value-number">{{ settings()?.maxQuizzesPerDay ?? 0 }}</span>
|
||||
<span class="value-unit">quizzes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<mat-icon>quiz</mat-icon>
|
||||
<span>Max Questions Per Quiz</span>
|
||||
</div>
|
||||
<div class="setting-value">
|
||||
<span class="value-number">{{ settings()?.maxQuestionsPerQuiz ?? 0 }}</span>
|
||||
<span class="value-unit">questions</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Session Configuration Section -->
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon session">
|
||||
<mat-icon>schedule</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Session Configuration</mat-card-title>
|
||||
<mat-card-subtitle>Session duration and expiry</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="setting-item">
|
||||
<div class="setting-label">
|
||||
<mat-icon>timer</mat-icon>
|
||||
<span>Session Expiry Time</span>
|
||||
</div>
|
||||
<div class="setting-value">
|
||||
<span class="value-number">{{ settings()?.sessionExpiryHours ?? 0 }}</span>
|
||||
<span class="value-unit">hours</span>
|
||||
<span class="value-formatted">({{ formatExpiryTime(settings()?.sessionExpiryHours ?? 0) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Upgrade Prompt Section -->
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon message">
|
||||
<mat-icon>message</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Upgrade Prompt</mat-card-title>
|
||||
<mat-card-subtitle>Message shown to guests when limit reached</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="upgrade-message">
|
||||
<mat-icon>format_quote</mat-icon>
|
||||
<p>{{ settings()?.upgradePromptMessage ?? 'No message configured' }}</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
||||
<!-- Guest Features Section -->
|
||||
@if (settings()?.features) {
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon features">
|
||||
<mat-icon>settings</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Guest Features</mat-card-title>
|
||||
<mat-card-subtitle>Available features for guest users</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="features-grid">
|
||||
<div class="feature-item">
|
||||
<mat-icon [class.enabled]="settings()?.features?.canBookmark">bookmark</mat-icon>
|
||||
<span>Bookmarking</span>
|
||||
<mat-chip [color]="getFeatureColor(settings()?.features?.canBookmark ?? false)">
|
||||
{{ getStatusText(settings()?.features?.canBookmark ?? false) }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<mat-icon [class.enabled]="settings()?.features?.canViewHistory">history</mat-icon>
|
||||
<span>View History</span>
|
||||
<mat-chip [color]="getFeatureColor(settings()?.features?.canViewHistory ?? false)">
|
||||
{{ getStatusText(settings()?.features?.canViewHistory ?? false) }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<mat-icon [class.enabled]="settings()?.features?.canExportResults">download</mat-icon>
|
||||
<span>Export Results</span>
|
||||
<mat-chip [color]="getFeatureColor(settings()?.features?.canExportResults ?? false)">
|
||||
{{ getStatusText(settings()?.features?.canExportResults ?? false) }}
|
||||
</mat-chip>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
|
||||
<!-- Allowed Categories Section -->
|
||||
@if (settings()?.allowedCategories && settings()!.allowedCategories!.length > 0) {
|
||||
<mat-card class="settings-card">
|
||||
<mat-card-header>
|
||||
<div class="card-header-icon categories">
|
||||
<mat-icon>category</mat-icon>
|
||||
</div>
|
||||
<mat-card-title>Allowed Categories</mat-card-title>
|
||||
<mat-card-subtitle>Categories accessible to guest users</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="categories-chips">
|
||||
@for (category of settings()?.allowedCategories; track category) {
|
||||
<mat-chip color="accent">{{ category }}</mat-chip>
|
||||
}
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="quick-actions">
|
||||
<button mat-button (click)="goToAnalytics()">
|
||||
<mat-icon>analytics</mat-icon>
|
||||
View Guest Analytics
|
||||
</button>
|
||||
<button mat-button (click)="goBack()">
|
||||
<mat-icon>dashboard</mat-icon>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,449 @@
|
||||
.guest-settings-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
// Header Section
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 2rem;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex: 1;
|
||||
|
||||
.header-title {
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
color: #666;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading State
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rem 2rem;
|
||||
gap: 1.5rem;
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Error State
|
||||
.error-card {
|
||||
margin-bottom: 2rem;
|
||||
|
||||
.error-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #d32f2f;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Settings Content
|
||||
.settings-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
// Settings Card
|
||||
.settings-card {
|
||||
mat-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
|
||||
.card-header-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
|
||||
mat-icon {
|
||||
font-size: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
&.enabled {
|
||||
background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);
|
||||
}
|
||||
|
||||
&.limits {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a6f 100%);
|
||||
}
|
||||
|
||||
&.session {
|
||||
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
|
||||
}
|
||||
|
||||
&.message {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
}
|
||||
|
||||
&.features {
|
||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||
}
|
||||
|
||||
&.categories {
|
||||
background: linear-gradient(135deg, #30cfd0 0%, #330867 100%);
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
mat-card-subtitle {
|
||||
margin: 0.25rem 0 0 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Setting Item
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
|
||||
mat-icon {
|
||||
color: #666;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
.value-number {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #3f51b5;
|
||||
}
|
||||
|
||||
.value-unit {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.value-formatted {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Info Banner
|
||||
.info-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
|
||||
mat-icon {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #856404;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade Message
|
||||
.upgrade-message {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid #3f51b5;
|
||||
|
||||
mat-icon {
|
||||
color: #3f51b5;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
color: #333;
|
||||
font-style: italic;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
// Features Grid
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
text-align: center;
|
||||
|
||||
mat-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: #999;
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&.enabled {
|
||||
color: #4caf50;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
mat-chip {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Categories Chips
|
||||
.categories-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
|
||||
mat-chip {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
// Quick Actions
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem 0;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive Design
|
||||
@media (max-width: 768px) {
|
||||
.guest-settings-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
|
||||
.header-left {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
flex-direction: column;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.settings-content {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
// Dark Mode Support
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.settings-header {
|
||||
.header-title h1 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-container p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.error-card .error-content .error-text p {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
background: #2a2a2a;
|
||||
|
||||
.setting-label {
|
||||
color: #fff;
|
||||
|
||||
mat-icon {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-banner {
|
||||
background: #4a3f2a;
|
||||
|
||||
span {
|
||||
color: #ffd54f;
|
||||
}
|
||||
}
|
||||
|
||||
.upgrade-message {
|
||||
background: #2a2a2a;
|
||||
|
||||
p {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.features-grid .feature-item {
|
||||
background: #2a2a2a;
|
||||
|
||||
span {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
border-top-color: #444;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component, OnInit, inject, DestroyRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { AdminService } from '../../../core/services/admin.service';
|
||||
import { GuestSettings } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* GuestSettingsComponent
|
||||
*
|
||||
* Displays guest access settings in read-only mode for admin users.
|
||||
* Allows navigation to edit settings view.
|
||||
*
|
||||
* Features:
|
||||
* - Read-only settings cards with icons
|
||||
* - Categorized settings display (Access, Limits, Session, Features)
|
||||
* - Loading and error states
|
||||
* - Refresh functionality
|
||||
* - Navigation to edit view
|
||||
* - Status indicators for enabled/disabled features
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-guest-settings',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatCardModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
MatChipsModule
|
||||
],
|
||||
templateUrl: './guest-settings.component.html',
|
||||
styleUrl: './guest-settings.component.scss'
|
||||
})
|
||||
export class GuestSettingsComponent implements OnInit {
|
||||
private readonly adminService = inject(AdminService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// Service signals
|
||||
readonly settings = this.adminService.guestSettingsState;
|
||||
readonly isLoading = this.adminService.isLoadingSettings;
|
||||
readonly error = this.adminService.settingsError;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load guest settings from API
|
||||
*/
|
||||
loadSettings(): void {
|
||||
this.adminService.getGuestSettings()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh settings (force reload)
|
||||
*/
|
||||
refreshSettings(): void {
|
||||
this.adminService.refreshGuestSettings()
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to edit settings page
|
||||
*/
|
||||
editSettings(): void {
|
||||
this.router.navigate(['/admin/guest-settings/edit']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate back to admin dashboard
|
||||
*/
|
||||
goBack(): void {
|
||||
this.router.navigate(['/admin']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to guest analytics
|
||||
*/
|
||||
goToAnalytics(): void {
|
||||
this.router.navigate(['/admin/analytics']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color for boolean settings
|
||||
*/
|
||||
getStatusColor(enabled: boolean): string {
|
||||
return enabled ? 'primary' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status text for boolean settings
|
||||
*/
|
||||
getStatusText(enabled: boolean): string {
|
||||
return enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get chip color for features
|
||||
*/
|
||||
getFeatureColor(enabled: boolean): string {
|
||||
return enabled ? 'accent' : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format session expiry hours to readable text
|
||||
*/
|
||||
formatExpiryTime(hours: number): string {
|
||||
if (hours < 24) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}`;
|
||||
}
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days !== 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
<div class="role-update-dialog">
|
||||
<!-- Step 1: Role Selection -->
|
||||
@if (!showConfirmation()) {
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
<mat-icon class="header-icon">admin_panel_settings</mat-icon>
|
||||
<h2 mat-dialog-title>Update User Role</h2>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<h3>{{ data.user.username }}</h3>
|
||||
<p>{{ data.user.email }}</p>
|
||||
<div class="current-role">
|
||||
<span class="label">Current Role:</span>
|
||||
<span [class]="'role-badge role-' + data.user.role">
|
||||
{{ getRoleLabel(data.user.role) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="role-selector">
|
||||
<h3 class="selector-title">Select New Role</h3>
|
||||
<mat-radio-group [(ngModel)]="selectedRole" class="role-options">
|
||||
<mat-radio-button value="user" class="role-option">
|
||||
<div class="role-option-content">
|
||||
<div class="role-option-header">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span class="role-name">Regular User</span>
|
||||
</div>
|
||||
<p class="role-description">{{ getRoleDescription('user') }}</p>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
|
||||
<mat-radio-button value="admin" class="role-option">
|
||||
<div class="role-option-content">
|
||||
<div class="role-option-header">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<span class="role-name">Administrator</span>
|
||||
</div>
|
||||
<p class="role-description">{{ getRoleDescription('admin') }}</p>
|
||||
</div>
|
||||
</mat-radio-button>
|
||||
</mat-radio-group>
|
||||
</div>
|
||||
|
||||
@if (isDemotingAdmin) {
|
||||
<div class="warning-box">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<div class="warning-content">
|
||||
<h4>Warning: Demoting Administrator</h4>
|
||||
<p>This user will lose access to:</p>
|
||||
<ul>
|
||||
<li>Admin dashboard and analytics</li>
|
||||
<li>User management capabilities</li>
|
||||
<li>System settings and configuration</li>
|
||||
<li>Question and category management</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (isPromotingToAdmin) {
|
||||
<div class="info-box">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div class="info-content">
|
||||
<h4>Promoting to Administrator</h4>
|
||||
<p>This user will gain access to:</p>
|
||||
<ul>
|
||||
<li>Full admin dashboard and analytics</li>
|
||||
<li>Manage all users and their roles</li>
|
||||
<li>Configure system settings</li>
|
||||
<li>Create and manage questions/categories</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()" [disabled]="isLoading()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
mat-raised-button
|
||||
color="primary"
|
||||
(click)="onNext()"
|
||||
[disabled]="!hasRoleChanged || isLoading()">
|
||||
Next
|
||||
<mat-icon>arrow_forward</mat-icon>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Step 2: Confirmation -->
|
||||
@if (showConfirmation()) {
|
||||
<div class="dialog-content">
|
||||
<div class="dialog-header">
|
||||
<mat-icon class="header-icon confirm">check_circle</mat-icon>
|
||||
<h2 mat-dialog-title>Confirm Role Change</h2>
|
||||
</div>
|
||||
|
||||
<mat-dialog-content>
|
||||
<div class="confirmation-message">
|
||||
<div class="change-summary">
|
||||
<div class="change-item">
|
||||
<span class="change-label">User:</span>
|
||||
<span class="change-value">{{ data.user.username }}</span>
|
||||
</div>
|
||||
<div class="change-arrow">
|
||||
<mat-icon>arrow_downward</mat-icon>
|
||||
</div>
|
||||
<div class="change-item">
|
||||
<span class="change-label">Current Role:</span>
|
||||
<span [class]="'role-badge role-' + data.user.role">
|
||||
{{ getRoleLabel(data.user.role) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="change-arrow">
|
||||
<mat-icon>arrow_downward</mat-icon>
|
||||
</div>
|
||||
<div class="change-item">
|
||||
<span class="change-label">New Role:</span>
|
||||
<span [class]="'role-badge role-' + selectedRole">
|
||||
{{ getRoleLabel(selectedRole) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isDemotingAdmin) {
|
||||
<div class="final-warning">
|
||||
<mat-icon>error</mat-icon>
|
||||
<p><strong>Important:</strong> This action will immediately revoke all administrative privileges. The user will be logged out if currently in an admin session.</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<p class="confirmation-question">
|
||||
Are you sure you want to change this user's role?
|
||||
</p>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onBack()" [disabled]="isLoading()">
|
||||
<mat-icon>arrow_back</mat-icon>
|
||||
Back
|
||||
</button>
|
||||
@if (isLoading()) {
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="isDemotingAdmin ? 'warn' : 'primary'"
|
||||
[disabled]="true">
|
||||
<mat-spinner diameter="20"></mat-spinner>
|
||||
Updating...
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-raised-button
|
||||
[color]="isDemotingAdmin ? 'warn' : 'primary'"
|
||||
(click)="onConfirm()">
|
||||
<mat-icon>check</mat-icon>
|
||||
Confirm Change
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,415 @@
|
||||
.role-update-dialog {
|
||||
.dialog-content {
|
||||
min-width: 500px;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dialog Header
|
||||
// ===========================
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.header-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--primary-color);
|
||||
|
||||
&.confirm {
|
||||
color: var(--success-color);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// User Info Section
|
||||
// ===========================
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.user-avatar {
|
||||
mat-icon {
|
||||
font-size: 56px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.current-role {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Role Selector
|
||||
// ===========================
|
||||
.role-selector {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.selector-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.role-option {
|
||||
padding: 16px;
|
||||
border: 2px solid var(--divider-color);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
&.mat-radio-checked {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.role-option-content {
|
||||
width: 100%;
|
||||
margin-left: 8px;
|
||||
|
||||
.role-option-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.role-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.role-description {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Role Badge
|
||||
// ===========================
|
||||
.role-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
|
||||
&.role-user {
|
||||
background-color: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
&.role-admin {
|
||||
background-color: var(--warn-light);
|
||||
color: var(--warn-color);
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Warning Box
|
||||
// ===========================
|
||||
.warning-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: var(--warn-light);
|
||||
border-left: 4px solid var(--warn-color);
|
||||
border-radius: 4px;
|
||||
margin-top: 16px;
|
||||
|
||||
> mat-icon {
|
||||
color: var(--warn-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--warn-dark);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Info Box
|
||||
// ===========================
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: var(--info-light);
|
||||
border-left: 4px solid var(--info-color);
|
||||
border-radius: 4px;
|
||||
margin-top: 16px;
|
||||
|
||||
> mat-icon {
|
||||
color: var(--info-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--info-dark);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
|
||||
li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Confirmation Step
|
||||
// ===========================
|
||||
.confirmation-message {
|
||||
.change-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background-color: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.change-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
|
||||
.change-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.change-value {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.change-arrow {
|
||||
mat-icon {
|
||||
color: var(--primary-color);
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.final-warning {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background-color: var(--error-light);
|
||||
border: 2px solid var(--error-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
mat-icon {
|
||||
color: var(--error-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
color: var(--error-dark);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.confirmation-question {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dialog Actions
|
||||
// ===========================
|
||||
mat-dialog-actions {
|
||||
padding: 16px 0 0;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
|
||||
button {
|
||||
mat-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
mat-spinner {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Responsive Design
|
||||
// ===========================
|
||||
@media (max-width: 767px) {
|
||||
.role-update-dialog {
|
||||
.dialog-content {
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
.user-details {
|
||||
width: 100%;
|
||||
|
||||
.current-role {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mat-dialog-actions {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dark Mode Support
|
||||
// ===========================
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.role-update-dialog {
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0a0;
|
||||
--bg-primary: #1e1e1e;
|
||||
--bg-secondary: #2a2a2a;
|
||||
--divider-color: #404040;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Component, Inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatRadioModule } from '@angular/material/radio';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { AdminUser } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* Dialog data interface
|
||||
*/
|
||||
export interface RoleUpdateDialogData {
|
||||
user: AdminUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* RoleUpdateDialogComponent
|
||||
*
|
||||
* Modal dialog for updating user role between User and Admin.
|
||||
*
|
||||
* Features:
|
||||
* - Role selector (User/Admin)
|
||||
* - Current role display
|
||||
* - Warning message when demoting admin
|
||||
* - Confirmation step before applying change
|
||||
* - Loading state during update
|
||||
* - Returns selected role or null on cancel
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-role-update-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatRadioModule,
|
||||
FormsModule,
|
||||
MatProgressSpinnerModule
|
||||
],
|
||||
templateUrl: './role-update-dialog.component.html',
|
||||
styleUrl: './role-update-dialog.component.scss'
|
||||
})
|
||||
export class RoleUpdateDialogComponent {
|
||||
// Selected role (initialize with current role)
|
||||
selectedRole: 'user' | 'admin';
|
||||
|
||||
// Component state
|
||||
readonly isLoading = signal<boolean>(false);
|
||||
readonly showConfirmation = signal<boolean>(false);
|
||||
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<RoleUpdateDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: RoleUpdateDialogData
|
||||
) {
|
||||
this.selectedRole = data.user.role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if role has changed
|
||||
*/
|
||||
get hasRoleChanged(): boolean {
|
||||
return this.selectedRole !== this.data.user.role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if demoting from admin to user
|
||||
*/
|
||||
get isDemotingAdmin(): boolean {
|
||||
return this.data.user.role === 'admin' && this.selectedRole === 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if promoting to admin
|
||||
*/
|
||||
get isPromotingToAdmin(): boolean {
|
||||
return this.data.user.role === 'user' && this.selectedRole === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role display label
|
||||
*/
|
||||
getRoleLabel(role: 'user' | 'admin'): string {
|
||||
return role === 'admin' ? 'Administrator' : 'Regular User';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role description
|
||||
*/
|
||||
getRoleDescription(role: 'user' | 'admin'): string {
|
||||
if (role === 'admin') {
|
||||
return 'Full access to admin panel, user management, and system settings';
|
||||
}
|
||||
return 'Standard user access with quiz and profile management';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle next button click
|
||||
* Shows confirmation if role changed, otherwise closes dialog
|
||||
*/
|
||||
onNext(): void {
|
||||
if (!this.hasRoleChanged) {
|
||||
this.dialogRef.close(null);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showConfirmation.set(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Go back to role selection
|
||||
*/
|
||||
onBack(): void {
|
||||
this.showConfirmation.set(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm role update
|
||||
*/
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(this.selectedRole);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel and close dialog
|
||||
*/
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<div class="status-dialog">
|
||||
<!-- Dialog Header -->
|
||||
<div class="dialog-header" [class.activate-header]="data.action === 'activate'" [class.deactivate-header]="data.action === 'deactivate'">
|
||||
<mat-icon class="dialog-icon">{{ dialogIcon }}</mat-icon>
|
||||
<h2 mat-dialog-title>{{ actionVerb }} User Account</h2>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Content -->
|
||||
<mat-dialog-content>
|
||||
<!-- User Info -->
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
@if (data.user.profilePicture) {
|
||||
<img [src]="data.user.profilePicture" [alt]="data.user.username">
|
||||
} @else {
|
||||
<div class="avatar-placeholder">
|
||||
{{ data.user.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<div class="username">{{ data.user.username }}</div>
|
||||
<div class="email">{{ data.user.email }}</div>
|
||||
<div class="role-badge" [class]="'role-' + data.user.role.toLowerCase()">
|
||||
{{ data.user.role }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning Message -->
|
||||
<div class="warning-box" [class.activate-warning]="data.action === 'activate'" [class.deactivate-warning]="data.action === 'deactivate'">
|
||||
<mat-icon>{{ data.action === 'activate' ? 'info' : 'warning' }}</mat-icon>
|
||||
<div class="warning-content">
|
||||
<div class="warning-title">
|
||||
@if (data.action === 'activate') {
|
||||
<span>Reactivate Account</span>
|
||||
} @else {
|
||||
<span>Deactivate Account</span>
|
||||
}
|
||||
</div>
|
||||
<div class="warning-message">
|
||||
@if (data.action === 'activate') {
|
||||
<span>Are you sure you want to activate <strong>{{ data.user.username }}</strong>'s account?</span>
|
||||
} @else {
|
||||
<span>Are you sure you want to deactivate <strong>{{ data.user.username }}</strong>'s account?</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consequences -->
|
||||
<div class="consequences">
|
||||
<div class="consequences-title">This action will:</div>
|
||||
<ul class="consequences-list">
|
||||
@for (consequence of consequences; track consequence) {
|
||||
<li>{{ consequence }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Additional Note -->
|
||||
@if (data.action === 'deactivate') {
|
||||
<div class="info-box">
|
||||
<mat-icon>info</mat-icon>
|
||||
<div class="info-content">
|
||||
<strong>Note:</strong> This is a soft delete. User data is preserved and the account can be reactivated at any time.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="info-box">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
<div class="info-content">
|
||||
<strong>Note:</strong> The user will be able to access their account immediately after activation.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</mat-dialog-content>
|
||||
|
||||
<!-- Dialog Actions -->
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="onCancel()">
|
||||
<mat-icon>close</mat-icon>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
|
||||
<button mat-raised-button [color]="buttonColor" (click)="onConfirm()">
|
||||
<mat-icon>{{ dialogIcon }}</mat-icon>
|
||||
<span>{{ actionVerb }} User</span>
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
</div>
|
||||
@@ -0,0 +1,387 @@
|
||||
.status-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
min-width: 400px;
|
||||
max-width: 550px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
min-width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dialog Header
|
||||
// ===========================
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 2px solid;
|
||||
margin: 0 0 20px 0;
|
||||
|
||||
.dialog-icon {
|
||||
font-size: 32px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.activate-header {
|
||||
border-bottom-color: var(--mat-accent-main, #00bcd4);
|
||||
|
||||
.dialog-icon {
|
||||
color: var(--mat-accent-main, #00bcd4);
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--mat-accent-main, #00bcd4);
|
||||
}
|
||||
}
|
||||
|
||||
&.deactivate-header {
|
||||
border-bottom-color: var(--mat-warn-main, #f44336);
|
||||
|
||||
.dialog-icon {
|
||||
color: var(--mat-warn-main, #f44336);
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--mat-warn-main, #f44336);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dialog Content
|
||||
// ===========================
|
||||
mat-dialog-content {
|
||||
padding: 0 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// User Info
|
||||
// ===========================
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
background-color: var(--mat-app-surface-variant, #f5f5f5);
|
||||
border-radius: 8px;
|
||||
|
||||
.user-avatar {
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--mat-app-primary, #1976d2), var(--mat-app-accent, #00bcd4));
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
border: 2px solid var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
}
|
||||
|
||||
.user-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.username {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 14px;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
margin-top: 4px;
|
||||
|
||||
&.role-admin {
|
||||
background-color: rgba(255, 152, 0, 0.1);
|
||||
color: #ff9800;
|
||||
border: 1px solid rgba(255, 152, 0, 0.3);
|
||||
}
|
||||
|
||||
&.role-user {
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
color: #2196f3;
|
||||
border: 1px solid rgba(33, 150, 243, 0.3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
|
||||
.user-avatar {
|
||||
img,
|
||||
.avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.user-details {
|
||||
align-items: center;
|
||||
|
||||
.username {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.email {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Warning Box
|
||||
// ===========================
|
||||
.warning-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.warning-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.warning-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.activate-warning {
|
||||
background-color: rgba(0, 188, 212, 0.1);
|
||||
border-left-color: var(--mat-accent-main, #00bcd4);
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-accent-main, #00bcd4);
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
color: var(--mat-accent-dark, #0097a7);
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
}
|
||||
|
||||
&.deactivate-warning {
|
||||
background-color: rgba(244, 67, 54, 0.1);
|
||||
border-left-color: var(--mat-warn-main, #f44336);
|
||||
|
||||
mat-icon {
|
||||
color: var(--mat-warn-main, #f44336);
|
||||
}
|
||||
|
||||
.warning-title {
|
||||
color: var(--mat-warn-dark, #d32f2f);
|
||||
}
|
||||
|
||||
.warning-message {
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Consequences
|
||||
// ===========================
|
||||
.consequences {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
.consequences-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
|
||||
.consequences-list {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
li {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Info Box
|
||||
// ===========================
|
||||
.info-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background-color: rgba(33, 150, 243, 0.1);
|
||||
border-left: 4px solid var(--mat-app-primary, #1976d2);
|
||||
border-radius: 8px;
|
||||
|
||||
mat-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--mat-app-primary, #1976d2);
|
||||
}
|
||||
|
||||
.info-content {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: var(--mat-app-on-surface-variant, #757575);
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
color: var(--mat-app-on-surface, #212121);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dialog Actions
|
||||
// ===========================
|
||||
mat-dialog-actions {
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--mat-app-outline-variant, #e0e0e0);
|
||||
margin: 0;
|
||||
gap: 12px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
|
||||
mat-icon {
|
||||
font-size: 20px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column-reverse;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Dark Mode Support
|
||||
// ===========================
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.user-info {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
&.activate-warning {
|
||||
background-color: rgba(0, 188, 212, 0.15);
|
||||
}
|
||||
|
||||
&.deactivate-warning {
|
||||
background-color: rgba(244, 67, 54, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background-color: rgba(33, 150, 243, 0.15);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Component, Inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MatDialogModule, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { AdminUser } from '../../../core/models/admin.model';
|
||||
|
||||
/**
|
||||
* Dialog data interface
|
||||
*/
|
||||
export interface StatusUpdateDialogData {
|
||||
user: AdminUser;
|
||||
action: 'activate' | 'deactivate';
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusUpdateDialogComponent
|
||||
*
|
||||
* Confirmation dialog for activating or deactivating user accounts.
|
||||
*
|
||||
* Features:
|
||||
* - Clear warning message based on action
|
||||
* - User information display
|
||||
* - Consequences explanation
|
||||
* - Confirm/Cancel buttons
|
||||
* - Different colors for activate (success) vs deactivate (warn)
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-status-update-dialog',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
MatDialogModule,
|
||||
MatButtonModule,
|
||||
MatIconModule
|
||||
],
|
||||
templateUrl: './status-update-dialog.component.html',
|
||||
styleUrl: './status-update-dialog.component.scss'
|
||||
})
|
||||
export class StatusUpdateDialogComponent {
|
||||
constructor(
|
||||
public dialogRef: MatDialogRef<StatusUpdateDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: StatusUpdateDialogData
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get action verb (present tense)
|
||||
*/
|
||||
get actionVerb(): string {
|
||||
return this.data.action === 'activate' ? 'Activate' : 'Deactivate';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get action verb (past tense)
|
||||
*/
|
||||
get actionVerbPast(): string {
|
||||
return this.data.action === 'activate' ? 'activated' : 'deactivated';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dialog icon based on action
|
||||
*/
|
||||
get dialogIcon(): string {
|
||||
return this.data.action === 'activate' ? 'check_circle' : 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get button color based on action
|
||||
*/
|
||||
get buttonColor(): 'accent' | 'warn' {
|
||||
return this.data.action === 'activate' ? 'accent' : 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consequences list based on action
|
||||
*/
|
||||
get consequences(): string[] {
|
||||
if (this.data.action === 'activate') {
|
||||
return [
|
||||
'User will regain access to their account',
|
||||
'Can login and use the platform normally',
|
||||
'All previous data will be restored',
|
||||
'Quiz history and bookmarks remain intact'
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
'User will lose access to their account immediately',
|
||||
'Cannot login until account is reactivated',
|
||||
'All sessions will be terminated',
|
||||
'Data is preserved but inaccessible to user',
|
||||
'User will not receive any notifications'
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm action
|
||||
*/
|
||||
onConfirm(): void {
|
||||
this.dialogRef.close(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel action
|
||||
*/
|
||||
onCancel(): void {
|
||||
this.dialogRef.close(false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user