diff --git a/client/package.json b/client/package.json index b504596..48079c3 100644 --- a/client/package.json +++ b/client/package.json @@ -13,11 +13,13 @@ "@internationalized/date": "^3.5.5", "@nextui-org/react": "^2.4.2", "axios": "^1.7.2", + "chart.js": "^4.4.3", "dayjs": "^1.11.12", "formik": "^2.4.6", "framer-motion": "^11.2.10", "openai": "^4.53.2", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.1", "react-router-dom": "^6.23.1", diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index b4152db..66995d2 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: axios: specifier: ^1.7.2 version: 1.7.2 + chart.js: + specifier: ^4.4.3 + version: 4.4.3 dayjs: specifier: ^1.11.12 version: 1.11.12 @@ -29,6 +32,9 @@ dependencies: react: specifier: ^18.2.0 version: 18.3.1 + react-chartjs-2: + specifier: ^5.2.0 + version: 5.2.0(chart.js@4.4.3)(react@18.3.1) react-dom: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) @@ -689,6 +695,10 @@ packages: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.4.15 + /@kurkle/color@0.3.2: + resolution: {integrity: sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==} + dev: false + /@nextui-org/accordion@2.0.35(@nextui-org/system@2.2.2)(@nextui-org/theme@2.2.6)(framer-motion@11.2.10)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-42T8DAgpICKORry5h1UCgAQ71QJ3dCzvqrnnJQco3LICeIER2JT/wEdpxHUVT893MkL6z6CFsJmWNfFJPk59kA==} peerDependencies: @@ -3648,6 +3658,13 @@ packages: supports-color: 7.2.0 dev: true + /chart.js@4.4.3: + resolution: {integrity: sha512-qK1gkGSRYcJzqrrzdR6a+I0vQ4/R+SoODXyAjscQ/4mzuNzySaMCd+hyVxitSY1+L2fjPD1Gbn+ibNqRmwQeLw==} + engines: {pnpm: '>=8'} + dependencies: + '@kurkle/color': 0.3.2 + dev: false + /chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -4747,6 +4764,16 @@ packages: /queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + /react-chartjs-2@5.2.0(chart.js@4.4.3)(react@18.3.1): + resolution: {integrity: sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + chart.js: 4.4.3 + react: 18.3.1 + dev: false + /react-dom@18.3.1(react@18.3.1): resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: diff --git a/client/src/App.tsx b/client/src/App.tsx index fe1efc9..af8b57b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -21,7 +21,7 @@ import HBContestPage from "./pages/HBContestPage"; import HBFormPage from "./pages/HBFormPage"; import DefaultLayout from "./layouts/default"; import AdministratorLayout from "./layouts/administrator"; -import UsersManagement from "./pages/UsersManagement"; +import UsersManagement from "./pages/UsersManagementPage"; import ResetPasswordPage from "./pages/ResetPasswordPage"; import ForgotPasswordPage from "./pages/ForgotPasswordPage"; import RestrictedLayout from "./layouts/restricted"; diff --git a/client/src/components/UserCountGraph.tsx b/client/src/components/UserCountGraph.tsx new file mode 100644 index 0000000..9a4e0aa --- /dev/null +++ b/client/src/components/UserCountGraph.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import instance from "../security/http"; +import config from "../config"; +import { Line } from "react-chartjs-2"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend, + ChartOptions, +} from "chart.js"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + Title, + Tooltip, + Legend +); + +export default function UserCountGraph() { + const [userInformationCollection, setUserInformationCollection] = useState< + any[] + >([]); + const [chartData, setChartData] = useState(null); + + const retrieveUserInformationCollection = () => { + instance.get(`${config.serverAddress}/users/all`).then((values) => { + setUserInformationCollection(values.data); + }); + }; + + useEffect(() => { + retrieveUserInformationCollection(); + }, []); + + useEffect(() => { + if (userInformationCollection.length > 0) { + const lastMonth = new Date(); + lastMonth.setMonth(lastMonth.getMonth() - 1); + const daysCount = new Array(31).fill(0); // 记录过去一个月每天的用户注册数量 + + userInformationCollection.forEach((user) => { + const createdAt = new Date(user.createdAt); + if (createdAt > lastMonth) { + const dayIndex = Math.floor( + (createdAt.getTime() - lastMonth.getTime()) / (1000 * 60 * 60 * 24) + ); + daysCount[dayIndex]++; + } + }); + + const labels = Array.from({ length: 31 }, (_, i) => { + const currentDate = new Date(); + currentDate.setDate(currentDate.getDate() - (30 - i)); + return currentDate.toISOString().split("T")[0]; // 返回日期字符串 YYYY-MM-DD + }); + + setChartData({ + labels: labels, + datasets: [ + { + label: "User Onboard", + data: daysCount, + fill: false, + backgroundColor: "rgba(75,192,192,0.4)", + borderColor: "rgba(75,192,192,1)", + }, + ], + }); + } + }, [userInformationCollection]); + + // 定义图表选项 + const options: ChartOptions<"line"> = { + responsive: true, + scales: { + y: { + beginAtZero: true, + max: Math.max( + 10, + Math.ceil(Math.max(...(chartData?.datasets[0]?.data || [10]))) + ), // 动态设置最大值,最低为10 + ticks: { + stepSize: 1, // 设置步长为1 + }, + }, + }, + }; + + return ( +
+ {chartData ? ( + + ) : ( +

Loading...

+ )} +
+ ); +} diff --git a/client/src/components/UserTownCouncilDistributionChart.tsx b/client/src/components/UserTownCouncilDistributionChart.tsx new file mode 100644 index 0000000..ee49e77 --- /dev/null +++ b/client/src/components/UserTownCouncilDistributionChart.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import instance from "../security/http"; +import config from "../config"; +import { Pie } from "react-chartjs-2"; +import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js"; + +// Register Chart.js components +ChartJS.register(ArcElement, Tooltip, Legend); + +export default function UserTownCouncilDistributionChart() { + const [userInformationCollection, setUserInformationCollection] = useState< + any[] + >([]); + const [townCouncilInformation, setTownCouncilInformation] = useState< + string[] + >([]); + const [chartData, setChartData] = useState(null); + + const retrieveInformation = () => { + instance.get(`${config.serverAddress}/users/all`).then((values) => { + setUserInformationCollection(values.data); + }); + instance + .get(`${config.serverAddress}/users/town-councils-metadata`) + .then((values) => { + setTownCouncilInformation(JSON.parse(values.data).townCouncils); + }); + }; + + const generateChartData = () => { + const townCouncilCounts: { [key: string]: number } = {}; + userInformationCollection.forEach((user: any) => { + const townCouncil = user.townCouncil || "Unknown"; + if (townCouncilCounts[townCouncil]) { + townCouncilCounts[townCouncil]++; + } else { + townCouncilCounts[townCouncil] = 1; + } + }); + + const labels = Object.keys(townCouncilCounts); + const data = Object.values(townCouncilCounts); + + const chartData = { + labels, + datasets: [ + { + data, + backgroundColor: labels.map( + (label, index) => `hsl(${index * (360 / labels.length)}, 70%, 50%)` + ), + }, + ], + }; + + setChartData(chartData); + }; + + useEffect(() => { + retrieveInformation(); + }, []); + + useEffect(() => { + if ( + userInformationCollection.length > 0 && + townCouncilInformation.length > 0 + ) { + generateChartData(); + } + }, [userInformationCollection, townCouncilInformation]); + + const options = { + responsive: true, + plugins: { + legend: { + position: "left" as const, + labels: { + font: { + size: 20, + }, + color: "black", + }, + }, + tooltip: { + callbacks: { + label: function (tooltipItem: any) { + const dataset = tooltipItem.dataset; + const dataIndex = tooltipItem.dataIndex; + const value = dataset.data[dataIndex]; + return `${chartData.labels[dataIndex]}: ${value}`; + }, + }, + }, + }, + }; + + return ( +
+ {chartData ? ( + + ) : ( +

Loading...

+ )} +
+ ); +} diff --git a/client/src/pages/AdministratorSpringboard.tsx b/client/src/pages/AdministratorSpringboard.tsx index e70ac4f..6d466f5 100644 --- a/client/src/pages/AdministratorSpringboard.tsx +++ b/client/src/pages/AdministratorSpringboard.tsx @@ -6,6 +6,8 @@ import UserProfilePicture from "../components/UserProfilePicture"; import { Button, Card } from "@nextui-org/react"; import { PencilSquareIcon } from "../icons"; import EcoconnectFullLogo from "../components/EcoconnectFullLogo"; +import UserCountGraph from "../components/UserCountGraph"; +import UserTownCouncilDistributionChart from "../components/UserTownCouncilDistributionChart"; export default function AdministratorSpringboard() { const navigate = useNavigate(); @@ -75,28 +77,18 @@ export default function AdministratorSpringboard() {

Statistics Overview

-
-
-
-

User Count

-

(past 30 days)

-
-

- Total: 2139 users -

-
-
- {/* TODO: Graph */} -

GRAPH HERE

+
+

Users Onboard

+
+
-
-
-

Population Distribution

-
-
- {/* TODO: Graph */} -

GRAPH HERE

+
+

Population Distribution

+
+
+ +
diff --git a/client/src/pages/UsersManagement.tsx b/client/src/pages/UsersManagementPage.tsx similarity index 98% rename from client/src/pages/UsersManagement.tsx rename to client/src/pages/UsersManagementPage.tsx index 144e44e..1502f26 100644 --- a/client/src/pages/UsersManagement.tsx +++ b/client/src/pages/UsersManagementPage.tsx @@ -34,6 +34,10 @@ export default function UsersManagement() { key: "phoneNumber", label: "TELEPHONE", }, + { + key: "townCouncil", + label: "TOWN COUNCIL", + }, { key: "accountType", label: "ACCOUNT TYPE",