diff --git a/.eslintrc.json b/.eslintrc.json index f7af4a62c..f00583812 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -3,7 +3,12 @@ "browser": true, "es2021": true }, - "extends": ["next/core-web-vitals", "google", "prettier"], + "extends": [ + "next/core-web-vitals", + "google", + "prettier", + "plugin:@tanstack/eslint-plugin-query/recommended" + ], "overrides": [], "parserOptions": { "ecmaVersion": "latest", diff --git a/package-lock.json b/package-lock.json index a4b0e1789..02e411361 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,8 @@ "@radix-ui/react-tabs": "^1.1.0", "@react-email/components": "^0.0.25", "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-query": "^5.51.11", + "@tanstack/react-query-devtools": "^5.55.0", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.6", "class-variance-authority": "^0.7.0", @@ -63,6 +65,7 @@ "zod": "^3.23.8" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.51.13", "@types/node": "^20.16.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -3215,6 +3218,76 @@ "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" } }, + "node_modules/@tanstack/eslint-plugin-query": { + "version": "5.53.0", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-query/-/eslint-plugin-query-5.53.0.tgz", + "integrity": "sha512-Q3WgvK2YTGc3h5EaktDouRkKBPGl3QQFLPZBagpBa6zD70PiNoDY72wWrX9T4yKClMmSulAa0wg5Nj3LVXGkEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.54.1.tgz", + "integrity": "sha512-hKS+WRpT5zBFip21pB6Jx1C0hranWQrbv5EJ7qPoiV5MYI3C8rTCqWC9DdBseiPT1JgQWh8Y55YthuYZNiw3Xw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.54.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.54.0.tgz", + "integrity": "sha512-B8Sa6mh7/4m2fyk2/YnUXeOZ1/us7G/C/i1It8YcCbieXc8vf1AdSYjR+mZIoJeKOKLqA741hZqfj8d4F1NCVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.55.0.tgz", + "integrity": "sha512-2uYuxEbRQD8TORUiTUacEOwt1e8aoSqUOJFGY5TUrh6rQ3U85zrMS2wvbNhBhXGh6Vj69QDCP2yv8tIY7joo6Q==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.54.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.55.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.55.0.tgz", + "integrity": "sha512-omUloSS7Ru+LNmXeK56ygtAgMXMR5M74v8kn4lRjMkjT/aTJHWGI2yJh0I1EE1a8tjwXyviqy+qWfJaeqQcTIA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.54.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.55.0", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.20.5", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.20.5.tgz", @@ -3790,6 +3863,134 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@typescript-eslint/utils": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.4.0.tgz", + "integrity": "sha512-swULW8n1IKLjRAgciCkTCafyTHHfwVQFt8DovmaF69sKbOxTSFMmIZaSHjqO9i/RV0wIblaawhzvtva8Nmm7lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.4.0", + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/typescript-estree": "8.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.4.0.tgz", + "integrity": "sha512-n2jFxLeY0JmKfUqy3P70rs6vdoPjHK8P/w+zJcV3fk0b0BwRXC/zxRTEnAsgYT7MwdQDt/ZEbtdzdVC+hcpF0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.4.0.tgz", + "integrity": "sha512-T1RB3KQdskh9t3v/qv7niK6P8yvn7ja1mS7QK7XfRVL6wtZ8/mFs/FHf4fKvTA0rKnqnYxl/uHFNbnEt0phgbw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.4.0.tgz", + "integrity": "sha512-kJ2OIP4dQw5gdI4uXsaxUZHRwWAGpREJ9Zq6D5L0BweyOrWsL6Sz0YcAZGWhvKnH7fm1J5YFE1JrQL0c9dd53A==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.4.0", + "@typescript-eslint/visitor-keys": "8.4.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.4.0.tgz", + "integrity": "sha512-zTQD6WLNTre1hj5wp09nBIDiOc2U5r/qmzo7wxPn4ZgAjHql09EofqhF9WF+fZHzL5aCyaIpPcT2hyxl73kr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.4.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@typescript-eslint/visitor-keys": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", diff --git a/package.json b/package.json index c2e265857..99c2690bc 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "prepare": "husky install" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.51.13", "@types/node": "^20.16.1", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -49,6 +50,8 @@ "@radix-ui/react-tabs": "^1.1.0", "@react-email/components": "^0.0.25", "@tailwindcss/typography": "^0.5.15", + "@tanstack/react-query": "^5.51.11", + "@tanstack/react-query-devtools": "^5.55.0", "@tanstack/react-table": "^8.20.5", "@tanstack/react-virtual": "^3.10.6", "class-variance-authority": "^0.7.0", diff --git a/src/app/admin/[type]/page.js b/src/app/admin/[type]/page.js index 14256428c..82dec4c2b 100644 --- a/src/app/admin/[type]/page.js +++ b/src/app/admin/[type]/page.js @@ -1,8 +1,8 @@ "use client"; import ProtectedPage from "@/components/ProtectedPage"; import Admins from "@/components/admin/dashboards/Admins"; -import Events from "@/components/admin/services/calendar/Events"; -import CheckIn from "@/components/admin/services/checkin/CheckIn"; +import Events from "@/components/admin/services/calendar"; +import CheckIn from "@/components/admin/services/checkin"; import Committees from "@/components/admin/dashboards/Committees"; import Feedback from "@/components/admin/dashboards/Feedback"; import Interests from "@/components/admin/dashboards/Interests"; diff --git a/src/app/api/statistics/route.js b/src/app/api/statistics/route.js deleted file mode 100644 index b2623ebea..000000000 --- a/src/app/api/statistics/route.js +++ /dev/null @@ -1,82 +0,0 @@ -import { NextResponse } from "next/server"; -import { db } from "../../../utils/firebase"; -import { collection, getDocs, getDoc, doc } from "firebase/firestore"; -import { authenticate } from "@/utils/auth"; - -export const GET = async () => { - const res = NextResponse; - - const { auth } = await authenticate({ - admins: [1], - }); - - if (auth !== 200) { - return res.json( - { message: `Authentication Error: ${"MESSAGE VARIABLE SHOULD BE HERE"}` }, - { status: auth }, - ); - } - - try { - const [statistics, events] = await Promise.all([ - getDoc(doc(db, "statistics", "statistics")), - getDocs(collection(db, "events")), - ]); - const { - teams, - participants, - volunteers, - judges, - mentors, - committees, - sponsors, - panels, - admins, - } = statistics.data(); - - const attendees = {}; - - events.forEach((doc) => { - const { name, attendance } = doc.data(); - attendees[name] = attendance; - }); - - const users = { - participants, - teams, - judges, - volunteers, - mentors, - committees, - sponsors, - panels, - admins, - }; - - const sizeData = ["XS", "S", "M", "L", "XL", "XXL"]; - const statusData = ["1", "0", "-1"]; - - const size = {}; - const status = {}; - - Object.entries(users).forEach(([group, entries]) => { - size[group] = Object.fromEntries( - Object.entries(entries).filter(([key]) => sizeData.includes(key)), - ); - - status[group] = Object.fromEntries( - Object.entries(entries).filter(([key]) => statusData.includes(key)), - ); - }); - - return res.json( - { items: { users: { status, size }, events: attendees } }, - { status: 200 }, - ); - } catch (err) { - return res.json( - { message: `Internal Server Error: ${err}` }, - { status: 500 }, - ); - } -}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2ee7f87a3..7440aa4de 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ /* eslint-disable new-cap */ import "./globals.css"; import { Poppins } from "next/font/google"; -import Session from "@/components/Session"; +import Providers from "@/components/Providers"; import { Toaster } from "react-hot-toast"; import { getServerSession } from "next-auth"; import { options } from "@/utils/auth"; @@ -24,10 +24,10 @@ const RootLayout = async ({ children }: Props) => {
- + {children} - +
diff --git a/src/components/Providers.tsx b/src/components/Providers.tsx new file mode 100644 index 000000000..3ecd9f6ad --- /dev/null +++ b/src/components/Providers.tsx @@ -0,0 +1,29 @@ +"use client"; +import { Session as SessionType } from "next-auth"; +import { SessionProvider } from "next-auth/react"; +import { useState } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +type props = { + children: React.ReactNode; + session: SessionType | null; +}; + +const Providers = ({ children, session }: props) => { + const [client] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: {}, + }, + }), + ); + + return ( + + {children} + + ); +}; + +export default Providers; diff --git a/src/components/Session.tsx b/src/components/Session.tsx deleted file mode 100644 index b27e8e449..000000000 --- a/src/components/Session.tsx +++ /dev/null @@ -1,18 +0,0 @@ -"use client"; -import { Session as SessionType } from "next-auth"; -import { SessionProvider } from "next-auth/react"; - -type props = { - children: React.ReactNode; - session: SessionType | null; -}; - -const Session = ({ children, session }: props) => { - return ( - - {children} - - ); -}; - -export default Session; diff --git a/src/components/admin/services/calendar/CalendarWrapper.jsx b/src/components/admin/services/calendar/Calendar.jsx similarity index 79% rename from src/components/admin/services/calendar/CalendarWrapper.jsx rename to src/components/admin/services/calendar/Calendar.jsx index 08307ee2a..39d0d53ae 100644 --- a/src/components/admin/services/calendar/CalendarWrapper.jsx +++ b/src/components/admin/services/calendar/Calendar.jsx @@ -1,18 +1,30 @@ "use client"; import { useEffect, useState } from "react"; import moment from "moment"; -import { Calendar, momentLocalizer } from "react-big-calendar"; +import { + Calendar as ReactBigCalendar, + momentLocalizer, +} from "react-big-calendar"; import Toolbar from "./Toolbar"; import Event from "./Event"; import Modal from "./Modal"; -const mLocalizer = momentLocalizer(moment); +import { getEvents } from "./actions"; +import { useQuery } from "@tanstack/react-query"; +import "react-big-calendar/lib/css/react-big-calendar.css"; + +const Calendar = () => { + const mLocalizer = momentLocalizer(moment); -const CalendarWrapper = ({ events }) => { const [event, setEvent] = useState(null); const [view, setView] = useState("month"); const [date, setDate] = useState(new Date()); const [tag, setTag] = useState("all"); + const { data: events } = useQuery({ + queryKey: ["/admin/calendar"], + queryFn: async () => getEvents(), + }); + const handleShortcuts = (e) => { switch (e.key) { case "m": @@ -35,7 +47,9 @@ const CalendarWrapper = ({ events }) => { return ( <> {event && } - { events={ tag === "all" ? events - : events.filter( - ({ description }) => - description - .split("\n")[0] - .split("#") - .filter((item) => item !== "")[0] - .trim() - .toLowerCase() === tag, - ) + : events.filter((event) => event.category === tag) } localizer={mLocalizer} defaultView="month" @@ -103,4 +109,4 @@ const CalendarWrapper = ({ events }) => { ); }; -export default CalendarWrapper; +export default Calendar; diff --git a/src/components/admin/services/calendar/Event.tsx b/src/components/admin/services/calendar/Event.tsx index 5a5df9bef..41a53ec81 100644 --- a/src/components/admin/services/calendar/Event.tsx +++ b/src/components/admin/services/calendar/Event.tsx @@ -2,7 +2,7 @@ import React from "react"; interface EventProps { event: { - start: Date; + startDate: Date; summary: string; }; view: string; @@ -14,7 +14,7 @@ const Event: React.FC = ({ event, view }) => {

{view === "month" && ( <> - {new Date(event.start).toLocaleTimeString(navigator.language, { + {new Date(event.startDate).toLocaleTimeString(navigator.language, { hour: "2-digit", minute: "2-digit", })} diff --git a/src/components/admin/services/calendar/Events.jsx b/src/components/admin/services/calendar/Events.jsx deleted file mode 100644 index 86cbc6bd3..000000000 --- a/src/components/admin/services/calendar/Events.jsx +++ /dev/null @@ -1,51 +0,0 @@ -import "react-big-calendar/lib/css/react-big-calendar.css"; -import { LABELS } from "@/data/admin/Calendar"; -import { api } from "@/utils/api"; -import CalendarWrapper from "./CalendarWrapper"; - -const CalendarEvents = async () => { - const min = new Date( - new Date().getTime() - 20 * 7 * 24 * 60 * 60 * 1000, - ).toISOString(); - - const max = new Date( - new Date().getTime() + 20 * 7 * 24 * 60 * 60 * 1000, - ).toISOString(); - - const hackathon = await api({ - method: "GET", - url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime&timeMin=${min}&timeMax=${max}`, - }); - - const leads = await api({ - method: "GET", - url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_LEADS}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime&timeMin=${min}&timeMax=${max}`, - }); - - const hackathonItems = hackathon.items; - const leadsItems = leads.items; - - const rawEvents = [...hackathonItems, ...leadsItems]; - - rawEvents.forEach((item) => { - item.start = new Date(item.start.dateTime); - item.end = new Date(item.end.dateTime); - const [category, assignee] = item.description - .split("\n")[0] - .split("#") - .map((item) => item.trim()) - .filter((item) => item !== ""); - item.category = category; - item.color = LABELS[item.category].background; - item.assignee = assignee; - item.hidden = false; - }); - - return ( -

- -
- ); -}; - -export default CalendarEvents; diff --git a/src/components/admin/services/calendar/Modal.jsx b/src/components/admin/services/calendar/Modal.jsx index fbd98f8db..6a2121656 100644 --- a/src/components/admin/services/calendar/Modal.jsx +++ b/src/components/admin/services/calendar/Modal.jsx @@ -18,14 +18,14 @@ const CalendarModal = ({ event, setEvent }) => {
- {event.start.toLocaleString("default", { + {event.startDate.toLocaleString("default", { month: "long", weekday: "long", day: "2-digit", year: "numeric", })}
- {event.start.toLocaleString("default", { + {event.startDate.toLocaleString("default", { hour: "numeric", minute: "2-digit", })} diff --git a/src/components/admin/services/calendar/actions.ts b/src/components/admin/services/calendar/actions.ts new file mode 100644 index 000000000..2b006a362 --- /dev/null +++ b/src/components/admin/services/calendar/actions.ts @@ -0,0 +1,50 @@ +import { LABELS, label } from "@/data/admin/Calendar"; +import { api } from "@/utils/api"; +import { Event } from "@/types/calendar"; +const min = new Date( + new Date().getTime() - 20 * 7 * 24 * 60 * 60 * 1000, +).toISOString(); + +const max = new Date( + new Date().getTime() + 20 * 7 * 24 * 60 * 60 * 1000, +).toISOString(); + +export const getEvents = async () => { + const hackathonResponse = await api({ + method: "GET", + url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime&timeMin=${min}&timeMax=${max}`, + }); + + const leadsResponse = await api({ + method: "GET", + url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_LEADS}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime&timeMin=${min}&timeMax=${max}`, + }); + + const items = [...hackathonResponse.items, leadsResponse.items][0]; + items.forEach((item: Event) => { + item.startDate = new Date(item.start.dateTime); + item.endDate = new Date(item.end.dateTime); + let category: string = "other"; + let assignee: string = ""; + if (item.description) { + [category, assignee] = item.description + .split("\n")[0] + .split("#") + .map((item: string) => item.trim()) + .filter((item: string) => item !== ""); + } else { + item.description = "N/A"; + } + if (category in LABELS) { + item.color = LABELS[category as keyof label].background; + } else { + category = "other"; + item.color = "!bg-hackathon-tags-gray-text"; + } + item.category = category; + item.assignee = assignee; + item.hidden = false; + }); + + return items; +}; diff --git a/src/components/admin/services/calendar/index.tsx b/src/components/admin/services/calendar/index.tsx new file mode 100644 index 000000000..26b32a869 --- /dev/null +++ b/src/components/admin/services/calendar/index.tsx @@ -0,0 +1,13 @@ +import { ReactQuery } from "@/utils/react-query"; +import { getEvents } from "./actions"; +import Calendar from "./Calendar"; + +const Index = () => { + return ( + + + + ); +}; + +export default Index; diff --git a/src/components/admin/services/checkin/CheckIn.jsx b/src/components/admin/services/checkin/CheckIn.jsx index 1de8a17af..d8f4bd63b 100644 --- a/src/components/admin/services/checkin/CheckIn.jsx +++ b/src/components/admin/services/checkin/CheckIn.jsx @@ -1,66 +1,81 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import Title from "../../Title"; import Scanner from "./Scanner"; import Select from "@/components/Select"; import Button from "../../Button"; import toaster from "@/utils/toaster"; import { api } from "@/utils/api"; +import { getEvents, getUser } from "./actions"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; const CheckIn = () => { const [event, setEvent] = useState({ name: "No events" }); - const [events, setEvents] = useState(null); - const [code, setCode] = useState(null); + const [code, setCode] = useState(""); + const queryClient = useQueryClient(); - useEffect(() => { - api({ - method: "GET", - url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime`, - }).then(({ items }) => { - setEvents( - items.map((event) => { - return { id: event.id, name: event.summary, hidden: false }; - }), - ); - }); - }, []); - const setResult = async (result) => { + const { data: events } = useQuery({ + queryKey: ["events"], + queryFn: async () => getEvents(), + refetchOnWindowFocus: false, + }); + + const mutation = useMutation({ + mutationFn: (body) => + api({ + method: "PUT", + url: "/api/checkin", + body, + }), + onSuccess: () => { + toaster(`Checked in for ${event.name}`, "success"); + }, + onError: (error) => { + toaster("Error checking in!", error); + }, + }); + + const setResult = (result) => { if (result !== code) { setCode(result); toaster("QR Code Scanned", "success"); } }; - const handleCheckIn = async () => { + const handleCheckIn = () => { if (event.name === "No events") { toaster("Please select an event!", "error"); return; } - if (!code) { toaster("Please scan a valid QR code!", "error"); return; } - const [user, date] = code.split("&"); - const delta = Math.round((new Date() - new Date(date)) / 1000); + const [userId, date] = code.split("&"); + console.log("HELLO", process.env.NODE_ENV); + const delta = + process.env.NODE_ENV === "development" + ? Math.round(new Date() - new Date(date)) / 1000 + : Math.round(new Date() - new Date(date)); if (delta < 5000) { - const { items } = await api({ - method: "GET", - url: `/api/checkin?uid=${user}`, - }); - - if (items.includes(event.id)) { - toaster("Already Checked In!", "error"); - return; - } - - api({ - method: "PUT", - url: "/api/checkin", - body: { uid: user, event: event.id, name: event.name }, - }).then(() => toaster(`Checked in for ${event.name}`, "success")); + queryClient + .fetchQuery({ + queryKey: ["/admin/checkin/user", userId], + queryFn: () => getUser(userId), + staleTime: 0, + }) + .then((userData) => { + if (userData.includes(event.id)) { + toaster("Already Checked In!", "error"); + } else { + mutation.mutate({ uid: userId, event: event.id, name: event.name }); + } + }) + .catch((error) => { + toaster("Error Fetching User Data!", "error"); + }); } else { toaster("Expired QR code!", "error"); return; diff --git a/src/components/admin/services/checkin/actions.ts b/src/components/admin/services/checkin/actions.ts new file mode 100644 index 000000000..590df1b73 --- /dev/null +++ b/src/components/admin/services/checkin/actions.ts @@ -0,0 +1,24 @@ +import { api } from "@/utils/api"; +import { Event } from "@/types/calendar"; +export const getEvents = async () => { + const { items } = await api({ + method: "GET", + url: `https://www.googleapis.com/calendar/v3/calendars/${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR}/events?key=${process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_API_KEY}&singleEvents=true&orderBy=startTime`, + }); + + return items.map((event: Event) => ({ + id: event.id, + name: event.summary, + hidden: false, + })); +}; + +export const getUser = async (user: string | null) => { + const { items } = await api({ + method: "GET", + // TODO: THIS SHOULD BE A DB CALL DIRECTLY NOW SINCE WE ARE IN ACTIONS TERRIROTY + url: `/api/checkin?uid=${user}`, + }); + + return items; +}; diff --git a/src/components/admin/services/checkin/index.tsx b/src/components/admin/services/checkin/index.tsx new file mode 100644 index 000000000..2afcee6d7 --- /dev/null +++ b/src/components/admin/services/checkin/index.tsx @@ -0,0 +1,13 @@ +import Checkin from "./CheckIn"; +import { getEvents } from "./actions"; +import { ReactQuery } from "@/utils/react-query"; + +const Index = async () => { + return ( + + + + ); +}; + +export default Index; diff --git a/src/components/admin/services/statistics/Charts.jsx b/src/components/admin/services/statistics/Charts.jsx index 9e0b2f685..42d7e7e34 100644 --- a/src/components/admin/services/statistics/Charts.jsx +++ b/src/components/admin/services/statistics/Charts.jsx @@ -2,19 +2,17 @@ import Chart from "./Chart"; const Charts = ({ counts }) => { return ( - <> -
- {Object.entries(counts).map(([category, data]) => - Object.entries(data) - .filter(([title, sizes]) => - Object.values(sizes).some((count) => count > 0), - ) - .map(([title, data], index) => ( - - )), - )} -
- +
+ {Object.entries(counts).map(([category, data]) => + Object.entries(data) + .filter(([title, sizes]) => + Object.values(sizes).some((count) => count > 0), + ) + .map(([title, data], index) => ( + + )), + )} +
); }; diff --git a/src/components/admin/services/statistics/Statistics.jsx b/src/components/admin/services/statistics/Statistics.jsx index 5dc11c8ca..150afeb99 100644 --- a/src/components/admin/services/statistics/Statistics.jsx +++ b/src/components/admin/services/statistics/Statistics.jsx @@ -1,21 +1,18 @@ "use client"; import Title from "@/components/admin/Title"; import Subtitle from "@/components/admin/Subtitle"; -import { useEffect, useState } from "react"; import Tabs from "./Tabs"; import Loading from "@/components/Loading"; -import { api } from "@/utils/api"; import Charts from "./Charts"; +import { getStats } from "./actions"; +import { useQuery } from "@tanstack/react-query"; const Statistics = () => { - const [counts, setCounts] = useState(null); - useEffect(() => { - api({ - method: "GET", - url: "/api/statistics", - }).then(({ items }) => setCounts(items)); - }, []); + const { data: counts } = useQuery({ + queryKey: ["/admin/statistics"], + queryFn: async () => getStats(), + }); return (
diff --git a/src/components/admin/services/statistics/actions.ts b/src/components/admin/services/statistics/actions.ts new file mode 100644 index 000000000..0a27ed4a1 --- /dev/null +++ b/src/components/admin/services/statistics/actions.ts @@ -0,0 +1,117 @@ +import { db } from "@/utils/firebase"; +import { collection, doc, getDoc, getDocs } from "firebase/firestore"; + +type StatusKeys = "pending" | "accepted" | "rejected"; +type StatusValues = -1 | 1 | 0; +type Status = Record; + +type Size = "XS" | "S" | "M" | "L" | "XL" | "XXL"; +type Sizes = Record; + +type StatsKeys = + | "teams" + | "participants" + | "volunteers" + | "judges" + | "mentors" + | "committees" + | "sponsors" + | "panels" + | "admins"; +type Stats = Record; + +type Event = Record<"attendance", number> & Record<"name", string>; + +export const getStats = async () => { + const [statistics, events] = await Promise.all([ + getDoc(doc(db, "statistics", "statistics")), + getDocs(collection(db, "events")), + ]); + + const { + participants, + teams, + volunteers, + judges, + mentors, + committees, + sponsors, + panels, + admins, + } = statistics.data() as Stats; + + const attendees: Record = {}; + + events.forEach((doc) => { + const { name, attendance } = doc.data() as Event; + attendees[name] = attendance; + }); + + const users = { + admins, + participants, + teams, + judges, + volunteers, + mentors, + committees, + sponsors, + panels, + }; + + const sizeData = ["XS", "S", "M", "L", "XL", "XXL"]; + const statusData = ["1", "0", "-1"]; + + const defaultShirts: Sizes = { + XS: 0, + S: 0, + M: 0, + L: 0, + XL: 0, + XXL: 0, + }; + + const size = { + teams: defaultShirts, + participants: defaultShirts, + volunteers: defaultShirts, + judges: defaultShirts, + mentors: defaultShirts, + committees: defaultShirts, + sponsors: defaultShirts, + panels: defaultShirts, + admins: defaultShirts, + }; + + const defaultRoles: Status = { + pending: 0, + accepted: 0, + rejected: 0, + }; + + const status = { + teams: defaultRoles, + participants: defaultRoles, + volunteers: defaultRoles, + judges: defaultRoles, + mentors: defaultRoles, + committees: defaultRoles, + sponsors: defaultRoles, + panels: defaultRoles, + admins: defaultRoles, + }; + + Object.entries(users).forEach(([group, entries]) => { + const role = group as keyof Stats; + + size[role] = Object.fromEntries( + Object.entries(entries).filter(([key]) => sizeData.includes(key)), + ) as Sizes; + + status[role] = Object.fromEntries( + Object.entries(entries).filter(([key]) => statusData.includes(key)), + ) as Status; + }); + + return { users: { status, size }, events: attendees }; +}; diff --git a/src/components/admin/services/statistics/index.tsx b/src/components/admin/services/statistics/index.tsx new file mode 100644 index 000000000..c4ee00192 --- /dev/null +++ b/src/components/admin/services/statistics/index.tsx @@ -0,0 +1,13 @@ +import { ReactQuery } from "@/utils/react-query"; +import { getStats } from "./actions"; +import Statistics from "./Statistics"; + +const Index = () => { + return ( + + + + ); +}; + +export default Index; diff --git a/src/data/admin/Calendar.ts b/src/data/admin/Calendar.ts index f90b428c4..96514d345 100644 --- a/src/data/admin/Calendar.ts +++ b/src/data/admin/Calendar.ts @@ -4,7 +4,8 @@ interface types { type: string; } -interface label { +export interface label { + other: types; directors: types; marketing: types; operations: types; @@ -61,6 +62,11 @@ export const LABELS: label = { background: "!bg-hackathon-tags-red-text", type: "leads", }, + other: { + color: "gray", + background: "!bg-hackathon-tags-gray-text", + type: "leads", + }, workshop: { color: "grayblue", background: "!bg-hackathon-tags-grayblue-text", diff --git a/src/types/calendar.ts b/src/types/calendar.ts new file mode 100644 index 000000000..d4717fd7c --- /dev/null +++ b/src/types/calendar.ts @@ -0,0 +1,19 @@ +export type Event = { + assignee?: string; + category?: string; + color?: string; + description: string; + end: { + dateTime: string; + timeZone: string; + }; + hidden: boolean; + id: string; + start: { + dateTime: string; + timeZone: string; + }; + startDate?: Date; + endDate?: Date; + summary: string; +}; diff --git a/src/utils/react-query.tsx b/src/utils/react-query.tsx new file mode 100644 index 000000000..03d5d4f6d --- /dev/null +++ b/src/utils/react-query.tsx @@ -0,0 +1,28 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, + QueryFunction, + QueryKey, +} from "@tanstack/react-query"; + +export const ReactQuery = async ({ + children, + query, + queryKey, +}: { + children: React.ReactNode; + query: QueryFunction; + queryKey: QueryKey; +}) => { + const client = new QueryClient(); + + await client.prefetchQuery({ + queryKey, + queryFn: query, + }); + + return ( + {children} + ); +};