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`,
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
<div className="grid grid-cols-12 gap-4 md:gap-6">
|
||||
<div className="col-span-12 space-y-6 xl:col-span-7">
|
||||
<EcommerceMetrics />
|
||||
|
||||
<MonthlySalesChart />
|
||||
<DailyNewChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<MonthlyTarget />
|
||||
<DetailedStatsTable />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12">
|
||||
<StatisticsChart />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-5">
|
||||
<DemographicCard />
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 xl:col-span-7">
|
||||
<RecentOrders />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||
<body className={`${inter.className} dark:bg-gray-900`} suppressHydrationWarning>
|
||||
<StoreProvider>
|
||||
<ThemeProvider>
|
||||
<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";
|
||||
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<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 (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-6">
|
||||
{/* <!-- Metric Item Start --> */}
|
||||
@@ -15,15 +56,15 @@ export const EcommerceMetrics = () => {
|
||||
<div className="flex items-end justify-between mt-5">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Customers
|
||||
Tổng Người Dùng
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<Badge color="success">
|
||||
<ArrowUpIcon />
|
||||
11.01%
|
||||
<Badge color={userGrowth >= 0 ? "success" : "error"}>
|
||||
{userGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||
{Math.abs(userGrowth).toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,16 +78,16 @@ export const EcommerceMetrics = () => {
|
||||
<div className="flex items-end justify-between mt-5">
|
||||
<div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Orders
|
||||
Tổng Dự Án
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<Badge color="error">
|
||||
<ArrowDownIcon className="text-error-500" />
|
||||
9.05%
|
||||
<Badge color={projectGrowth >= 0 ? "success" : "error"}>
|
||||
{projectGrowth >= 0 ? <ArrowUpIcon /> : <ArrowDownIcon className="text-error-500" />}
|
||||
{Math.abs(projectGrowth).toFixed(2)}%
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -54,3 +95,5 @@ export const EcommerceMetrics = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EcommerceMetrics;
|
||||
|
||||
@@ -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<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(() => {
|
||||
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:
|
||||
'<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>',
|
||||
});
|
||||
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<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
setStartDate(val);
|
||||
fetchChartData(val, endDate);
|
||||
};
|
||||
|
||||
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: {
|
||||
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 (
|
||||
<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="w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90">
|
||||
Statistics
|
||||
Thống kê hệ thống
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:justify-end">
|
||||
<ChartTab />
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
|
||||
{/* 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">
|
||||
<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
|
||||
ref={datePickerRef}
|
||||
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"
|
||||
placeholder="Select date range"
|
||||
type="date"
|
||||
value={startDate}
|
||||
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>
|
||||
@@ -172,7 +170,11 @@ export default function StatisticsChart() {
|
||||
|
||||
<div className="max-w-full overflow-x-auto custom-scrollbar">
|
||||
<div className="min-w-[1000px] xl:min-w-full">
|
||||
<Chart options={options} series={series} type="area" height={310} />
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-[310px]">Đang tải...</div>
|
||||
) : (
|
||||
<Chart options={options} series={series} type="area" height={310} />
|
||||
)}
|
||||
</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";
|
||||
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' }}
|
||||
/>
|
||||
<Image
|
||||
width={154}
|
||||
@@ -97,6 +96,7 @@ const AppHeader: React.FC = () => {
|
||||
className="hidden dark:block"
|
||||
src="./images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
style={{ height: 'auto' }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
<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> K </span>
|
||||
</button>
|
||||
|
||||
@@ -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' }}
|
||||
/>
|
||||
</Link>
|
||||
</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