From 91b0eef9c8b0c291f131f573e8914433e1886ac6 Mon Sep 17 00:00:00 2001 From: AzenKain Date: Sat, 9 May 2026 11:09:51 +0700 Subject: [PATCH] feat: implement role-based sidebar navigation and admin dashboard layout with statistics components --- api.ts | 4 + src/app/(admin)/page.tsx | 22 +- src/app/layout.tsx | 2 +- src/components/ecommerce/DailyNewChart.tsx | 181 +++++++++++++ .../ecommerce/DetailedStatsTable.tsx | 92 +++++++ src/components/ecommerce/EcommerceMetrics.tsx | 65 ++++- src/components/ecommerce/StatisticsChart.tsx | 240 +++++++++--------- src/interface/statistics.ts | 28 ++ src/layout/AppHeader.tsx | 8 +- src/layout/AppSidebar.tsx | 1 + src/service/statisticsService.ts | 14 + 11 files changed, 505 insertions(+), 152 deletions(-) create mode 100644 src/components/ecommerce/DailyNewChart.tsx create mode 100644 src/components/ecommerce/DetailedStatsTable.tsx create mode 100644 src/interface/statistics.ts create mode 100644 src/service/statisticsService.ts diff --git a/api.ts b/api.ts index 9ab4373..004fdd5 100644 --- a/api.ts +++ b/api.ts @@ -64,5 +64,9 @@ export const API = { UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`, CREATE: `${API_URL_ROOT}/submissions`, DELETE: (id: string) => `${API_URL_ROOT}/submissions/${id}`, + }, + Statistics: { + GET_ALL: `${API_URL_ROOT}/statistics`, + GET_BY_DATE: (date: string) => `${API_URL_ROOT}/statistics/${date}`, } } \ No newline at end of file diff --git a/src/app/(admin)/page.tsx b/src/app/(admin)/page.tsx index 65843b4..ef7c5c3 100644 --- a/src/app/(admin)/page.tsx +++ b/src/app/(admin)/page.tsx @@ -1,15 +1,12 @@ import type { Metadata } from "next"; import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics"; import React from "react"; -import MonthlyTarget from "@/components/ecommerce/MonthlyTarget"; -import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart"; +import DailyNewChart from "@/components/ecommerce/DailyNewChart"; import StatisticsChart from "@/components/ecommerce/StatisticsChart"; -import RecentOrders from "@/components/ecommerce/RecentOrders"; -import DemographicCard from "@/components/ecommerce/DemographicCard"; +import DetailedStatsTable from "@/components/ecommerce/DetailedStatsTable"; export const metadata: Metadata = { - title: - "Admin Dashboard", + title: "Admin Dashboard", description: "This is Dashboard Home for History Web", }; @@ -18,25 +15,16 @@ export default function Ecommerce() {
- - +
- +
- -
- -
- -
- -
); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b6b20bb..f3dc742 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -17,7 +17,7 @@ export default function RootLayout({ }>) { return ( - + {children} diff --git a/src/components/ecommerce/DailyNewChart.tsx b/src/components/ecommerce/DailyNewChart.tsx new file mode 100644 index 0000000..84d3b15 --- /dev/null +++ b/src/components/ecommerce/DailyNewChart.tsx @@ -0,0 +1,181 @@ +"use client"; +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { ApexOptions } from "apexcharts"; +import { getStatistics } from "@/service/statisticsService"; +import { StatisticResponse } from "@/interface/statistics"; + +const Chart = dynamic(() => import("react-apexcharts"), { ssr: false }); + +const METRICS = [ + { key: "users", name: "Người dùng" }, + { key: "projects", name: "Dự án" }, + { key: "commits", name: "Bản lưu" }, + { key: "submissions", name: "Duyệt tin" }, + { key: "medias", name: "Tệp đa phương tiện" }, + { key: "wikis", name: "Bài viết Wiki" }, + { key: "entities", name: "Thực thể" }, + { key: "geometries", name: "Dữ liệu không gian" }, +]; + +export default function MonthlyNewChart() { + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [chartData, setChartData] = useState<{ month: string; value: number }[]>([]); + const [selectedMetricKey, setSelectedMetricKey] = useState("users"); + const [loading, setLoading] = useState(true); + + const fetchChartData = async (start: string, end: string, metric: string) => { + if (!start || !end) return; + setLoading(true); + try { + const response = await getStatistics({ start_date: start, end_date: end }); + if (response?.data) { + // Tổng hợp theo tháng + const monthsMap: { [key: string]: number } = {}; + + response.data.forEach(item => { + const date = new Date(item.date); + const monthKey = `${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}`; + const val = item[`new_${metric}` as keyof StatisticResponse] as number || 0; + + if (!monthsMap[monthKey]) { + monthsMap[monthKey] = 0; + } + monthsMap[monthKey] += val; + }); + + const aggregated = Object.entries(monthsMap).map(([key, value]) => ({ + month: key, + value: value + })).sort((a, b) => { + const [mA, yA] = a.month.split('/').map(Number); + const [mB, yB] = b.month.split('/').map(Number); + return yA !== yB ? yA - yB : mA - mB; + }); + + setChartData(aggregated); + } + } catch (error) { + console.error("Failed to fetch chart data", error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + const today = new Date(); + const startOfYear = new Date(today.getFullYear(), 0, 1); // Từ đầu năm + + const todayStr = today.toISOString().split('T')[0]; + const startOfYearStr = startOfYear.toISOString().split('T')[0]; + + setStartDate(startOfYearStr); + setEndDate(todayStr); + + fetchChartData(startOfYearStr, todayStr, selectedMetricKey); + }, []); + + const handleStartDateChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setStartDate(val); + fetchChartData(val, endDate, selectedMetricKey); + }; + + const handleEndDateChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setEndDate(val); + fetchChartData(startDate, val, selectedMetricKey); + }; + + const handleMetricChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setSelectedMetricKey(val); + fetchChartData(startDate, endDate, val); + }; + + const categories = chartData.map(item => item.month); + const series = [ + { + name: "Tạo mới", + data: chartData.map(item => item.value), + } + ]; + + const options: ApexOptions = { + colors: ["#465fff"], + chart: { + fontFamily: "Outfit, sans-serif", + type: "bar", + height: 180, + toolbar: { show: false }, + }, + plotOptions: { + bar: { + horizontal: false, + columnWidth: "40%", + borderRadius: 5, + borderRadiusApplication: "end", + }, + }, + dataLabels: { enabled: false }, + stroke: { show: true, width: 4, colors: ["transparent"] }, + xaxis: { + categories: categories, + axisBorder: { show: false }, + axisTicks: { show: false }, + }, + legend: { show: false }, + yaxis: { labels: { style: { colors: ["#6B7280"] } } }, + grid: { yaxis: { lines: { show: true } } }, + fill: { opacity: 1 }, + tooltip: { y: { formatter: (val: number) => `${val}` } }, + }; + + return ( +
+
+

+ Lượng tạo mới hàng tháng +

+ +
+ + + + +
+
+ +
+
+ {loading ? ( +
Đang tải...
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/src/components/ecommerce/DetailedStatsTable.tsx b/src/components/ecommerce/DetailedStatsTable.tsx new file mode 100644 index 0000000..4581718 --- /dev/null +++ b/src/components/ecommerce/DetailedStatsTable.tsx @@ -0,0 +1,92 @@ +"use client"; +import React, { useEffect, useState } from "react"; +import { getStatisticByDate } from "@/service/statisticsService"; +import { StatisticResponse } from "@/interface/statistics"; + +const METRICS_MAP = [ + { key: "users", name: "Người dùng" }, + { key: "projects", name: "Dự án" }, + { key: "commits", name: "Bản lưu" }, + { key: "submissions", name: "Duyệt tin" }, + { key: "medias", name: "Tệp đa phương tiện" }, + { key: "wikis", name: "Bài viết Wiki" }, + { key: "entities", name: "Thực thể" }, + { key: "geometries", name: "Dữ liệu không gian" }, +]; + +export default function DetailedStatsTable() { + const [todayData, setTodayData] = useState(null); + const [yesterdayData, setYesterdayData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const todayStr = today.toISOString().split('T')[0]; + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + const [todayRes, yesterdayRes] = await Promise.all([ + getStatisticByDate(todayStr), + getStatisticByDate(yesterdayStr) + ]); + + if (todayRes?.data) setTodayData(todayRes.data); + if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data); + } catch (error) { + console.error("Failed to fetch detailed statistics", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const calculateGrowth = (today: number, yesterday: number) => { + if (!yesterday || yesterday === 0) return 0; + return ((today - yesterday) / yesterday) * 100; + }; + + return ( +
+

+ Bảng Số Liệu Chi Tiết (Hôm nay vs Hôm qua) +

+ +
+ + + + + + + + + + + {METRICS_MAP.map((metric) => { + const todayVal = todayData ? (todayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0; + const yesterdayVal = yesterdayData ? (yesterdayData[`total_${metric.key}` as keyof StatisticResponse] as number) : 0; + const growth = calculateGrowth(todayVal, yesterdayVal); + + return ( + + + + + + + ); + })} + +
Nội dungHôm nayHôm quaTăng trưởng
{metric.name}{loading ? "..." : todayVal.toLocaleString()}{loading ? "..." : yesterdayVal.toLocaleString()}= 0 ? "text-success-500" : "text-error-500"}`}> + {loading ? "..." : `${growth >= 0 ? "+" : ""}${growth.toFixed(2)}%`} +
+
+
+ ); +} diff --git a/src/components/ecommerce/EcommerceMetrics.tsx b/src/components/ecommerce/EcommerceMetrics.tsx index 58ab6c1..0b0b212 100644 --- a/src/components/ecommerce/EcommerceMetrics.tsx +++ b/src/components/ecommerce/EcommerceMetrics.tsx @@ -1,9 +1,50 @@ "use client"; -import React from "react"; +import React, { useEffect, useState } from "react"; import Badge from "../ui/badge/Badge"; import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons"; +import { getStatisticByDate } from "@/service/statisticsService"; +import { StatisticResponse } from "@/interface/statistics"; export const EcommerceMetrics = () => { + const [todayData, setTodayData] = useState(null); + const [yesterdayData, setYesterdayData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchData = async () => { + try { + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const todayStr = today.toISOString().split('T')[0]; + const yesterdayStr = yesterday.toISOString().split('T')[0]; + + const [todayRes, yesterdayRes] = await Promise.all([ + getStatisticByDate(todayStr), + getStatisticByDate(yesterdayStr) + ]); + + if (todayRes?.data) setTodayData(todayRes.data); + if (yesterdayRes?.data) setYesterdayData(yesterdayRes.data); + } catch (error) { + console.error("Failed to fetch statistics", error); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, []); + + const calculateGrowth = (today: number, yesterday: number) => { + if (!yesterday || yesterday === 0) return 0; + return ((today - yesterday) / yesterday) * 100; + }; + + const userGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_users, yesterdayData.total_users) : 0; + const projectGrowth = todayData && yesterdayData ? calculateGrowth(todayData.total_projects, yesterdayData.total_projects) : 0; + return (
{/* */} @@ -15,15 +56,15 @@ export const EcommerceMetrics = () => {
- Customers + Tổng Người Dùng

- 3,782 + {loading ? "..." : todayData?.total_users?.toLocaleString() || "N/A"}

- - - 11.01% + = 0 ? "success" : "error"}> + {userGrowth >= 0 ? : } + {Math.abs(userGrowth).toFixed(2)}%
@@ -37,16 +78,16 @@ export const EcommerceMetrics = () => {
- Orders + Tổng Dự Án

- 5,359 + {loading ? "..." : todayData?.total_projects?.toLocaleString() || "N/A"}

- - - 9.05% + = 0 ? "success" : "error"}> + {projectGrowth >= 0 ? : } + {Math.abs(projectGrowth).toFixed(2)}%
@@ -54,3 +95,5 @@ export const EcommerceMetrics = () => { ); }; + +export default EcommerceMetrics; diff --git a/src/components/ecommerce/StatisticsChart.tsx b/src/components/ecommerce/StatisticsChart.tsx index a88e71b..ff29ac0 100644 --- a/src/components/ecommerce/StatisticsChart.tsx +++ b/src/components/ecommerce/StatisticsChart.tsx @@ -1,170 +1,168 @@ "use client"; -import { useEffect, useRef } from "react"; +import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; import { ApexOptions } from "apexcharts"; -import flatpickr from "flatpickr"; -import ChartTab from "../common/ChartTab"; -import { CalenderIcon } from "../../icons"; +import { getStatistics } from "@/service/statisticsService"; +import { StatisticResponse } from "@/interface/statistics"; const Chart = dynamic(() => import("react-apexcharts"), { ssr: false }); +const METRICS = [ + { key: "users", name: "Người dùng" }, + { key: "projects", name: "Dự án" }, + { key: "commits", name: "Bản lưu" }, + { key: "submissions", name: "Duyệt tin" }, + { key: "medias", name: "Tệp đa phương tiện" }, + { key: "wikis", name: "Bài viết Wiki" }, + { key: "entities", name: "Thực thể" }, + { key: "geometries", name: "Dữ liệu không gian" }, +]; + export default function StatisticsChart() { - const datePickerRef = useRef(null); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [chartData, setChartData] = useState([]); + const [selectedMetricKey, setSelectedMetricKey] = useState("users"); + const [loading, setLoading] = useState(true); + + const fetchChartData = async (start: string, end: string) => { + if (!start || !end) return; + setLoading(true); + try { + const response = await getStatistics({ start_date: start, end_date: end }); + if (response?.data) { + setChartData(response.data); + } + } catch (error) { + console.error("Failed to fetch chart data", error); + } finally { + setLoading(false); + } + }; useEffect(() => { - if (!datePickerRef.current) return; - const today = new Date(); const sevenDaysAgo = new Date(); sevenDaysAgo.setDate(today.getDate() - 6); - const fp = flatpickr(datePickerRef.current, { - mode: "range", - static: true, - monthSelectorType: "static", - dateFormat: "M d", - defaultDate: [sevenDaysAgo, today], - clickOpens: true, - prevArrow: - '', - nextArrow: - '', - }); + const todayStr = today.toISOString().split('T')[0]; + const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0]; - return () => { - if (!Array.isArray(fp)) { - fp.destroy(); - } - }; + setStartDate(sevenDaysAgoStr); + setEndDate(todayStr); + + fetchChartData(sevenDaysAgoStr, todayStr); }, []); - const options: ApexOptions = { - legend: { - show: false, // Hide legend - position: "top", - horizontalAlign: "left", + const handleStartDateChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setStartDate(val); + fetchChartData(val, endDate); + }; + + const handleEndDateChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setEndDate(val); + fetchChartData(startDate, val); + }; + + const categories = chartData.map(item => { + const date = new Date(item.date); + return `${date.getDate()}/${date.getMonth() + 1}`; + }); + + const series = [ + { + name: "Tổng cộng", + data: chartData.map(item => item[`total_${selectedMetricKey}` as keyof StatisticResponse] as number || 0), }, - colors: ["#465FFF", "#9CB9FF"], // Define line colors + { + name: "Mới", + data: chartData.map(item => item[`new_${selectedMetricKey}` as keyof StatisticResponse] as number || 0), + } + ]; + + const options: ApexOptions = { + legend: { show: true, position: "top" }, + colors: ["#465FFF", "#9CB9FF"], chart: { fontFamily: "Outfit, sans-serif", height: 310, - type: "line", // Set the chart type to 'line' - toolbar: { - show: false, // Hide chart toolbar - }, + type: "area", + toolbar: { show: false }, }, - stroke: { - curve: "straight", // Define the line style (straight, smooth, or step) - width: [2, 2], // Line width for each dataset - }, - + stroke: { curve: "smooth", width: [2, 2] }, fill: { type: "gradient", - gradient: { - opacityFrom: 0.55, - opacityTo: 0, - }, - }, - markers: { - size: 0, // Size of the marker points - strokeColors: "#fff", // Marker border color - strokeWidth: 2, - hover: { - size: 6, // Marker size on hover - }, + gradient: { opacityFrom: 0.55, opacityTo: 0 }, }, grid: { - xaxis: { - lines: { - show: false, // Hide grid lines on x-axis - }, - }, - yaxis: { - lines: { - show: true, // Show grid lines on y-axis - }, - }, - }, - dataLabels: { - enabled: false, // Disable data labels + xaxis: { lines: { show: false } }, + yaxis: { lines: { show: true } }, }, + dataLabels: { enabled: false }, tooltip: { - enabled: true, // Enable tooltip - x: { - format: "dd MMM yyyy", // Format for x-axis tooltip - }, + enabled: true, + x: { format: "dd MMM" }, }, xaxis: { - type: "category", // Category-based x-axis - categories: [ - "Jan", - "Feb", - "Mar", - "Apr", - "May", - "Jun", - "Jul", - "Aug", - "Sep", - "Oct", - "Nov", - "Dec", - ], - axisBorder: { - show: false, // Hide x-axis border - }, - axisTicks: { - show: false, // Hide x-axis ticks - }, - tooltip: { - enabled: false, // Disable tooltip for x-axis points - }, + type: "category", + categories: categories, + axisBorder: { show: false }, + axisTicks: { show: false }, }, yaxis: { labels: { style: { - fontSize: "12px", // Adjust font size for y-axis labels - colors: ["#6B7280"], // Color of the labels - }, - }, - title: { - text: "", // Remove y-axis title - style: { - fontSize: "0px", + fontSize: "12px", + colors: ["#6B7280"], }, }, }, }; - const series = [ - { - name: "Sales", - data: [180, 190, 170, 160, 175, 165, 170, 205, 230, 210, 240, 235], - }, - { - name: "Revenue", - data: [40, 30, 50, 40, 55, 40, 70, 100, 110, 120, 150, 140], - }, - ]; return (

- Statistics + Thống kê hệ thống

- Target you've set for each month + Dữ liệu thống kê theo thời gian

-
- +
+ {/* Dropdown chọn Metric */} + + + {/* Date Picker Start */}
- +
+ + {/* Date Picker End */} +
+
@@ -172,7 +170,11 @@ export default function StatisticsChart() {
- + {loading ? ( +
Đang tải...
+ ) : ( + + )}
diff --git a/src/interface/statistics.ts b/src/interface/statistics.ts new file mode 100644 index 0000000..7f11d8a --- /dev/null +++ b/src/interface/statistics.ts @@ -0,0 +1,28 @@ +export interface StatisticResponse { + id: string; + date: string; + total_users: number; + total_projects: number; + total_commits: number; + total_submissions: number; + total_medias: number; + total_wikis: number; + total_entities: number; + total_geometries: number; + total_storage_bytes: number; + new_users: number; + new_projects: number; + new_commits: number; + new_submissions: number; + new_medias: number; + new_wikis: number; + new_entities: number; + new_geometries: number; + new_storage_bytes: number; + created_at: string; +} + +export interface GetStatisticsParams { + start_date?: string; + end_date?: string; +} diff --git a/src/layout/AppHeader.tsx b/src/layout/AppHeader.tsx index b0f9e14..f1216ca 100644 --- a/src/layout/AppHeader.tsx +++ b/src/layout/AppHeader.tsx @@ -1,6 +1,4 @@ "use client"; -import { ThemeToggleButton } from "@/components/common/ThemeToggleButton"; -import NotificationDropdown from "@/components/header/NotificationDropdown"; import UserDropdown from "@/components/header/UserDropdown"; import { useSidebar } from "@/context/SidebarContext"; import Image from "next/image"; @@ -90,6 +88,7 @@ const AppHeader: React.FC = () => { className="dark:hidden" src="./images/logo/logo.svg" alt="Logo" + style={{ height: 'auto' }} /> { className="hidden dark:block" src="./images/logo/logo.svg" alt="Logo" + style={{ height: 'auto' }} /> @@ -144,10 +144,10 @@ const AppHeader: React.FC = () => { ref={inputRef} type="text" placeholder="Search or type command..." - className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:bg-white/[0.03] dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]" + className="dark:bg-dark-900 h-11 w-full rounded-lg border border-gray-200 bg-transparent py-2.5 pl-12 pr-14 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 xl:w-[430px]" /> - diff --git a/src/layout/AppSidebar.tsx b/src/layout/AppSidebar.tsx index 2da75a1..910eaea 100644 --- a/src/layout/AppSidebar.tsx +++ b/src/layout/AppSidebar.tsx @@ -275,6 +275,7 @@ const AppSidebar: React.FC = () => { alt="Logo" width={isExpanded || isHovered || isMobileOpen ? 80 : 32} height={isExpanded || isHovered || isMobileOpen ? 50 : 32} + style={{ height: 'auto' }} />
diff --git a/src/service/statisticsService.ts b/src/service/statisticsService.ts new file mode 100644 index 0000000..48d4f1f --- /dev/null +++ b/src/service/statisticsService.ts @@ -0,0 +1,14 @@ +import api from "@/config/config"; +import { API } from "../../api"; +import { GetStatisticsParams, StatisticResponse } from "@/interface/statistics"; +import { CommonResponse } from "@/interface/common"; + +export const getStatistics = async (params?: GetStatisticsParams): Promise> => { + const response = await api.get(API.Statistics.GET_ALL, { params }); + return response?.data; +}; + +export const getStatisticByDate = async (date: string): Promise> => { + const response = await api.get(API.Statistics.GET_BY_DATE(date)); + return response?.data; +};