diff --git a/public/assets/background/kinhlup.png b/public/assets/background/kinhlup.png new file mode 100644 index 00000000..bd0764a1 Binary files /dev/null and b/public/assets/background/kinhlup.png differ diff --git a/public/assets/background/laptop.png b/public/assets/background/laptop.png new file mode 100644 index 00000000..621291f8 Binary files /dev/null and b/public/assets/background/laptop.png differ diff --git a/public/assets/background/tham.png b/public/assets/background/tham.png new file mode 100644 index 00000000..af6e1679 Binary files /dev/null and b/public/assets/background/tham.png differ diff --git a/public/assets/background/traidat.png b/public/assets/background/traidat.png new file mode 100644 index 00000000..cd44380d Binary files /dev/null and b/public/assets/background/traidat.png differ diff --git a/public/assets/background/traidat.svg b/public/assets/background/traidat.svg new file mode 100644 index 00000000..2353ca10 --- /dev/null +++ b/public/assets/background/traidat.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/assets/background/vi.png b/public/assets/background/vi.png new file mode 100644 index 00000000..0c71c4fe Binary files /dev/null and b/public/assets/background/vi.png differ diff --git a/public/assets/background/vo.png b/public/assets/background/vo.png new file mode 100644 index 00000000..72d223cf Binary files /dev/null and b/public/assets/background/vo.png differ diff --git a/public/assets/background/vutru.svg b/public/assets/background/vutru.svg new file mode 100644 index 00000000..d5b1b0f6 --- /dev/null +++ b/public/assets/background/vutru.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/app/core/models/statistics.model.ts b/src/app/core/models/statistics.model.ts index 5745642d..2e95240d 100644 --- a/src/app/core/models/statistics.model.ts +++ b/src/app/core/models/statistics.model.ts @@ -24,3 +24,13 @@ export type SummaryStatisticsAdmin = { totalSubmissions: number; totalPassedSubmissions: number; }; +export type PaymentStatisticsAdmin = { + day: string; + totalAmount: number; +}; +export type PaymentStatisticsUser = { + day: string; + depositAmount: number; + purchaseAmount: number; + walletBalance: number; +}; 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 index 18070798..e32e3bba 100644 --- 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 @@ -18,5 +18,19 @@ export function sidebarStatisticsRouter(roles: string[]): SidebarItem[] { icon: 'fa-solid fa-chart-pie', isVisible: !roles.includes(auth_lv2[0]), }, + { + id: 'chart-payment-statistics', + path: '/codecampus-statistics/admin-payment-statistics', + label: 'Thống kê doanh thu', + icon: 'fa-solid fa-file-invoice-dollar', + isVisible: !roles.includes(auth_lv2[0]), + }, + { + id: 'chart-user-payment-statistics', + path: '/codecampus-statistics/user-payment-statistics', + label: 'Thống kê nạp & mua', + icon: 'fa-solid fa-credit-card', + 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 index 25dd0f38..50b5f7be 100644 --- a/src/app/core/services/api-service/statistics.service.ts +++ b/src/app/core/services/api-service/statistics.service.ts @@ -3,6 +3,8 @@ import { ApiMethod } from '../config-service/api.methods'; import { ApiResponse, IPaginationResponse } from '../../models/api-response'; import { ExerciseStatisticsResponse, + PaymentStatisticsAdmin, + PaymentStatisticsUser, SummaryStatisticsAdmin, } from '../../models/statistics.model'; import { API_CONFIG } from '../config-service/api.enpoints'; @@ -24,4 +26,14 @@ export class StatisticsService { API_CONFIG.ENDPOINTS.GET.GET_SUMMARY_STATISTICS_ADMIN ); } + getAdminPaymentStats(year: number, month: number) { + return this.api.get>( + API_CONFIG.ENDPOINTS.GET.GET_PAYMENT_STATISTICS_ADMIN(year, month) + ); + } + getUserPaymentStats(year: number, month: number) { + return this.api.get>( + API_CONFIG.ENDPOINTS.GET.GET_USER_PAYMENT_STATISTICS_ADMIN(year, month) + ); + } } diff --git a/src/app/core/services/config-service/api.enpoints.ts b/src/app/core/services/config-service/api.enpoints.ts index 50a4bd2e..716cf1a1 100644 --- a/src/app/core/services/config-service/api.enpoints.ts +++ b/src/app/core/services/config-service/api.enpoints.ts @@ -217,6 +217,10 @@ export const API_CONFIG = { GET_EXERCISE_STATISTICS_ADMIN: (page: number, size: number) => `/submission/stats/admin/exercises?page=${page}&size=${size}`, + GET_PAYMENT_STATISTICS_ADMIN: (year: number, month: number) => + `/payment/payment-statistics/daily-deposit?year=${year}&month=${month}`, + GET_USER_PAYMENT_STATISTICS_ADMIN: (year: number, month: number) => + `/payment/payment-statistics/daily-statistic?year=${year}&month=${month}`, GET_SUMMARY_STATISTICS_ADMIN: '/submission/stats/admin/summary', }, POST: { diff --git a/src/app/features/landing/components/interactive-animation/interactive-animation.component.html b/src/app/features/landing/components/interactive-animation/interactive-animation.component.html index 3267c015..55380370 100644 --- a/src/app/features/landing/components/interactive-animation/interactive-animation.component.html +++ b/src/app/features/landing/components/interactive-animation/interactive-animation.component.html @@ -1,4 +1,18 @@
+ +
+
+
+
+ + +
+
+
+
+
+ +
diff --git a/src/app/features/landing/components/interactive-animation/interactive-animation.component.scss b/src/app/features/landing/components/interactive-animation/interactive-animation.component.scss index 7b76da41..c7c81add 100644 --- a/src/app/features/landing/components/interactive-animation/interactive-animation.component.scss +++ b/src/app/features/landing/components/interactive-animation/interactive-animation.component.scss @@ -1,8 +1,219 @@ .interactive-container { - // Tạo không gian để cuộn, chiều cao gấp 3 lần màn hình - height: 200vh; + height: 150vh; position: relative; - background-color: #0a0a1a; // Nền tối để nổi bật animation + overflow: hidden; + + background-image: + /* lớp overlay đen fade trong suốt ở trên/dưới */ linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 0%, + rgba(0, 0, 0, 0.6) 20%, + rgba(0, 0, 0, 0.6) 80%, + rgba(0, 0, 0, 0) 100% + ), + /* lớp ảnh vũ trụ có gradient để mờ dần */ + linear-gradient( + to bottom, + rgba(0, 0, 0, 0.6) 0%, + rgba(0, 0, 0, 0) 20%, + rgba(0, 0, 0, 0) 80%, + rgba(0, 0, 0, 0.6) 100% + ), + url("../../../../../../public/assets/background/vutru.svg"); + + background-size: cover; + background-position: center; + background-repeat: no-repeat; + + .parallax, + .sticky-wrapper { + position: relative; + z-index: 1; + } + + .parallax { + position: absolute; + background-repeat: no-repeat; + background-size: contain; + background-position: center; + will-change: transform; + } + + .parallax-earth { + top: 50%; + left: 50%; + width: 60vw; + height: 60vh; + transform: translate(-50%, -50%); + position: absolute; + z-index: 1; + + background: url("../../../../../../public/assets/background/traidat.png") + center/contain no-repeat; + + -webkit-mask-image: url("../../../../../../public/assets/background/traidat.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/traidat.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + + &::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(255, 255, 249, 0.6), + rgba(3, 18, 181, 0.6) + ); + opacity: 0.6; + pointer-events: none; + + -webkit-mask-image: url("../../../../../../public/assets/background/traidat.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/traidat.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + /* Gom cụm về góc dưới bên phải */ + .parallax-laptop, + .parallax-kinhlup, + .parallax-tham, + .parallax-vo, + .parallax-vi { + position: absolute; + top: auto; + left: auto; + bottom: 0; + right: -20%; + transform: translate(-50%, -50%); + background-repeat: no-repeat; + background-size: contain; + will-change: transform; + + /* overlay sáng giống parallax-earth */ + &::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 135deg, + rgba(255, 241, 241, 0.4), + rgba(26, 5, 118, 0.5) + ); + pointer-events: none; + } + } + + /* Laptop chính, to và nghiêng nhẹ */ + .parallax-laptop { + width: 65vw; + height: 65vh; + background-image: url("../../../../../../public/assets/background/laptop.png"); + transform: translate(-50%, -50%) rotate(-20deg); + top: 70%; + bottom: 10%; + z-index: 5; + + &::before { + -webkit-mask-image: url("../../../../../../public/assets/background/laptop.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/laptop.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .parallax-vo { + width: 55vw; + height: 50vh; + background-image: url("../../../../../../public/assets/background/vo.png"); + bottom: 55%; + right: 50%; + z-index: 4; + + &::before { + -webkit-mask-image: url("../../../../../../public/assets/background/vo.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/vo.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .parallax-tham { + width: 55vw; + height: 40vh; + background-image: url("../../../../../../public/assets/background/tham.png"); + bottom: 10%; + right: 40%; + z-index: 3; + + &::before { + -webkit-mask-image: url("../../../../../../public/assets/background/tham.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/tham.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .parallax-kinhlup { + width: 28vw; + height: 28vh; + background-image: url("../../../../../../public/assets/background/kinhlup.png"); + bottom: 80%; + right: 0; + z-index: 6; + + &::before { + -webkit-mask-image: url("../../../../../../public/assets/background/kinhlup.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/kinhlup.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .parallax-vi { + width: 38vw; + height: 32vh; + background-image: url("../../../../../../public/assets/background/vi.png"); + bottom: 40%; + right: -10%; + z-index: 4; + + &::before { + -webkit-mask-image: url("../../../../../../public/assets/background/vi.png"); + -webkit-mask-repeat: no-repeat; + -webkit-mask-position: center; + -webkit-mask-size: contain; + mask-image: url("../../../../../../public/assets/background/vi.png"); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } .sticky-wrapper { position: sticky; @@ -11,24 +222,15 @@ width: 100%; display: flex; justify-content: center; - align-items: center; - overflow: hidden; - z-index: 1; - - canvas { - width: 80%; - height: 80%; - object-fit: contain; - } + align-items: flex-start; + z-index: 3; .overlay-text { position: absolute; color: white; text-align: center; max-width: 600px; - // Hiệu ứng chữ mờ dần khi cuộn - opacity: 1; - transition: opacity 0.5s ease; + transition: opacity 0.5s ease, transform 0.5s ease; } } } diff --git a/src/app/features/landing/components/interactive-animation/interactive-animation.component.ts b/src/app/features/landing/components/interactive-animation/interactive-animation.component.ts index 9e2cd87d..d82a8738 100644 --- a/src/app/features/landing/components/interactive-animation/interactive-animation.component.ts +++ b/src/app/features/landing/components/interactive-animation/interactive-animation.component.ts @@ -47,35 +47,126 @@ export class InteractiveAnimationComponent { this.context.clearRect(0, 0, canvas.width, canvas.height); this.context.drawImage(img, 0, 0, canvas.width, canvas.height); } - @HostListener('window:scroll', []) onScroll() { - const containerRect = this.el.nativeElement.getBoundingClientRect(); - const containerHeight = this.el.nativeElement.clientHeight; + const scrollTop = window.scrollY || document.documentElement.scrollTop; + const docHeight = + document.documentElement.scrollHeight - window.innerHeight; + const globalProgress = Math.max(0, Math.min(1, scrollTop / docHeight)); - // Chỉ tính toán khi section này trong tầm nhìn - if (containerRect.top > window.innerHeight || containerRect.bottom < 0) { - return; + // --- Laptop (di chuyển chậm + scale nhanh) --- + const laptop = this.el.nativeElement.querySelector( + '.parallax-laptop' + ) as HTMLElement; + if (laptop) { + laptop.style.transform = `translateY(${ + -globalProgress * 30 + }px) /* chậm hơn */ + translateX(${-globalProgress * 40}px) /* chậm hơn */ + scale(${ + 1 + globalProgress * 0.5 + }) /* vẫn phóng to nhanh */ + rotate(${globalProgress * -20}deg)`; } - // Tính toán tiến trình cuộn bên trong container (từ 0 đến 1) - // 0 = bắt đầu cuộn vào container, 1 = đã cuộn hết container - const scrollProgress = - -containerRect.top / (containerHeight - window.innerHeight); - const clampedProgress = Math.max(0, Math.min(1, scrollProgress)); + // --- Kính lúp --- + const kinhlup = this.el.nativeElement.querySelector( + '.parallax-kinhlup' + ) as HTMLElement; + if (kinhlup) { + kinhlup.style.transform = `translateY(${-globalProgress * 120}px) + translateX(${-globalProgress * 180}px) + scale(${1 + globalProgress * 0.25}) + rotate(${globalProgress * 720}deg)`; + } - // Map tiến trình cuộn tới frame tương ứng - const frameIndex = Math.floor(clampedProgress * (this.totalFrames - 1)); + // --- Thảm --- + const tham = this.el.nativeElement.querySelector( + '.parallax-tham' + ) as HTMLElement; + if (tham) { + tham.style.transform = `translateY(${-globalProgress * 80}px) + translateX(${-globalProgress * 220}px) + scale(${1 + globalProgress * 0.2}) + rotate(${globalProgress * -520}deg)`; + } + + // --- Vở (dịch trái nhanh hơn) --- + const vo = this.el.nativeElement.querySelector( + '.parallax-vo' + ) as HTMLElement; + if (vo) { + vo.style.transform = `translateY(${-globalProgress * 90}px) + translateX(${ + -globalProgress * 280 + }px) /* tăng mạnh hơn */ + scale(${1 + globalProgress * 0.25}) + rotate(${globalProgress * 720}deg)`; + } - // Dùng requestAnimationFrame để tối ưu việc vẽ lại - requestAnimationFrame(() => this.drawFrame(frameIndex)); + // --- Ví (dịch trái nhanh hơn) --- + const vi = this.el.nativeElement.querySelector( + '.parallax-vi' + ) as HTMLElement; + if (vi) { + vi.style.transform = `translateY(${-globalProgress * 70}px) + translateX(${ + -globalProgress * 200 + }px) /* tăng mạnh hơn */ + scale(${1 + globalProgress * 0.2}) + rotate(${globalProgress * 720}deg)`; + } - // Hiệu ứng phụ: làm mờ chữ khi cuộn qua - const overlayText = this.el.nativeElement.querySelector( - '.overlay-text' + // --- Earth (quay + scale mạnh hơn) --- + const earth = this.el.nativeElement.querySelector( + '.parallax-earth' ) as HTMLElement; - if (overlayText) { - overlayText.style.opacity = (1 - clampedProgress * 2).toString(); + if (earth) { + earth.style.transform = `translate(-50%, -50%) + rotate(${globalProgress * 720}deg) + scale(${1 + globalProgress * 1})`; + } + + // --- Rocket --- + const rocket = this.el.nativeElement.querySelector( + '.parallax-rocket' + ) as HTMLElement; + if (rocket) { + rocket.style.transform = `translate(calc(-50% - ${ + globalProgress * 250 + }px), + calc(-50% - ${globalProgress * 200}px)) + translateX(${-globalProgress * 200}px) + rotate(${globalProgress * 25}deg)`; + } + + // --- Robot --- + const robot = this.el.nativeElement.querySelector( + '.parallax-robot' + ) as HTMLElement; + if (robot) { + robot.style.transform = `translate(calc(-50% - ${globalProgress * 220}px), + calc(-50% - ${globalProgress * 150}px)) + translateX(${-globalProgress * 150}px) + rotate(${-globalProgress * 25}deg)`; + } + + // --- Overlay text --- + const containerRect = this.el.nativeElement.getBoundingClientRect(); + const containerHeight = this.el.nativeElement.clientHeight; + + if (!(containerRect.top > window.innerHeight || containerRect.bottom < 0)) { + const scrollProgress = + -containerRect.top / (containerHeight - window.innerHeight); + const clamped = Math.max(0, Math.min(1, scrollProgress)); + + const overlayText = this.el.nativeElement.querySelector( + '.overlay-text' + ) as HTMLElement; + if (overlayText) { + overlayText.style.opacity = (1 - clamped * 2).toString(); + overlayText.style.transform = `translateY(${clamped * 100}px)`; + } } } } diff --git a/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.html b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.html new file mode 100644 index 00000000..3beeb091 --- /dev/null +++ b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.html @@ -0,0 +1,75 @@ +
+
+

Thống kê tiền nạp

+

Các chỉ số chính về tiền nạp trên hệ thống.

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

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

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

{{ error }}

+ +
+ } @else if (paymentData && !isLoading && !error) { +
+
+
+
Tổng số
+
{{ totalAmount | number }}
+
+
+
Trung bình
+
{{ averageAmount | number }}
+
+
+
Cao nhất
+
{{ maxAmount | number }}
+
+
+
Thấp nhất
+
{{ minAmount | number }}
+
+
+ +
+
+ +
+
+ +
+ + +
+
+ } +
diff --git a/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.scss b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.scss new file mode 100644 index 00000000..434853f6 --- /dev/null +++ b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.scss @@ -0,0 +1,194 @@ +:host { + display: block; +} + +.payment-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; +} + +.payment-header { + margin-bottom: 12px; + h1 { + color: var(--title-text); + font-size: 1.2rem; + font-weight: 700; + margin-top: 0; + } + p { + color: var(--text-muted-color); + font-size: 1rem; + } +} +// ---- Thanh lọc ---- +.filter-bar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + // background-color: var(--surface-color); + // border: 1px solid var(--border-color); + // border-radius: 8px; + // box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + + label { + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted-color); + margin-right: 4px; + } + + select { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); + } + } +} + +// ---- Grid cho các thẻ ---- +.payment-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; +} + +.payment-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; +} +.table-wrapper { + margin: 32px auto; // thay vì margin-top + width: 80%; + border-radius: 12px; + padding: 20px; +} + +.table-wrapper table th:first-child, +.table-wrapper table td:first-child { + width: 60px; // nhỏ hơn các cột khác + text-align: center; // căn giữa số +} + +// ---- 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); + } +} +// Responsive +@media (max-width: 600px) { + .filter-bar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + + label { + margin-bottom: 4px; + } + + select { + width: 100%; + } + } +} diff --git a/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.ts b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.ts new file mode 100644 index 00000000..b1278fc4 --- /dev/null +++ b/src/app/features/statistics/pages/payment-statistics/payment-statistics.component.ts @@ -0,0 +1,111 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; // cần để dùng [(ngModel)] +import { Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import { StatisticsService } from '../../../../core/services/api-service/statistics.service'; +import { PaymentStatisticsAdmin } from '../../../../core/models/statistics.model'; +import { LineChartComponent } from '../../../../shared/components/my-shared/line-chart/line-chart'; +import { TableComponent } from '../../../../shared/components/my-shared/table/table.component'; + +@Component({ + selector: 'app-payment-statistics', + imports: [CommonModule, FormsModule, LineChartComponent, TableComponent], + templateUrl: './payment-statistics.component.html', + styleUrl: './payment-statistics.component.scss', +}) +export class PaymentStatisticsComponent implements OnInit, OnDestroy { + paymentData: PaymentStatisticsAdmin[] = []; + isLoading = false; + error: string | null = null; + private paymentSubscription: Subscription | undefined; + + // dữ liệu thẻ + minAmount = 0; + maxAmount = 0; + averageAmount = 0; + totalAmount = 0; + // table + tableHeaders = [ + { label: 'Ngày', value: 'day' }, + { label: 'Số tiền nạp', value: 'totalAmount' }, + ]; + + tableData: { [key: string]: any }[] = []; + // dropdown filter + years: number[] = []; + months: number[] = Array.from({ length: 12 }, (_, i) => i + 1); + selectedYear = new Date().getFullYear(); + selectedMonth = new Date().getMonth() + 1; + + // chart data + chartCategories: string[] = []; + chartSeries: { name: string; data: number[] }[] = []; + + constructor(private statisticsService: StatisticsService) {} + + ngOnInit(): void { + // generate years từ 2004 -> current year + const currentYear = new Date().getFullYear(); + for (let y = 2004; y <= currentYear; y++) { + this.years.push(y); + } + + this.loadPayment(); + } + + onFilterChange(): void { + this.loadPayment(); + } + + loadPayment(): void { + this.isLoading = true; + this.error = null; + + this.paymentSubscription = this.statisticsService + .getAdminPaymentStats(this.selectedYear, this.selectedMonth) + .pipe(finalize(() => (this.isLoading = false))) + .subscribe({ + next: (response) => { + if (response && response.result) { + this.paymentData = response.result; + this.prepareChartData(); + } 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 tiền nạp:', err); + this.error = 'Đã có lỗi xảy ra. Vui lòng thử lại sau.'; + }, + }); + } + prepareChartData(): void { + if (!this.paymentData || this.paymentData.length === 0) return; + + const amounts = this.paymentData.map((d) => d.totalAmount); + this.totalAmount = amounts.reduce((sum, val) => sum + val, 0); + this.minAmount = Math.min(...amounts); + this.maxAmount = Math.max(...amounts); + this.averageAmount = this.totalAmount / amounts.length; + + // Chart data + this.chartCategories = this.paymentData.map((d) => d.day); + this.chartSeries = [ + { + name: 'Tiền nạp', + data: amounts, + }, + ]; + + // Table data + this.tableData = this.paymentData.map((d) => ({ + day: d.day, + totalAmount: d.totalAmount, + })); + } + ngOnDestroy(): void { + this.paymentSubscription?.unsubscribe(); + } +} diff --git a/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.html b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.html new file mode 100644 index 00000000..a7d281a2 --- /dev/null +++ b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.html @@ -0,0 +1,68 @@ +
+
+

Thống kê tiền nạp

+

Các chỉ số chính về tiền nạp trên hệ thống.

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

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

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

{{ error }}

+ +
+ } @else if (paymentData && !isLoading && !error) { +
+
+
+
Tổng số tiền nạp
+
{{ depositAmount | number }}
+
+
+
Tổng tiền mua
+
{{ purchaseAmount | number }}
+
+
+ +
+
+ +
+
+ +
+ + + + +
+
+ } +
diff --git a/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.scss b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.scss new file mode 100644 index 00000000..185f0c9a --- /dev/null +++ b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.scss @@ -0,0 +1,197 @@ +:host { + display: block; +} + +.payment-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; +} + +.payment-header { + margin-bottom: 12px; + h1 { + color: var(--title-text); + font-size: 1.2rem; + font-weight: 700; + margin-top: 0; + } + p { + color: var(--text-muted-color); + font-size: 1rem; + } +} +// ---- Thanh lọc ---- +.filter-bar { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 24px; + // background-color: var(--surface-color); + // border: 1px solid var(--border-color); + // border-radius: 8px; + // box-shadow: 0 2px 6px rgba(0, 0, 0, 0.05); + + label { + font-size: 0.95rem; + font-weight: 500; + color: var(--text-muted-color); + margin-right: 4px; + } + + select { + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border-color); + background-color: var(--background-color); + color: var(--text-color); + font-size: 0.95rem; + transition: border-color 0.2s, box-shadow 0.2s; + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.2); + } + } +} + +// ---- Grid cho các thẻ ---- +.payment-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; +} + +.payment-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; +} +.table-wrapper { + display: flex; + gap: 20px; + flex-direction: row; + justify-content: space-between; + margin: 32px auto; // thay vì margin-top + width: 100%; + border-radius: 12px; +} + +.table-wrapper table th:first-child, +.table-wrapper table td:first-child { + width: 60px; // nhỏ hơn các cột khác + text-align: center; // căn giữa số +} + +// ---- 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); + } +} +// Responsive +@media (max-width: 600px) { + .filter-bar { + flex-direction: column; + align-items: flex-start; + gap: 12px; + + label { + margin-bottom: 4px; + } + + select { + width: 100%; + } + } +} diff --git a/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.ts b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.ts new file mode 100644 index 00000000..bab0aa31 --- /dev/null +++ b/src/app/features/statistics/pages/user-payment-statistic/user-payment-statistic.component.ts @@ -0,0 +1,249 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; // cần để dùng [(ngModel)] +import { Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; + +import { StatisticsService } from '../../../../core/services/api-service/statistics.service'; +import { + PaymentStatisticsAdmin, + PaymentStatisticsUser, +} from '../../../../core/models/statistics.model'; +import { LineChartComponent } from '../../../../shared/components/my-shared/line-chart/line-chart'; +import { TableComponent } from '../../../../shared/components/my-shared/table/table.component'; +import { MultiLineChartComponent } from '../../../../shared/components/my-shared/multi-line-chart/multi-line-chart'; + +@Component({ + selector: 'app-user-payment-statistics', + imports: [CommonModule, FormsModule, MultiLineChartComponent, TableComponent], + templateUrl: './user-payment-statistic.component.html', + styleUrls: ['./user-payment-statistic.component.scss'], +}) +export class UserPaymentStatisticsComponent implements OnInit, OnDestroy { + paymentData: PaymentStatisticsUser[] = []; + isLoading = false; + error: string | null = null; + private paymentSubscription: Subscription | undefined; + + // dữ liệu thẻ + purchaseAmount = 0; + depositAmount = 0; + // table + tablePurchaseHeaders = [ + { label: 'Ngày', value: 'day' }, + { label: 'Số tiền nạp', value: 'depositAmount' }, + ]; + tableDepositHeaders = [ + { label: 'Ngày', value: 'day' }, + { label: 'Số tiền mua', value: 'purchaseAmount' }, + ]; + fakeStats = [ + { + day: '2025-09-11', + depositAmount: 370.0, + purchaseAmount: 300.0, + walletBalance: 600.0, + }, + { + day: '2025-09-12', + depositAmount: 200.0, + purchaseAmount: 100.0, + walletBalance: 680.0, + }, + { + day: '2025-09-13', + depositAmount: 100.0, + purchaseAmount: 200.0, + walletBalance: 680.0, + }, + { + day: '2025-09-14', + depositAmount: 370.0, + purchaseAmount: 300.0, + walletBalance: 600.0, + }, + { + day: '2025-09-15', + depositAmount: 200.0, + purchaseAmount: 100.0, + walletBalance: 680.0, + }, + { + day: '2025-09-16', + depositAmount: 100.0, + purchaseAmount: 200.0, + walletBalance: 680.0, + }, + { + day: '2025-09-11', + depositAmount: 370.0, + purchaseAmount: 300.0, + walletBalance: 600.0, + }, + { + day: '2025-09-12', + depositAmount: 200.0, + purchaseAmount: 100.0, + walletBalance: 680.0, + }, + { + day: '2025-09-13', + depositAmount: 100.0, + purchaseAmount: 200.0, + walletBalance: 680.0, + }, + { + day: '2025-09-14', + depositAmount: 370.0, + purchaseAmount: 300.0, + walletBalance: 600.0, + }, + { + day: '2025-09-15', + depositAmount: 200.0, + purchaseAmount: 100.0, + walletBalance: 680.0, + }, + { + day: '2025-09-16', + depositAmount: 100.0, + purchaseAmount: 200.0, + walletBalance: 680.0, + }, + ]; + + tablePurchaseData: { [key: string]: any }[] = []; + tableDepositData: { [key: string]: any }[] = []; + // dropdown filter + years: number[] = []; + months: number[] = Array.from({ length: 12 }, (_, i) => i + 1); + selectedYear = new Date().getFullYear(); + selectedMonth = new Date().getMonth() + 1; + + // chart data + chartCategories: string[] = []; + chartSeries: { name: string; data: number[] }[] = []; + + constructor(private statisticsService: StatisticsService) {} + + ngOnInit(): void { + // generate years từ 2004 -> current year + const currentYear = new Date().getFullYear(); + for (let y = 2004; y <= currentYear; y++) { + this.years.push(y); + } + + this.loadPayment(); + //chuẩn bị dữ liệu cho biểu đồ + this.convertToChartData(this.paymentData); + + this.calculateTotals(this.paymentData); + // ✅ Gán dữ liệu BE vào chart + const { categories, seriesData } = this.convertToChartData( + this.paymentData + ); + this.chartCategories = categories; + this.chartSeries = seriesData; + + // ✅ Tính tổng nạp + chi + this.calculateTotals(this.paymentData); + + // ✅ Chuẩn bị dữ liệu 2 bảng + this.splitDataForTables(this.paymentData); + // this.convertToChartData(this.fakeStats); + // // ✅ Gán fake data vào chart + // const { categories, seriesData } = this.convertToChartData(this.fakeStats); + // this.chartCategories = categories; + // this.chartSeries = seriesData; + } + + onFilterChange(): void { + this.loadPayment(); + //chuẩn bị dữ liệu cho biểu đồ + this.convertToChartData(this.paymentData); + + this.calculateTotals(this.paymentData); + // ✅ Gán dữ liệu BE vào chart + const { categories, seriesData } = this.convertToChartData( + this.paymentData + ); + this.chartCategories = categories; + this.chartSeries = seriesData; + + // ✅ Tính tổng nạp + chi + this.calculateTotals(this.paymentData); + + // ✅ Chuẩn bị dữ liệu 2 bảng + this.splitDataForTables(this.paymentData); + } + + loadPayment(): void { + this.isLoading = true; + this.error = null; + + this.paymentSubscription = this.statisticsService + .getUserPaymentStats(this.selectedYear, this.selectedMonth) + .pipe(finalize(() => (this.isLoading = false))) + .subscribe({ + next: (response) => { + if (response && response.result) { + this.paymentData = response.result; + } 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 tiền nạp:', err); + this.error = 'Đã có lỗi xảy ra. Vui lòng thử lại sau.'; + }, + }); + } + calculateTotals(data: any[]) { + this.depositAmount = data.reduce( + (sum, item) => sum + (item.depositAmount || 0), + 0 + ); + this.purchaseAmount = data.reduce( + (sum, item) => sum + (item.purchaseAmount || 0), + 0 + ); + } + + convertToChartData(data: any[]) { + const categories = data.map((item) => item.day); + + const seriesData = [ + { + name: 'Deposit', + data: data.map((item) => item.depositAmount), + }, + { + name: 'Purchase', + data: data.map((item) => item.purchaseAmount), + }, + { + name: 'Wallet Balance', + data: data.map((item) => item.walletBalance), + }, + ]; + + return { categories, seriesData }; + } + splitDataForTables(data: any[]) { + // Bảng nạp tiền + this.tableDepositData = data.map((item) => ({ + day: item.day, + depositAmount: item.depositAmount, + })); + + // Bảng mua hàng + this.tablePurchaseData = data.map((item) => ({ + day: item.day, + purchaseAmount: item.purchaseAmount, + })); + } + + ngOnDestroy(): void { + this.paymentSubscription?.unsubscribe(); + } +} diff --git a/src/app/features/statistics/statistics-routing.module.ts b/src/app/features/statistics/statistics-routing.module.ts index a5324780..6c9c14cc 100644 --- a/src/app/features/statistics/statistics-routing.module.ts +++ b/src/app/features/statistics/statistics-routing.module.ts @@ -3,6 +3,8 @@ 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'; +import { PaymentStatisticsComponent } from './pages/payment-statistics/payment-statistics.component'; +import { UserPaymentStatisticsComponent } from './pages/user-payment-statistic/user-payment-statistic.component'; const routes: Routes = [ { @@ -20,6 +22,16 @@ const routes: Routes = [ component: SummaryStatisticsComponent, data: { breadcrumb: 'Thống kê bài tập' }, }, + { + path: 'admin-payment-statistics', //thêm vào đây + component: PaymentStatisticsComponent, //thêm vào đây + data: { breadcrumb: 'Thống kê doanh thu' }, + }, + { + path: 'user-payment-statistics', //thêm vào đây + component: UserPaymentStatisticsComponent, //thêm vào đây + data: { breadcrumb: 'Thống kê nạp & mua' }, + }, //thêm vào đây ], }, diff --git a/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.html b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.html new file mode 100644 index 00000000..0f656b37 --- /dev/null +++ b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.html @@ -0,0 +1,14 @@ +
+ +
diff --git a/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.scss b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.scss new file mode 100644 index 00000000..6526bd93 --- /dev/null +++ b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.scss @@ -0,0 +1,3 @@ +#chart { + width: 90vw; +} diff --git a/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.ts b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.ts new file mode 100644 index 00000000..1bb89392 --- /dev/null +++ b/src/app/shared/components/my-shared/multi-line-chart/multi-line-chart.ts @@ -0,0 +1,102 @@ +import { + Component, + ViewChild, + Input, + OnChanges, + SimpleChanges, +} from '@angular/core'; +import { + ApexAxisChartSeries, + ApexChart, + ChartComponent, + ApexDataLabels, + ApexPlotOptions, + ApexYAxis, + ApexLegend, + ApexStroke, + ApexXAxis, + ApexFill, + ApexTooltip, + NgApexchartsModule, +} from 'ng-apexcharts'; + +export type ChartOptions = { + series: ApexAxisChartSeries; + chart: ApexChart; + dataLabels: ApexDataLabels; + plotOptions: ApexPlotOptions; + yaxis: ApexYAxis; + xaxis: ApexXAxis; + fill: ApexFill; + tooltip: ApexTooltip; + stroke: ApexStroke; + legend: ApexLegend; +}; + +@Component({ + selector: 'app-multi-line-chart', + templateUrl: './multi-line-chart.html', + styleUrls: ['./multi-line-chart.scss'], + imports: [NgApexchartsModule], + standalone: true, +}) +export class MultiLineChartComponent implements OnChanges { + @ViewChild('chart') chart!: ChartComponent; + + // ✅ Nhận categories và series động từ bên ngoài + @Input() categories: string[] = []; + @Input() seriesData: ApexAxisChartSeries = []; + + public chartOptions: ChartOptions = { + series: [], + chart: { + type: 'bar', + height: 350, + }, + dataLabels: { + enabled: true, + }, + stroke: { + curve: 'smooth', + width: 2, + }, + xaxis: { + categories: [], + }, + yaxis: { + title: { + text: 'Amount ($)', + }, + }, + tooltip: { + y: { + formatter: function (val: number) { + return val + ' $'; + }, + }, + }, + legend: { + position: 'top', + }, + fill: { + opacity: 1, + }, + plotOptions: { + bar: { + columnWidth: '55%', + borderRadius: 5, + }, + }, + }; + + ngOnChanges(changes: SimpleChanges): void { + this.updateChart(); + } + + private updateChart() { + this.chartOptions.series = this.seriesData; + this.chartOptions.xaxis = { + categories: this.categories, + }; + } +} diff --git a/src/app/shared/components/my-shared/table/table.component.html b/src/app/shared/components/my-shared/table/table.component.html index ce87d81f..628cc29b 100644 --- a/src/app/shared/components/my-shared/table/table.component.html +++ b/src/app/shared/components/my-shared/table/table.component.html @@ -3,202 +3,192 @@ @if (needNo) { - - } - @for (header of headers; track header) { - - } - @if (needDelete || needEdit || needViewResult || needswitch) { - + + } @for (header of headers; track header) { + + } @if (needDelete || needEdit || needViewResult || needswitch) { + } @for (row of data; track row; let i = $index) { - - @if (needNo) { - + + @if (needNo) { + + } @for (header of headers; track header) { + - } - @if (needDelete || needEdit || needViewResult || needswitch) { - + + } @if (needViewResult) { + +
+
} - + + } - -
STT{{ header.label }}STT{{ header.label }}
- {{ - i + 1 + amountDataPerPage * (currentIndex ? currentIndex - 1 : 0) - }} -
+ {{ + i + 1 + amountDataPerPage * (currentIndex ? currentIndex - 1 : 0) + }} + + @if (templateMap[header.value]) { + + } @else { + {{ row[header.value] }} } - @for (header of headers; track header) { - + } @if (needDelete || needEdit || needViewResult || needswitch) { + +
+ @if (needEdit) { +
+ + + + +
+ } @if (needDelete) { +
+ + + + + + + +
+ } @if (needswitch) { +
+ @if (row[switchField] === lockValue) { + - @if (templateMap[header.value]) { - - } @else { - {{ row[header.value] }} + + + + } @if (row[switchField] === openValue) { + + + + + } @if (row[switchField] === pendingValue) { +
+ + + + + + + + + + +
} -
-
- @if (needEdit) { -
- - - - -
- } - @if (needDelete) { -
- - - - - - - -
- } - @if (needswitch) { -
- @if (row[switchField] === lockValue) { - - - - - } - @if (row[switchField] === openValue) { - - - - - } - @if (row[switchField] === pendingValue) { -
- - - - - - - - - - -
- } -
- } - @if (needViewResult) { - -
-
- } -
-
-
+ + } + + +