Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---
Expand Down
7 changes: 7 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand Down
26 changes: 26 additions & 0 deletions src/app/core/models/statistics.model.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 1 addition & 1 deletion src/app/core/router-manager/horizontal-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
Original file line number Diff line number Diff line change
@@ -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]),
},
];
}
27 changes: 27 additions & 0 deletions src/app/core/services/api-service/statistics.service.ts
Original file line number Diff line number Diff line change
@@ -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<IPaginationResponse<ExerciseStatisticsResponse[]>>
>(API_CONFIG.ENDPOINTS.GET.GET_EXERCISE_STATISTICS_ADMIN(page, size));
}

getAdminSummary() {
return this.api.get<ApiResponse<SummaryStatisticsAdmin>>(
API_CONFIG.ENDPOINTS.GET.GET_SUMMARY_STATISTICS_ADMIN
);
}
}
6 changes: 5 additions & 1 deletion src/app/core/services/config-service/api.enpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ <h2>{{ exercise.title }}</h2>
(click)="toggleMainDropdown()"
>
<i class="fa fa-edit"></i>
<span>Sửa bài tập</span>
<!-- <span>Sửa bài tập</span> -->
</button>
@if (isMainDropdownOpen) {
<div class="edit-dropdown">
Expand All @@ -49,7 +49,7 @@ <h2>{{ exercise.title }}</h2>
(click)="openConfirmDelete()"
>
<i class="fa fa-trash"></i>
<span>Xóa bài tập</span>
<!-- <span>Xóa bài tập</span> -->
</button>
</div>
}
Expand Down Expand Up @@ -158,7 +158,7 @@ <h2>{{ exercise.title }}</h2>
<div class="stats">
<span title="Số câu hỏi"
><i class="fa-regular fa-question-circle"></i>
{{ exercise.quizDetail?.totalElements ?? 0 }}</span
{{ exercise.quizDetail?.totalElements ?? 0 }} Câu</span
>
<span title="Thời lượng"
><i class="fa-regular fa-hourglass-half"></i>
Expand Down Expand Up @@ -199,6 +199,8 @@ <h2>{{ exercise.title }}</h2>
@for (q of exercise.quizDetail?.questions; track q; let i = $index) {
<div class="question-item">
<div class="question-header">
<div class="question-number-index">Câu hỏi số {{ i + 1 }}</div>

<div class="btn-control">
@if (isActionActive) {
<button
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
padding: 20px 18px 18px 18px;
max-width: 400px;
min-width: 260px;
min-width: 300px;
.header {
display: flex;
// flex-direction: column;
Expand Down Expand Up @@ -341,6 +341,10 @@
}
}
}

.question-number-index {
font-weight: 700;
}
}
.confirm-modal-overlay {
position: fixed;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<div class="stats-container">
<header class="stats-header">
<h1>Bảng thống kê bài tập</h1>
<p>Tổng quan về hiệu suất và tương tác của các bài tập.</p>
</header>

@if (isLoading) {
<div class="loading-overlay">
<div class="spinner"></div>
<p>Đang tải dữ liệu...</p>
</div>
} @else if (error && !isLoading) {
<div class="error-message">
<p>{{ error }}</p>
<button class="btn-retry" (click)="loadStats()">Thử lại</button>
</div>
} @else {
<div class="stats-content">
@if (statsData.length > 0) {
<div class="table-wrapper">
<table class="stats-table">
<thead>
<tr>
<th>Tiêu đề</th>
<th>Loại</th>
<th>Trạng thái</th>
<th>Lượt hoàn thành</th>
<th>Tỷ lệ hoàn thành</th>
<th>Lượt nộp bài</th>
<th>Tỷ lệ qua</th>
<th>Điểm TB</th>
<th>Lần nộp cuối</th>
</tr>
</thead>
<tbody>
@for (stat of statsData; track $index) {
<tr>
<td class="cell-title" [title]="stat.title">
{{ stat.title }}
</td>
<td>
<span
class="badge"
[class.badge-quiz]="stat.exerciseType === 'QUIZ'"
[class.badge-coding]="stat.exerciseType === 'CODING'"
>
{{ stat.exerciseType }}
</span>
</td>
<td>{{ stat.visibility ? "Công khai" : "Riêng tư" }}</td>
<td>{{ stat.completedCount }} / {{ stat.assignedCount }}</td>
<td>
<div
class="progress-bar-container"
[title]="stat.completionRate | percent : '1.0-2'"
>
<div
class="progress-bar-fill"
[style.width.%]="stat.completionRate * 100"
></div>
</div>
</td>
<td>{{ stat.passedCount }} / {{ stat.submissionCount }}</td>
<td>
<div
class="progress-bar-container"
[title]="stat.passRate | percent : '0.0-1'"
>
<div
class="progress-bar-fill success"
[style.width.%]="stat.passRate * 100"
></div>
</div>
</td>
<td>{{ stat.avgScore | number : "1.1-2" }}</td>
<td>{{ stat.lastSubmissionAt | date : "dd/MM/yyyy HH:mm" }}</td>
</tr>
}
</tbody>
</table>
</div>

@if (totalPages > 1) {
<nav class="pagination-controls" aria-label="Page navigation">
<button
(click)="onPageChange(currentPage - 1)"
[disabled]="currentPage === 1"
>
&laquo;
</button>
@for (page of getPagesArray(); track page) {
<button
(click)="onPageChange(page)"
[class.active]="page === currentPage"
>
{{ page }}
</button>
}
<button
(click)="onPageChange(currentPage + 1)"
[disabled]="currentPage === totalPages"
>
&raquo;
</button>
</nav>
} } @else {
<div class="no-data-message">
<p>Không có dữ liệu thống kê để hiển thị.</p>
</div>
}
</div>
}
</div>
Loading
Loading