first commit

This commit is contained in:
AD2025
2025-12-27 22:00:37 +02:00
commit 41e3d43129
179 changed files with 46444 additions and 0 deletions

View File

@@ -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>

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}