diff --git a/README.md b/README.md index 4f272ecf..59ce13b4 100644 --- a/README.md +++ b/README.md @@ -165,22 +165,18 @@ Dự án Angular này áp dụng nhiều **design pattern chuẩn** để đảm - **Guards** (`AuthGuard`, `RoleGuard`) áp dụng strategy để quyết định quyền truy cập. - **Environments** (`dev`, `staging`, `prod`) chọn cấu hình phù hợp theo môi trường. -### 6. Facade Pattern -- **Router Manager** gom logic routing vào 1 chỗ. -- **NgRx Facade** (nếu sử dụng) để tách component khỏi chi tiết state management. - -### 7. Template Pattern +### 6. Template Pattern - Các **layout** (`header`, `sidebar`, `footer`) định nghĩa khung sẵn, feature module nhúng nội dung vào khu vực content. -### 8. Smart & Dumb Components (Container/Presenter) +### 7. Smart & Dumb Components (Container/Presenter) - Component **Smart** xử lý logic, data fetching, và state. - Component **Dumb** nhận `@Input()` và emit `@Output()`, chỉ chịu trách nhiệm hiển thị. -### 9. Decorator Pattern +### 8. Decorator Pattern - Angular decorators: `@Component`, `@Directive`, `@Pipe`, `@Injectable`. - Cho phép mở rộng chức năng mà không sửa code gốc. -### 10. DTO Pattern +### 9. DTO Pattern - Các **model** trong `core/models` quản lý dữ liệu giữa API và component, đảm bảo type safety. --- diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 35b64a26..acfb194b 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -138,6 +138,13 @@ export const routes: Routes = [ (m) => m.ServiceAndPaymentModule ), }, + { + path: 'codecampus-statistics', + loadChildren: () => + import('./features/statistics/statistics.module').then( + (m) => m.StatisticsModule + ), + }, { path: 'organization', loadChildren: () => diff --git a/src/app/core/models/statistics.model.ts b/src/app/core/models/statistics.model.ts new file mode 100644 index 00000000..5745642d --- /dev/null +++ b/src/app/core/models/statistics.model.ts @@ -0,0 +1,26 @@ +export type ExerciseStatisticsResponse = { + exerciseId: string; + title: string; + exerciseType: 'QUIZ' | 'CODING'; + visibility: boolean; + orgId: string | null; + assignedCount: number; + completedCount: number; + completionRate: number; + submissionCount: number; + passedCount: number; + passRate: number; + avgScore: number; + lastSubmissionAt: string; +}; + +export type SummaryStatisticsAdmin = { + totalExercises: number; + totalVisibleExercises: number; + totalQuiz: number; + totalCoding: number; + totalAssignments: number; + totalCompletedAssignments: number; + totalSubmissions: number; + totalPassedSubmissions: number; +}; diff --git a/src/app/core/router-manager/horizontal-menu.ts b/src/app/core/router-manager/horizontal-menu.ts index 2cca7818..4ce6843c 100644 --- a/src/app/core/router-manager/horizontal-menu.ts +++ b/src/app/core/router-manager/horizontal-menu.ts @@ -34,7 +34,7 @@ export function getNavHorizontalItems(roles: string[]): SidebarItem[] { }, { id: 'statistics', - path: '/statistics', + path: '/codecampus-statistics/admin-exercise-statistics', label: 'Thống kê', icon: 'fas fa-chart-bar', isVisible: !roles.includes(auth_lv2[0]), diff --git a/src/app/core/router-manager/vetical-menu-dynamic/statistics-vetical-menu.ts b/src/app/core/router-manager/vetical-menu-dynamic/statistics-vetical-menu.ts new file mode 100644 index 00000000..18070798 --- /dev/null +++ b/src/app/core/router-manager/vetical-menu-dynamic/statistics-vetical-menu.ts @@ -0,0 +1,22 @@ +import { SidebarItem } from '../../models/data-handle'; + +export function sidebarStatisticsRouter(roles: string[]): SidebarItem[] { + const auth_lv2 = ['ADMIN']; + + return [ + { + id: 'list-exericse-satistics', + path: '/codecampus-statistics/admin-exercise-statistics', + label: 'Thống kê bài tập', + icon: 'fa-solid fa-file-contract', + isVisible: !roles.includes(auth_lv2[0]), + }, + { + id: 'chart-exercise-statistics', + path: '/codecampus-statistics/admin-chart-exercise-statistics', + label: 'Biểu đồ thống kê', + icon: 'fa-solid fa-chart-pie', + isVisible: !roles.includes(auth_lv2[0]), + }, + ]; +} diff --git a/src/app/core/services/api-service/statistics.service.ts b/src/app/core/services/api-service/statistics.service.ts new file mode 100644 index 00000000..25dd0f38 --- /dev/null +++ b/src/app/core/services/api-service/statistics.service.ts @@ -0,0 +1,27 @@ +import { Injectable } from '@angular/core'; +import { ApiMethod } from '../config-service/api.methods'; +import { ApiResponse, IPaginationResponse } from '../../models/api-response'; +import { + ExerciseStatisticsResponse, + SummaryStatisticsAdmin, +} from '../../models/statistics.model'; +import { API_CONFIG } from '../config-service/api.enpoints'; + +@Injectable({ + providedIn: 'root', +}) +export class StatisticsService { + constructor(private api: ApiMethod) {} + + getAdminExerciseStats(page: number, size: number) { + return this.api.get< + ApiResponse> + >(API_CONFIG.ENDPOINTS.GET.GET_EXERCISE_STATISTICS_ADMIN(page, size)); + } + + getAdminSummary() { + return this.api.get>( + API_CONFIG.ENDPOINTS.GET.GET_SUMMARY_STATISTICS_ADMIN + ); + } +} diff --git a/src/app/core/services/config-service/api.enpoints.ts b/src/app/core/services/config-service/api.enpoints.ts index 9c3a7fc6..50a4bd2e 100644 --- a/src/app/core/services/config-service/api.enpoints.ts +++ b/src/app/core/services/config-service/api.enpoints.ts @@ -214,6 +214,10 @@ export const API_CONFIG = { ) => `/notification/my?page=${page}&size=${size}&readStatus=${readStatus}`, GET_COUNT_MY_UNREAD: '/notification/my/unread-count', + + GET_EXERCISE_STATISTICS_ADMIN: (page: number, size: number) => + `/submission/stats/admin/exercises?page=${page}&size=${size}`, + GET_SUMMARY_STATISTICS_ADMIN: '/submission/stats/admin/summary', }, POST: { LOGIN: '/identity/auth/login', @@ -288,7 +292,7 @@ export const API_CONFIG = { ADD_ADMIN: '/identity/admin', ADD_STUDENT: '/identity/teacher', ADD_TEACHER: '/identity/user', - MARK_AS_READ_NOTIFICATION: '/my/mark-read', + MARK_AS_READ_NOTIFICATION: '/notification/my/mark-read', }, PUT: { EDIT_FILE: (id: string) => `/file/api/FileDocument/edit/${id}`, diff --git a/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html b/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html index 426a6300..546e64d4 100644 --- a/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html +++ b/src/app/features/excercise/exercise-pages/exercise-details/exercise-details.component.html @@ -28,7 +28,7 @@

{{ exercise.title }}

(click)="toggleMainDropdown()" > - Sửa bài tập + @if (isMainDropdownOpen) {
@@ -49,7 +49,7 @@

{{ exercise.title }}

(click)="openConfirmDelete()" > - Xóa bài tập +
} @@ -158,7 +158,7 @@

{{ exercise.title }}

- {{ exercise.quizDetail?.totalElements ?? 0 }} @@ -199,6 +199,8 @@

{{ exercise.title }}

@for (q of exercise.quizDetail?.questions; track q; let i = $index) {
+
Câu hỏi số {{ i + 1 }}
+
@if (isActionActive) { +
+ } @else { +
+ @if (statsData.length > 0) { +
+ + + + + + + + + + + + + + + + @for (stat of statsData; track $index) { + + + + + + + + + + + + } + +
Tiêu đềLoạiTrạng tháiLượt hoàn thànhTỷ lệ hoàn thànhLượt nộp bàiTỷ lệ quaĐiểm TBLần nộp cuối
+ {{ stat.title }} + + + {{ stat.exerciseType }} + + {{ stat.visibility ? "Công khai" : "Riêng tư" }}{{ stat.completedCount }} / {{ stat.assignedCount }} +
+
+
+
{{ stat.passedCount }} / {{ stat.submissionCount }} +
+
+
+
{{ stat.avgScore | number : "1.1-2" }}{{ stat.lastSubmissionAt | date : "dd/MM/yyyy HH:mm" }}
+
+ + @if (totalPages > 1) { + + } } @else { +
+

Không có dữ liệu thống kê để hiển thị.

+
+ } +
+ } +
diff --git a/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.scss b/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.scss new file mode 100644 index 00000000..bb91a00b --- /dev/null +++ b/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.scss @@ -0,0 +1,205 @@ +// Sử dụng các biến CSS đã được định nghĩa trong theme của bạn +:host { + display: block; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, + Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +.stats-container { + padding: 24px; + background-color: var(--background-color); + color: var(--text-color); + transition: background-color 0.3s, color 0.3s; +} + +.stats-header { + margin-bottom: 24px; + h1 { + color: var(--title-text); + font-size: 1.2rem; + font-weight: 700; + margin-bottom: 8px; + margin-top: 0; + } + p { + color: var(--text-muted-color); + font-size: 1rem; + } +} + +// ---- Trạng thái Tải và Lỗi ---- +.loading-overlay, +.error-message, +.no-data-message { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 60px 20px; + border-radius: 8px; + background-color: var(--background-color-secondary); + border: 1px solid var(--border-color); + p { + font-size: 1.1rem; + margin-top: 16px; + } +} + +.error-message { + color: var(--error-color); +} + +.btn-retry { + margin-top: 16px; + padding: 10px 20px; + border: 1px solid var(--button-color); + background-color: transparent; + color: var(--button-color); + border-radius: 5px; + cursor: pointer; + transition: all 0.2s ease; + &:hover { + background-color: var(--button-color-hover); + color: var(--reverse-color-text); + } +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid var(--primary-color-lighter); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +// ---- Bảng dữ liệu ---- +.stats-content { + height: calc(100vh - 265px); + overflow: auto; +} + +.table-wrapper { + overflow-x: auto; // Đảm bảo bảng có thể cuộn ngang trên màn hình nhỏ + height: calc(100vh - 330px); + overflow: auto; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + background-color: var(--surface-color); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); + + th, + td { + padding: 14px 18px; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + thead { + background-color: var(--background-color-secondary); + th { + font-weight: 600; + color: var(--accent-color); + font-size: 0.85rem; + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + + tbody tr { + transition: background-color 0.2s ease; + &:hover { + background-color: var(--background-color-secondary); + } + &:last-child td { + border-bottom: none; + } + } + + .cell-title { + font-weight: 500; + color: var(--primary-color); + max-width: 250px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +// ---- Các element phụ ---- +.badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + font-weight: 600; + background-color: var(--tags-color); + color: var(--title-text); +} + +.progress-bar-container { + width: 100px; + height: 8px; + background-color: var(--primary-color-lightest); + border-radius: 4px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background-color: var(--progress-color); + border-radius: 4px; + transition: width 0.5s ease-in-out; + &.success { + background-color: var(--success-color); + } +} + +// ---- Phân trang ---- +.pagination-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-top: 24px; + + button { + border: 1px solid var(--border-color); + background-color: var(--surface-color); + color: var(--text-color); + padding: 8px 14px; + cursor: pointer; + border-radius: 5px; + transition: background-color 0.2s, color 0.2s, border-color 0.2s; + + &:hover:not(:disabled) { + background-color: var(--hover-color); + border-color: var(--hover-color); + color: var(--reverse-color-text); + } + + &.active { + background-color: var(--primary-color); + color: var(--reverse-color-text); + border-color: var(--primary-color); + font-weight: bold; + } + + &:disabled { + background-color: var(--disabled-color); + color: var(--text-muted-color); + cursor: not-allowed; + opacity: 0.6; + } + } +} diff --git a/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.ts b/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.ts new file mode 100644 index 00000000..3c6a28c6 --- /dev/null +++ b/src/app/features/statistics/pages/exercise-admin-statistics/exercise-admin-statistics.component.ts @@ -0,0 +1,89 @@ +import { Component } from '@angular/core'; +import { StatisticsService } from '../../../../core/services/api-service/statistics.service'; +import { ExerciseStatisticsResponse } from '../../../../core/models/statistics.model'; +import { Subscription } from 'rxjs/internal/Subscription'; +import { finalize } from 'rxjs/internal/operators/finalize'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'app-exercise-admin-statistics', + imports: [CommonModule], + templateUrl: './exercise-admin-statistics.component.html', + styleUrl: './exercise-admin-statistics.component.scss', +}) +export class ExerciseAdminStatisticsComponent { + // Dữ liệu và trạng thái + statsData: ExerciseStatisticsResponse[] = []; + isLoading = false; + error: string | null = null; + private statsSubscription: Subscription | undefined; + + // Thuộc tính phân trang + currentPage = 1; + totalPages = 0; + pageSize = 8; // Bạn có thể thay đổi số lượng item mỗi trang ở đây + totalElements = 0; + + constructor(private statisticsService: StatisticsService) {} + + ngOnInit(): void { + this.loadStats(); + } + + loadStats(page: number = 1): void { + if (this.isLoading) return; // Ngăn gọi lại khi đang tải + + this.isLoading = true; + this.error = null; + this.currentPage = page; + + this.statsSubscription = this.statisticsService + .getAdminExerciseStats(this.currentPage, this.pageSize) + .pipe( + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe({ + next: (response) => { + if (response && response.result) { + const paginationResult = response.result; + this.statsData = paginationResult.data; + this.currentPage = paginationResult.currentPage; + this.totalPages = paginationResult.totalPages; + this.totalElements = paginationResult.totalElements; + } else { + this.error = 'Dữ liệu trả về không hợp lệ.'; + this.statsData = []; + } + }, + error: (err) => { + console.error('Lỗi khi lấy dữ liệu thống kê:', err); + this.error = 'Đã có lỗi xảy ra. Vui lòng thử lại sau.'; + this.statsData = []; + }, + }); + } + + // Chuyển trang + onPageChange(newPage: number): void { + if ( + newPage >= 1 && + newPage <= this.totalPages && + newPage !== this.currentPage + ) { + this.loadStats(newPage); + } + } + + // Tạo mảng số trang để dễ dàng lặp trong template + getPagesArray(): number[] { + return Array(this.totalPages) + .fill(0) + .map((x, i) => i + 1); + } + + ngOnDestroy(): void { + this.statsSubscription?.unsubscribe(); + } +} diff --git a/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.html b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.html new file mode 100644 index 00000000..f53f4815 --- /dev/null +++ b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.html @@ -0,0 +1,82 @@ +
+
+

Thống kê Tổng quan

+

Các chỉ số chính về hoạt động trên hệ thống.

+
+ + @if (isLoading) { +
+
+

Đang tải dữ liệu...

+
+ } @else if (error && !isLoading) { +
+

{{ error }}

+ +
+ } @else if (summaryData && !isLoading && !error) { +
+
+
+
Tổng số bài tập
+
{{ summaryData.totalExercises | number }}
+
+ {{ summaryData.totalVisibleExercises | number }} công khai +
+
+
+
Tổng lượt giao bài
+
+ {{ summaryData.totalAssignments | number }} +
+
+ {{ summaryData.totalCompletedAssignments | number }} đã hoàn thành +
+
+
+
Tổng lượt nộp bài
+
+ {{ summaryData.totalSubmissions | number }} +
+
+ {{ summaryData.totalPassedSubmissions | number }} bài đạt +
+
+
+ +
+ @if (exerciseTypeData.length > 0 && (exerciseTypeData[0] > 0 || + exerciseTypeData[1] > 0)) { +
+ +
+ } @if (completionRateData.length > 0 && (completionRateData[0] > 0 || + completionRateData[1] > 0)) { +
+ +
+ } @if (passRateData.length > 0 && (passRateData[0] > 0 || passRateData[1] + > 0)) { +
+ +
+ } +
+
+ } +
diff --git a/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.scss b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.scss new file mode 100644 index 00000000..68dd86e8 --- /dev/null +++ b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.scss @@ -0,0 +1,132 @@ +:host { + display: block; +} + +.summary-container { + padding: 24px; + background-color: var(--background-color); + color: var(--text-color); + height: calc(100vh - 180px); + overflow: auto; + transition: background-color 0.3s, color 0.3s; +} + +.summary-header { + margin-bottom: 32px; + h1 { + color: var(--title-text); + font-size: 1.2rem; + font-weight: 700; + margin-top: 0; + } + p { + color: var(--text-muted-color); + font-size: 1rem; + } +} + +// ---- Grid cho các thẻ ---- +.summary-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; +} + +.summary-card { + background-color: var(--surface-color); + padding: 24px; + border-radius: 12px; + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); + } + + .card-label { + font-size: 0.9rem; + color: var(--text-muted-color); + margin-bottom: 8px; + } + + .card-value { + font-size: 2.5rem; + font-weight: 700; + color: var(--primary-color); + line-height: 1.1; + } + + .card-sub-value { + font-size: 0.85rem; + color: var(--accent-color); + margin-top: 12px; + } +} + +// ---- Grid cho các biểu đồ ---- +.charts-grid { + margin-top: 48px; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 30px; +} + +.chart-wrapper { + background-color: var(--surface-color); + padding: 20px; + border-radius: 12px; + border: 1px solid var(--border-color); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); + // Đảm bảo chart component được căn giữa + display: flex; + justify-content: center; + align-items: center; +} + +// ---- Các style cho Loading, Error (có thể dùng chung) ---- +.loading-overlay, +.error-message { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 60px 20px; + border-radius: 8px; + background-color: var(--background-color-secondary); +} + +.spinner { + width: 50px; + height: 50px; + border: 5px solid var(--primary-color-lighter); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.error-message p { + color: var(--error-color); +} + +.btn-retry { + margin-top: 16px; + padding: 10px 20px; + border: 1px solid var(--button-color); + color: var(--button-color); + background-color: transparent; + border-radius: 5px; + cursor: pointer; + &:hover { + background-color: var(--button-color-hover); + color: var(--reverse-color-text); + } +} diff --git a/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.ts b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.ts new file mode 100644 index 00000000..648ffaac --- /dev/null +++ b/src/app/features/statistics/pages/summary-statistics/summary-statistics.component.ts @@ -0,0 +1,99 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import { ApexNonAxisChartSeries } from 'ng-apexcharts'; +import { PieChartComponent } from '../../../../shared/components/my-shared/pie-chart/pie-chart'; +import { StatisticsService } from '../../../../core/services/api-service/statistics.service'; +import { SummaryStatisticsAdmin } from '../../../../core/models/statistics.model'; + +@Component({ + selector: 'app-summary-statistics', + imports: [CommonModule, PieChartComponent], + templateUrl: './summary-statistics.component.html', + styleUrl: './summary-statistics.component.scss', +}) +export class SummaryStatisticsComponent { + summaryData: SummaryStatisticsAdmin | null = null; + isLoading = false; + error: string | null = null; + private summarySubscription: Subscription | undefined; + + // Dữ liệu cho các biểu đồ + exerciseTypeData: ApexNonAxisChartSeries = []; + exerciseTypeLabels: string[] = []; + + completionRateData: ApexNonAxisChartSeries = []; + completionRateLabels: string[] = []; + + passRateData: ApexNonAxisChartSeries = []; + passRateLabels: string[] = []; + + constructor(private statisticsService: StatisticsService) {} + + ngOnInit(): void { + this.loadSummary(); + } + + loadSummary(): void { + this.isLoading = true; + this.error = null; + this.summarySubscription = this.statisticsService + .getAdminSummary() + .pipe( + finalize(() => { + this.isLoading = false; + }) + ) + .subscribe({ + next: (response) => { + if (response && response.result) { + this.summaryData = response.result; + this.prepareChartData(); // Chuẩn bị dữ liệu sau khi nhận thành công + } else { + this.error = 'Dữ liệu trả về không hợp lệ.'; + } + }, + error: (err) => { + console.error('Lỗi khi lấy dữ liệu tổng quan:', err); + this.error = 'Đã có lỗi xảy ra. Vui lòng thử lại sau.'; + }, + }); + } + + prepareChartData(): void { + if (!this.summaryData) return; + + // 1. Biểu đồ Phân loại Bài tập + this.exerciseTypeLabels = ['Quiz', 'Coding']; + this.exerciseTypeData = [ + this.summaryData.totalQuiz, + this.summaryData.totalCoding, + ]; + + // 2. Biểu đồ Tỷ lệ Hoàn thành + const incompleteAssignments = + this.summaryData.totalAssignments - + this.summaryData.totalCompletedAssignments; + this.completionRateLabels = ['Đã hoàn thành', 'Chưa hoàn thành']; + this.completionRateData = [ + this.summaryData.totalCompletedAssignments, + incompleteAssignments, + ]; + + // 3. Biểu đồ Tỷ lệ Nộp bài Đạt + const failedSubmissions = + this.summaryData.totalSubmissions - + this.summaryData.totalPassedSubmissions; + this.passRateLabels = ['Đạt', 'Chưa đạt']; + this.passRateData = [ + this.summaryData.totalPassedSubmissions, + failedSubmissions, + ]; + } + + ngOnDestroy(): void { + this.summarySubscription?.unsubscribe(); + } +} diff --git a/src/app/features/statistics/statistics-layout/statistics-layout.component.html b/src/app/features/statistics/statistics-layout/statistics-layout.component.html new file mode 100644 index 00000000..c71c4aab --- /dev/null +++ b/src/app/features/statistics/statistics-layout/statistics-layout.component.html @@ -0,0 +1,14 @@ +
+
+ @if (showSidebar) { + + + } +
+ +
+
+
diff --git a/src/app/features/statistics/statistics-layout/statistics-layout.component.scss b/src/app/features/statistics/statistics-layout/statistics-layout.component.scss new file mode 100644 index 00000000..5b516cae --- /dev/null +++ b/src/app/features/statistics/statistics-layout/statistics-layout.component.scss @@ -0,0 +1,18 @@ +.statistic-layout-container { + display: flex; + flex-direction: column; + width: 100%; + + .main-statistic-container { + display: flex; + flex-direction: row; + width: 100%; + height: 100%; + .statistic-content-container { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + } + } +} diff --git a/src/app/features/statistics/statistics-layout/statistics-layout.component.ts b/src/app/features/statistics/statistics-layout/statistics-layout.component.ts new file mode 100644 index 00000000..54da12e7 --- /dev/null +++ b/src/app/features/statistics/statistics-layout/statistics-layout.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { MainSidebarComponent } from '../../../shared/components/fxdonad-shared/main-sidebar/main-sidebar.component'; +import { SidebarItem } from '../../../core/models/data-handle'; +import { decodeJWT } from '../../../shared/utils/stringProcess'; +import { CommonModule } from '@angular/common'; +import { RouterModule } from '@angular/router'; +import { sidebarStatisticsRouter } from '../../../core/router-manager/vetical-menu-dynamic/statistics-vetical-menu'; + +@Component({ + selector: 'app-statistics-layout', + imports: [CommonModule, MainSidebarComponent, RouterModule], + templateUrl: './statistics-layout.component.html', + styleUrls: ['./statistics-layout.component.scss'], +}) +export class StatisticsLayoutComponent { + isSidebarCollapsed = true; + sidebarData: SidebarItem[] = []; + + showSidebar = true; + + constructor() { + const roles = decodeJWT(localStorage.getItem('token') ?? '')?.payload.roles; + this.sidebarData = sidebarStatisticsRouter(roles); + } +} diff --git a/src/app/features/statistics/statistics-routing.module.ts b/src/app/features/statistics/statistics-routing.module.ts new file mode 100644 index 00000000..a5324780 --- /dev/null +++ b/src/app/features/statistics/statistics-routing.module.ts @@ -0,0 +1,32 @@ +import { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; +import { StatisticsLayoutComponent } from './statistics-layout/statistics-layout.component'; +import { ExerciseAdminStatisticsComponent } from './pages/exercise-admin-statistics/exercise-admin-statistics.component'; +import { SummaryStatisticsComponent } from './pages/summary-statistics/summary-statistics.component'; + +const routes: Routes = [ + { + path: '', + component: StatisticsLayoutComponent, + title: 'Thống kê', + children: [ + { + path: 'admin-exercise-statistics', + component: ExerciseAdminStatisticsComponent, + data: { breadcrumb: 'Thống kê bài tập' }, + }, + { + path: 'admin-chart-exercise-statistics', + component: SummaryStatisticsComponent, + data: { breadcrumb: 'Thống kê bài tập' }, + }, + //thêm vào đây + ], + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class StatisticsRoutingModule {} diff --git a/src/app/features/statistics/statistics.module.ts b/src/app/features/statistics/statistics.module.ts new file mode 100644 index 00000000..8b24797b --- /dev/null +++ b/src/app/features/statistics/statistics.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { StatisticsRoutingModule } from './statistics-routing.module'; + +@NgModule({ + declarations: [], + imports: [ + CommonModule, + StatisticsRoutingModule, // <-- Tích hợp route vào module + ], +}) +export class StatisticsModule {} diff --git a/src/app/shared/components/fxdonad-shared/main-sidebar/main-sidebar.component.scss b/src/app/shared/components/fxdonad-shared/main-sidebar/main-sidebar.component.scss index 985c60c1..388e1332 100644 --- a/src/app/shared/components/fxdonad-shared/main-sidebar/main-sidebar.component.scss +++ b/src/app/shared/components/fxdonad-shared/main-sidebar/main-sidebar.component.scss @@ -78,7 +78,9 @@ transition: all 0.3s ease; &:hover { - background-color: rgba(223, 8, 8, 0.1); + background-color: oklch( + from var(--button-color-hover) calc(l * 3.2) c h + ); transform: translateX(3px); } diff --git a/src/app/shared/components/my-shared/header/header.ts b/src/app/shared/components/my-shared/header/header.ts index 52ad29ef..69202692 100644 --- a/src/app/shared/components/my-shared/header/header.ts +++ b/src/app/shared/components/my-shared/header/header.ts @@ -15,6 +15,7 @@ import { SetPasswordModalComponent } from '../../../../features/auth/components/ import { NotificationModalComponent } from './notification-modal/notification-modal.component'; import { NotificationSocketService } from '../../../../core/services/socket-service/notification-socket.service'; import { NotificationListService } from '../../../../core/services/api-service/notification-list.service'; +import { sendNotification } from '../../../utils/notification'; @Component({ selector: 'app-header', @@ -82,18 +83,14 @@ export class HeaderComponent { localStorage.getItem('needPasswordSetup') || 'false' ); - // 👇 Đăng ký lắng nghe notification từ socket - this.notificationService - .listenNoticeCount() - .subscribe((event: { unread: number }) => { - console.log('Header nhận count notification:', event.unread); - - // Tăng counter - this.notificationCount = event.unread; + //Đăng ký lắng nghe notification từ socket + this.notificationService.listenNoticeCount().subscribe((event) => { + this.notificationCount = event.unread; + }); - // Nếu muốn push vào modal hoặc show toast - // this.notifications.unshift(event); - }); + this.notificationService.listenNotifications().subscribe((notice) => { + sendNotification(this.store, 'Thông báo mới', notice.body, 'info'); + }); this.getCountNotice(); }