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 @@
+
+
+
+
+
+
+
+
+
+
+ @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 @@
+
+
+
+
+
+
+
+
+
+
+ @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 @@
@for (row of data; track row; let i = $index) {
-
- @if (needNo) {
- |
- {{
- i + 1 + amountDataPerPage * (currentIndex ? currentIndex - 1 : 0)
- }}
- |
+
+ @if (needNo) {
+ |
+ {{
+ i + 1 + amountDataPerPage * (currentIndex ? currentIndex - 1 : 0)
+ }}
+ |
+ } @for (header of headers; track header) {
+
+ @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 (row[switchField] === openValue) {
+
+ } @if (row[switchField] === pendingValue) {
+
}
- |
- }
- @if (needDelete || needEdit || needViewResult || needswitch) {
-
-
- @if (needEdit) {
-
- }
- @if (needDelete) {
-
- }
- @if (needswitch) {
-
- @if (row[switchField] === lockValue) {
-
- }
- @if (row[switchField] === openValue) {
-
- }
- @if (row[switchField] === pendingValue) {
-
- }
-
- }
- @if (needViewResult) {
-
-
-
- }
-
- |
+
+ } @if (needViewResult) {
+
+
+
}
-
+
+
}
-
-
-