feat: implement role-based sidebar navigation and admin dashboard layout with statistics components
All checks were successful
Build and Release / release (push) Successful in 30s
All checks were successful
Build and Release / release (push) Successful in 30s
This commit is contained in:
4
api.ts
4
api.ts
@@ -64,5 +64,9 @@ export const API = {
|
|||||||
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
UPDATE_STATUS: (id: number | string) => `${API_URL_ROOT}/submissions/${id}/status`,
|
||||||
CREATE: `${API_URL_ROOT}/submissions`,
|
CREATE: `${API_URL_ROOT}/submissions`,
|
||||||
DELETE: (id: string) => `${API_URL_ROOT}/submissions/${id}`,
|
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}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
|
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
|
import DailyNewChart from "@/components/ecommerce/DailyNewChart";
|
||||||
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
|
|
||||||
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
|
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
|
||||||
import RecentOrders from "@/components/ecommerce/RecentOrders";
|
import DetailedStatsTable from "@/components/ecommerce/DetailedStatsTable";
|
||||||
import DemographicCard from "@/components/ecommerce/DemographicCard";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title:
|
title: "Admin Dashboard",
|
||||||
"Admin Dashboard",
|
|
||||||
description: "This is Dashboard Home for History Web",
|
description: "This is Dashboard Home for History Web",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -18,25 +15,16 @@ export default function Ecommerce() {
|
|||||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||||
<div className="col-span-12 space-y-6 xl:col-span-7">
|
<div className="col-span-12 space-y-6 xl:col-span-7">
|
||||||
<EcommerceMetrics />
|
<EcommerceMetrics />
|
||||||
|
<DailyNewChart />
|
||||||
<MonthlySalesChart />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-5">
|
<div className="col-span-12 xl:col-span-5">
|
||||||
<MonthlyTarget />
|
<DetailedStatsTable />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12">
|
<div className="col-span-12">
|
||||||
<StatisticsChart />
|
<StatisticsChart />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-5">
|
|
||||||
<DemographicCard />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="col-span-12 xl:col-span-7">
|
|
||||||
<RecentOrders />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function RootLayout({
|
|||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
<body className={`${inter.className} dark:bg-gray-900`} suppressHydrationWarning>
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||||
|
|||||||
181
src/components/ecommerce/DailyNewChart.tsx
Normal file
181
src/components/ecommerce/DailyNewChart.tsx
Normal file
@@ -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<string>("");
|
||||||
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
|
const [chartData, setChartData] = useState<{ month: string; value: number }[]>([]);
|
||||||
|
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("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<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setStartDate(val);
|
||||||
|
fetchChartData(val, endDate, selectedMetricKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setEndDate(val);
|
||||||
|
fetchChartData(startDate, val, selectedMetricKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMetricChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white px-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
|
||||||
|
<div className="flex flex-col gap-4 mb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||||
|
Lượng tạo mới hàng tháng
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
|
<select
|
||||||
|
value={selectedMetricKey}
|
||||||
|
onChange={handleMetricChange}
|
||||||
|
className="px-2 py-1 text-xs font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<option key={metric.key} value={metric.key}>
|
||||||
|
{metric.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={handleStartDateChange}
|
||||||
|
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={handleEndDateChange}
|
||||||
|
className="px-2 py-1 text-xs rounded-lg border border-gray-200 bg-white text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||||
|
<div className="-ml-5 min-w-[650px] xl:min-w-full pl-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-[180px]">Đang tải...</div>
|
||||||
|
) : (
|
||||||
|
<Chart options={options} series={series} type="bar" height={180} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
92
src/components/ecommerce/DetailedStatsTable.tsx
Normal file
92
src/components/ecommerce/DetailedStatsTable.tsx
Normal file
@@ -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<StatisticResponse | null>(null);
|
||||||
|
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(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 (
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] md:p-6">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||||
|
Bảng Số Liệu Chi Tiết (Hôm nay vs Hôm qua)
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-800">
|
||||||
|
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Nội dung</th>
|
||||||
|
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm nay</th>
|
||||||
|
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Hôm qua</th>
|
||||||
|
<th className="py-3 text-sm font-medium text-gray-500 dark:text-gray-400">Tăng trưởng</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={metric.key} className="border-b border-gray-100 dark:border-gray-800/50 last:border-0">
|
||||||
|
<td className="py-3 text-sm font-medium text-gray-800 dark:text-white/90">{metric.name}</td>
|
||||||
|
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : todayVal.toLocaleString()}</td>
|
||||||
|
<td className="py-3 text-sm text-gray-600 dark:text-gray-300">{loading ? "..." : yesterdayVal.toLocaleString()}</td>
|
||||||
|
<td className={`py-3 text-sm font-medium ${growth >= 0 ? "text-success-500" : "text-error-500"}`}>
|
||||||
|
{loading ? "..." : `${growth >= 0 ? "+" : ""}${growth.toFixed(2)}%`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,50 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import Badge from "../ui/badge/Badge";
|
import Badge from "../ui/badge/Badge";
|
||||||
import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons";
|
import { ArrowDownIcon, ArrowUpIcon, BoxIconLine, GroupIcon } from "@/icons";
|
||||||
|
import { getStatisticByDate } from "@/service/statisticsService";
|
||||||
|
import { StatisticResponse } from "@/interface/statistics";
|
||||||
|
|
||||||
export const EcommerceMetrics = () => {
|
export const EcommerceMetrics = () => {
|
||||||
|
const [todayData, setTodayData] = useState<StatisticResponse | null>(null);
|
||||||
|
const [yesterdayData, setYesterdayData] = useState<StatisticResponse | null>(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 (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
|
||||||
{/* <!-- Metric Item Start --> */}
|
{/* <!-- Metric Item Start --> */}
|
||||||
@@ -15,15 +56,15 @@ export const EcommerceMetrics = () => {
|
|||||||
<div className="flex items-end justify-between mt-5">
|
<div className="flex items-end justify-between mt-5">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Customers
|
Tổng Người Dùng
|
||||||
</span>
|
</span>
|
||||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||||
3,782
|
{loading ? "..." : todayData?.total_users?.toLocaleString() || "N/A"}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<Badge color="success">
|
<Badge color={userGrowth >= 0 ? "success" : "error"}>
|
||||||
<ArrowUpIcon />
|
{userGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||||
11.01%
|
{Math.abs(userGrowth).toFixed(2)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,16 +78,16 @@ export const EcommerceMetrics = () => {
|
|||||||
<div className="flex items-end justify-between mt-5">
|
<div className="flex items-end justify-between mt-5">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
Orders
|
Tổng Dự Án
|
||||||
</span>
|
</span>
|
||||||
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
<h4 className="mt-2 font-bold text-gray-800 text-title-sm dark:text-white/90">
|
||||||
5,359
|
{loading ? "..." : todayData?.total_projects?.toLocaleString() || "N/A"}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge color="error">
|
<Badge color={projectGrowth >= 0 ? "success" : "error"}>
|
||||||
<ArrowDownIcon className="text-error-500" />
|
{projectGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||||
9.05%
|
{Math.abs(projectGrowth).toFixed(2)}%
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,3 +95,5 @@ export const EcommerceMetrics = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default EcommerceMetrics;
|
||||||
|
|||||||
@@ -1,170 +1,168 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { ApexOptions } from "apexcharts";
|
import { ApexOptions } from "apexcharts";
|
||||||
import flatpickr from "flatpickr";
|
import { getStatistics } from "@/service/statisticsService";
|
||||||
import ChartTab from "../common/ChartTab";
|
import { StatisticResponse } from "@/interface/statistics";
|
||||||
import { CalenderIcon } from "../../icons";
|
|
||||||
|
|
||||||
const Chart = dynamic(() => import("react-apexcharts"), { ssr: false });
|
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() {
|
export default function StatisticsChart() {
|
||||||
const datePickerRef = useRef<HTMLInputElement>(null);
|
const [startDate, setStartDate] = useState<string>("");
|
||||||
|
const [endDate, setEndDate] = useState<string>("");
|
||||||
|
const [chartData, setChartData] = useState<StatisticResponse[]>([]);
|
||||||
|
const [selectedMetricKey, setSelectedMetricKey] = useState<string>("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(() => {
|
useEffect(() => {
|
||||||
if (!datePickerRef.current) return;
|
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const sevenDaysAgo = new Date();
|
const sevenDaysAgo = new Date();
|
||||||
sevenDaysAgo.setDate(today.getDate() - 6);
|
sevenDaysAgo.setDate(today.getDate() - 6);
|
||||||
|
|
||||||
const fp = flatpickr(datePickerRef.current, {
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
mode: "range",
|
const sevenDaysAgoStr = sevenDaysAgo.toISOString().split('T')[0];
|
||||||
static: true,
|
|
||||||
monthSelectorType: "static",
|
|
||||||
dateFormat: "M d",
|
|
||||||
defaultDate: [sevenDaysAgo, today],
|
|
||||||
clickOpens: true,
|
|
||||||
prevArrow:
|
|
||||||
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12.5 15L7.5 10L12.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
||||||
nextArrow:
|
|
||||||
'<svg class="stroke-current" width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.5 15L12.5 10L7.5 5" stroke="" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>',
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
setStartDate(sevenDaysAgoStr);
|
||||||
if (!Array.isArray(fp)) {
|
setEndDate(todayStr);
|
||||||
fp.destroy();
|
|
||||||
}
|
fetchChartData(sevenDaysAgoStr, todayStr);
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const options: ApexOptions = {
|
const handleStartDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
legend: {
|
const val = e.target.value;
|
||||||
show: false, // Hide legend
|
setStartDate(val);
|
||||||
position: "top",
|
fetchChartData(val, endDate);
|
||||||
horizontalAlign: "left",
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
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: {
|
chart: {
|
||||||
fontFamily: "Outfit, sans-serif",
|
fontFamily: "Outfit, sans-serif",
|
||||||
height: 310,
|
height: 310,
|
||||||
type: "line", // Set the chart type to 'line'
|
type: "area",
|
||||||
toolbar: {
|
toolbar: { show: false },
|
||||||
show: false, // Hide chart toolbar
|
|
||||||
},
|
},
|
||||||
},
|
stroke: { curve: "smooth", width: [2, 2] },
|
||||||
stroke: {
|
|
||||||
curve: "straight", // Define the line style (straight, smooth, or step)
|
|
||||||
width: [2, 2], // Line width for each dataset
|
|
||||||
},
|
|
||||||
|
|
||||||
fill: {
|
fill: {
|
||||||
type: "gradient",
|
type: "gradient",
|
||||||
gradient: {
|
gradient: { opacityFrom: 0.55, opacityTo: 0 },
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
xaxis: {
|
xaxis: { lines: { show: false } },
|
||||||
lines: {
|
yaxis: { lines: { show: true } },
|
||||||
show: false, // Hide grid lines on x-axis
|
|
||||||
},
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
lines: {
|
|
||||||
show: true, // Show grid lines on y-axis
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dataLabels: {
|
|
||||||
enabled: false, // Disable data labels
|
|
||||||
},
|
},
|
||||||
|
dataLabels: { enabled: false },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
enabled: true, // Enable tooltip
|
enabled: true,
|
||||||
x: {
|
x: { format: "dd MMM" },
|
||||||
format: "dd MMM yyyy", // Format for x-axis tooltip
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
type: "category", // Category-based x-axis
|
type: "category",
|
||||||
categories: [
|
categories: categories,
|
||||||
"Jan",
|
axisBorder: { show: false },
|
||||||
"Feb",
|
axisTicks: { show: false },
|
||||||
"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
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
labels: {
|
labels: {
|
||||||
style: {
|
style: {
|
||||||
fontSize: "12px", // Adjust font size for y-axis labels
|
fontSize: "12px",
|
||||||
colors: ["#6B7280"], // Color of the labels
|
colors: ["#6B7280"],
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
text: "", // Remove y-axis title
|
|
||||||
style: {
|
|
||||||
fontSize: "0px",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
|
<div className="rounded-2xl border border-gray-200 bg-white px-5 pb-5 pt-5 dark:border-gray-800 dark:bg-white/[0.03] sm:px-6 sm:pt-6">
|
||||||
<div className="flex flex-col gap-5 mb-6 sm:flex-row sm:justify-between">
|
<div className="flex flex-col gap-5 mb-6 sm:flex-row sm:justify-between">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||||
Statistics
|
Thống kê hệ thống
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
|
<p className="mt-1 text-gray-500 text-theme-sm dark:text-gray-400">
|
||||||
Target you've set for each month
|
Dữ liệu thống kê theo thời gian
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 sm:justify-end">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||||
<ChartTab />
|
{/* Dropdown chọn Metric */}
|
||||||
|
<select
|
||||||
|
value={selectedMetricKey}
|
||||||
|
onChange={(e) => setSelectedMetricKey(e.target.value)}
|
||||||
|
className="px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-200 rounded-lg focus:outline-none focus:border-brand-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
{METRICS.map((metric) => (
|
||||||
|
<option key={metric.key} value={metric.key}>
|
||||||
|
{metric.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Date Picker Start */}
|
||||||
<div className="relative inline-flex items-center">
|
<div className="relative inline-flex items-center">
|
||||||
<CalenderIcon className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 lg:left-3 lg:top-1/2 lg:translate-x-0 lg:-translate-y-1/2 text-gray-500 dark:text-gray-400 pointer-events-none z-10" />
|
|
||||||
<input
|
<input
|
||||||
ref={datePickerRef}
|
type="date"
|
||||||
className="h-10 w-10 lg:w-40 lg:h-auto lg:pl-10 lg:pr-3 lg:py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-transparent lg:text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:lg:text-gray-300 cursor-pointer"
|
value={startDate}
|
||||||
placeholder="Select date range"
|
onChange={handleStartDateChange}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Picker End */}
|
||||||
|
<div className="relative inline-flex items-center">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={handleEndDateChange}
|
||||||
|
className="px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm font-medium text-gray-700 outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +170,11 @@ export default function StatisticsChart() {
|
|||||||
|
|
||||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||||
<div className="min-w-[1000px] xl:min-w-full">
|
<div className="min-w-[1000px] xl:min-w-full">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-[310px]">Đang tải...</div>
|
||||||
|
) : (
|
||||||
<Chart options={options} series={series} type="area" height={310} />
|
<Chart options={options} series={series} type="area" height={310} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
28
src/interface/statistics.ts
Normal file
28
src/interface/statistics.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ThemeToggleButton } from "@/components/common/ThemeToggleButton";
|
|
||||||
import NotificationDropdown from "@/components/header/NotificationDropdown";
|
|
||||||
import UserDropdown from "@/components/header/UserDropdown";
|
import UserDropdown from "@/components/header/UserDropdown";
|
||||||
import { useSidebar } from "@/context/SidebarContext";
|
import { useSidebar } from "@/context/SidebarContext";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
@@ -90,6 +88,7 @@ const AppHeader: React.FC = () => {
|
|||||||
className="dark:hidden"
|
className="dark:hidden"
|
||||||
src="./images/logo/logo.svg"
|
src="./images/logo/logo.svg"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
<Image
|
<Image
|
||||||
width={154}
|
width={154}
|
||||||
@@ -97,6 +96,7 @@ const AppHeader: React.FC = () => {
|
|||||||
className="hidden dark:block"
|
className="hidden dark:block"
|
||||||
src="./images/logo/logo.svg"
|
src="./images/logo/logo.svg"
|
||||||
alt="Logo"
|
alt="Logo"
|
||||||
|
style={{ height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -144,10 +144,10 @@ const AppHeader: React.FC = () => {
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search or type command..."
|
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]"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
|
<button className="absolute right-2.5 top-1/2 inline-flex -translate-y-1/2 items-center gap-0.5 rounded-lg border border-gray-200 bg-gray-50 px-[7px] py-[4.5px] text-xs -tracking-[0.2px] text-gray-500 dark:border-gray-800 dark:bg-white/3 dark:text-gray-400">
|
||||||
<span> ⌘ </span>
|
<span> ⌘ </span>
|
||||||
<span> K </span>
|
<span> K </span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ const AppSidebar: React.FC = () => {
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||||
|
style={{ height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
14
src/service/statisticsService.ts
Normal file
14
src/service/statisticsService.ts
Normal file
@@ -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<CommonResponse<StatisticResponse[]>> => {
|
||||||
|
const response = await api.get(API.Statistics.GET_ALL, { params });
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatisticByDate = async (date: string): Promise<CommonResponse<StatisticResponse>> => {
|
||||||
|
const response = await api.get(API.Statistics.GET_BY_DATE(date));
|
||||||
|
return response?.data;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user