diff --git a/.env.local.template b/.env.local.template index b9f5a791..3e58d9e5 100644 --- a/.env.local.template +++ b/.env.local.template @@ -11,4 +11,4 @@ AUTH0_CLIENT_SECRET=#client secret AUTH0_ISSUER=#issuer base url, example: example.us.auth0.com AWS_SECRET_ACCESS_KEY=#aws secret access key -AWS_ACCESS_KEY_ID=#aws access key id \ No newline at end of file +AWS_ACCESS_KEY_ID=#aws access key id diff --git a/algorithm/README.md b/algorithm/README.md index a7d5f291..f40a55b8 100644 --- a/algorithm/README.md +++ b/algorithm/README.md @@ -9,11 +9,10 @@ cd algorithm python -m venv ".venv" ``` -Lag så en fil i `.\.venv\Lib\site-packages` som slutter på `.pth` og inneholder den absolutte filstien til `mip_matching`-mappen. - ``` .\.venv\Scripts\activate -python -m pip install -r requirements.txt +pip install -e . +pip install -r requirements.txt ``` ## TODOs diff --git a/algorithm/bridge/.env.template b/algorithm/bridge/.env.template new file mode 100644 index 00000000..5c76b2b7 --- /dev/null +++ b/algorithm/bridge/.env.template @@ -0,0 +1,2 @@ +MONGODB_URI=#url to mongodb database +DB_NAME=#name of db \ No newline at end of file diff --git a/algorithm/bridge/fetch_applicants_and_committees.py b/algorithm/bridge/fetch_applicants_and_committees.py new file mode 100644 index 00000000..b01cf645 --- /dev/null +++ b/algorithm/bridge/fetch_applicants_and_committees.py @@ -0,0 +1,76 @@ +from pymongo import MongoClient +from dotenv import load_dotenv +from datetime import datetime, timezone +import os +import certifi + +def main(): + periods = fetch_periods() + + #Sjekker om perioden er etter søknadstiden og før intervjuslutt og hasSentInterviewtimes er false, og returnerer søkere og komitétider dersom det er tilfelle + for period in periods: + periodId = str(period["_id"]) + interview_end = datetime.fromisoformat(period["interviewPeriod"]["end"].replace("Z", "+00:00")) + application_end = datetime.fromisoformat(period["applicationPeriod"]["end"].replace("Z", "+00:00")) + + + now = datetime.now(timezone.utc) + + if application_end > now and period["hasSentInterviewTimes"] == False and interview_end < now: + applicants = fetch_applicants(periodId) + committee_times = fetch_committee_times(periodId) + print(applicants) + print(committee_times) + + return applicants, committee_times + + +def connect_to_db(collection_name): + load_dotenv() + + mongo_uri = os.getenv("MONGODB_URI") + db_name = os.getenv("DB_NAME") + + client = MongoClient(mongo_uri, tlsCAFile=certifi.where()) + + db = client[db_name] # type: ignore + + collection = db[collection_name] + + return collection, client + +def fetch_periods(): + collection, client = connect_to_db("period") + + periods = collection.find() + + periods = list(periods) + + client.close() + + return periods + +def fetch_applicants(periodId): + collection, client = connect_to_db("applicant") + + applicants = collection.find({"periodId": periodId}) + + applicants = list(applicants) + + client.close() + + return applicants + +def fetch_committee_times(periodId): + collection, client = connect_to_db("committee") + + committee_times = collection.find({"periodId": periodId}) + + committee_times = list(committee_times) + + client.close() + + return committee_times + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/algorithm/requirements.txt b/algorithm/requirements.txt index 6a0b6cf3..6b8206b4 100644 Binary files a/algorithm/requirements.txt and b/algorithm/requirements.txt differ diff --git a/algorithm/src/mip_matching/Applicant.py b/algorithm/src/mip_matching/Applicant.py index 5d78429d..d769ebc7 100644 --- a/algorithm/src/mip_matching/Applicant.py +++ b/algorithm/src/mip_matching/Applicant.py @@ -3,8 +3,8 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: # Unngår cyclic import - from Committee import Committee - from TimeInterval import TimeInterval + from mip_matching.Committee import Committee + from mip_matching.TimeInterval import TimeInterval import itertools diff --git a/components/ErrorPage.tsx b/components/ErrorPage.tsx new file mode 100644 index 00000000..786cef3a --- /dev/null +++ b/components/ErrorPage.tsx @@ -0,0 +1,23 @@ +import Image from "next/image"; +import { useTheme } from "../lib/hooks/useTheme"; + +const ErrorPage = () => { + const theme = useTheme(); + + const onlineLogoSrc = + theme === "dark" ? "/Online_hvit.svg" : "/Online_bla.svg"; + + return ( +
+ Online logo +
Det har skjedd en feil :(
+
+ ); +}; + +export default ErrorPage; diff --git a/components/Navbar.tsx b/components/Navbar.tsx index aad4d8b2..44c9e4a6 100644 --- a/components/Navbar.tsx +++ b/components/Navbar.tsx @@ -36,13 +36,12 @@ const Navbar = () => {
- + Online logo @@ -65,7 +64,7 @@ const Navbar = () => { href="/admin" /> )} - {session.user?.isCommitee && ( + {session.user?.isCommittee && (
diff --git a/components/PageTitle.tsx b/components/PageTitle.tsx index 121e864b..b9774f4e 100644 --- a/components/PageTitle.tsx +++ b/components/PageTitle.tsx @@ -19,10 +19,10 @@ const PageTitle = ({ {mainTitle} {subTitle && ( -

+

{boldSubTitle && {boldSubTitle}{boldSubTitle && subTitle && ":"} } {subTitle} -

+
)}
diff --git a/components/PeriodCard.tsx b/components/PeriodCard.tsx index 88a42c60..95ae5999 100644 --- a/components/PeriodCard.tsx +++ b/components/PeriodCard.tsx @@ -4,6 +4,9 @@ import { periodType } from "../lib/types/types"; import { formatDateNorwegian } from "../lib/utils/dateUtils"; import Button from "./Button"; import CheckIcon from "./icons/icons/CheckIcon"; +import { useQuery } from "@tanstack/react-query"; +import { fetchApplicantByPeriodAndId } from "../lib/api/applicantApi"; +import { PeriodSkeleton } from "./PeriodSkeleton"; interface Props { period: periodType; @@ -13,23 +16,18 @@ const PeriodCard = ({ period }: Props) => { const { data: session } = useSession(); const [hasApplied, setHasApplied] = useState(false); - useEffect(() => { - const checkApplicationStatus = async () => { - if (session?.user?.owId) { - const response = await fetch( - `/api/applicants/${period._id}/${session.user.owId}` - ); - if (response.ok) { - const data = await response.json(); - setHasApplied(data.exists); - } - } - }; + const { data: applicantData, isLoading: applicantIsLoading } = useQuery({ + queryKey: ["applicants", period._id, session?.user?.owId], + queryFn: fetchApplicantByPeriodAndId, + }); - if (period._id && session?.user?.owId) { - checkApplicationStatus(); + useEffect(() => { + if (applicantData) { + setHasApplied(applicantData.exists); } - }, [period._id, session?.user?.owId]); + }, [applicantData]); + + if (applicantIsLoading) return ; return (
diff --git a/components/PeriodSkeleton.tsx b/components/PeriodSkeleton.tsx new file mode 100644 index 00000000..ef0249aa --- /dev/null +++ b/components/PeriodSkeleton.tsx @@ -0,0 +1,27 @@ +export const PeriodSkeleton = () => { + return ( +
+
+
+
+
+
+ ); +}; + +export const PeriodSkeletonPage = () => { + return ( +
+
+
+

+ Nåværende opptaksperioder +

+
+ +
+
+
+
+ ); +}; diff --git a/components/Table.tsx b/components/Table.tsx index 90a9a4e4..78039b12 100644 --- a/components/Table.tsx +++ b/components/Table.tsx @@ -24,8 +24,8 @@ const Table = ({ rows, columns, onDelete }: TableProps) => { const router = useRouter(); return ( -
-
+ <> +
@@ -36,16 +36,16 @@ const Table = ({ rows, columns, onDelete }: TableProps) => { {column.label} ) : ( -
+ )} ))} - {rows.map((row) => ( + {rows.map((row, index) => ( row.link && router.push(row.link)} > @@ -78,12 +78,12 @@ const Table = ({ rows, columns, onDelete }: TableProps) => {
-
- {rows.map((row) => ( - +
+ {rows.map((row, index) => ( + ))}
-
+ ); }; diff --git a/components/applicantoverview/ApplicantCard.tsx b/components/applicantoverview/ApplicantCard.tsx index 0f7de84c..b77f73c9 100644 --- a/components/applicantoverview/ApplicantCard.tsx +++ b/components/applicantoverview/ApplicantCard.tsx @@ -72,7 +72,9 @@ const ApplicantCard = ({ applicant, includePreferences }: Props) => { )}

Om:

-

Bankom: {getBankomValue(applicant?.bankom)}

+

+ Ønsker å være økonomiansvarlig: {getBankomValue(applicant?.bankom)} +

{applicant?.about}

diff --git a/components/applicantoverview/ApplicantsOverview.tsx b/components/applicantoverview/ApplicantsOverview.tsx index a93689f0..790d959f 100644 --- a/components/applicantoverview/ApplicantsOverview.tsx +++ b/components/applicantoverview/ApplicantsOverview.tsx @@ -7,10 +7,16 @@ import { } from "../../lib/types/types"; import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline"; import ApplicantTable from "./ApplicantTable"; -import ApplicantOverviewSkeleton from "./ApplicantOverviewSkeleton"; +import ApplicantOverviewSkeleton from "../skeleton/ApplicantOverviewSkeleton"; +import { useQuery } from "@tanstack/react-query"; +import { + fetchApplicantsByPeriodId, + fetchApplicantsByPeriodIdAndCommittee, +} from "../../lib/api/applicantApi"; +import ErrorPage from "../ErrorPage"; interface Props { - period: periodType | null; + period?: periodType | null; committees?: string[] | null; committee?: string; includePreferences: boolean; @@ -42,54 +48,36 @@ const ApplicantsOverview = ({ const [applicants, setApplicants] = useState([]); const [years, setYears] = useState([]); - const [error, setError] = useState(null); - const [isLoading, setIsLoading] = useState(true); const bankomOptions: string[] = ["yes", "no", "maybe"]; - const apiUrl = includePreferences - ? `/api/applicants/${period?._id}` - : `/api/committees/applicants/${period?._id}/${committee}`; + const { + data: applicantsData, + isError: applicantsIsError, + isLoading: applicantsIsLoading, + } = useQuery({ + queryKey: ["applicants", period?._id, committee], + queryFn: includePreferences + ? fetchApplicantsByPeriodId + : fetchApplicantsByPeriodIdAndCommittee, + }); useEffect(() => { - const fetchApplicants = async () => { - try { - const response = await fetch(apiUrl, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw new Error("Failed to fetch applicants"); - } + if (!applicantsData) return; - const data = await response.json(); - const dataType = includePreferences - ? data.applications - : data.applicants; + const dataType = includePreferences + ? applicantsData.applications + : applicantsData.applicants; - setApplicants(dataType); + setApplicants(dataType); - const uniqueYears: string[] = Array.from( - new Set( - dataType.map((applicant: applicantType) => - applicant.grade.toString() - ) - ) - ); - setYears(uniqueYears); - } catch (error: any) { - setError(error.message); - } finally { - setIsLoading(false); - } - }; - if (period) { - fetchApplicants(); - } - }, [period]); + const uniqueYears: string[] = Array.from( + new Set( + dataType.map((applicant: applicantType) => applicant.grade.toString()) + ) + ); + setYears(uniqueYears); + }, [applicantsData, includePreferences]); useEffect(() => { let filtered: applicantType[] = applicants; @@ -103,13 +91,12 @@ const ApplicantsOverview = ({ applicant.preferences.second.toLowerCase() === selectedCommittee.toLowerCase() || applicant.preferences.third.toLowerCase() === - selectedCommittee.toLowerCase() - ); - } else { - return applicant.preferences.some( - (preference) => - preference.committee.toLowerCase() === - selectedCommittee.toLowerCase() + selectedCommittee.toLowerCase() || + applicant.optionalCommittees.some( + (optionalCommittee) => + optionalCommittee.toLowerCase() === + selectedCommittee.toLowerCase() + ) ); } }); @@ -165,17 +152,8 @@ const ApplicantsOverview = ({ }; }, [filterMenuRef]); - if (isLoading) { - return ; - } - - if (error) { - return ( -
-

Det skjedde en feil, vennligst prøv igjen

-
- ); - } + if (applicantsIsLoading) return ; + if (applicantsIsError) return ; return (
diff --git a/components/committee/CommitteeInterviewTimes.tsx b/components/committee/CommitteeInterviewTimes.tsx index d3b80fa4..0edff087 100644 --- a/components/committee/CommitteeInterviewTimes.tsx +++ b/components/committee/CommitteeInterviewTimes.tsx @@ -1,6 +1,6 @@ import React from "react"; -import { BaseSyntheticEvent, useEffect } from "react"; +import { BaseSyntheticEvent, useEffect, useRef } from "react"; import { useState } from "react"; import { useSession } from "next-auth/react"; import FullCalendar from "@fullcalendar/react"; @@ -11,8 +11,10 @@ import toast from "react-hot-toast"; import NotFound from "../../pages/404"; import Button from "../Button"; import ImportantNote from "../ImportantNote"; +import useUnsavedChangesWarning from "../../lib/utils/unSavedChangesWarning"; interface Interview { + title: string; start: string; end: string; } @@ -37,12 +39,23 @@ const CommitteeInterviewTimes = ({ const [visibleRange, setVisibleRange] = useState({ start: "", end: "" }); const [selectedTimeslot, setSelectedTimeslot] = useState("15"); + const [interviewsPlanned, setInterviewsPlanned] = useState(0); const [calendarEvents, setCalendarEvents] = useState([]); const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false); const [countdown, setCountdown] = useState(""); + const [isModalOpen, setIsModalOpen] = useState(false); + const [currentSelection, setCurrentSelection] = useState(null); + const [roomInput, setRoomInput] = useState(""); + const inputRef = useRef(null); + const calendarRef = useRef(null); + + const [deadLineHasPassed, setDeadLineHasPassed] = useState(false); + + const { unsavedChanges, setUnsavedChanges } = useUnsavedChangesWarning(); + useEffect(() => { if (period) { setVisibleRange({ @@ -67,6 +80,7 @@ const CommitteeInterviewTimes = ({ setHasAlreadySubmitted(true); const events = committeeInterviewTimes.availabletimes.map( (at: any) => ({ + title: at.room, start: new Date(at.start).toISOString(), end: new Date(at.end).toISOString(), }) @@ -82,17 +96,67 @@ const CommitteeInterviewTimes = ({ } }, [committeeInterviewTimes]); - const createInterval = (selectionInfo: any) => { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (isModalOpen) { + if (event.key === "Enter") { + handleRoomSubmit(); + } else if (event.key === "Escape") { + setIsModalOpen(false); + } + } + }; + + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isModalOpen, roomInput]); + + useEffect(() => { + if (isModalOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isModalOpen]); + + useEffect(() => { + if (calendarEvents.length > 0) { + calculateInterviewsPlanned(); + } + }, [calendarEvents, selectedTimeslot]); + + const handleDateSelect = (selectionInfo: any) => { + setCurrentSelection(selectionInfo); + setIsModalOpen(true); + setUnsavedChanges(true); + }; + + const handleRoomSubmit = () => { + if (!roomInput) { + toast.error("Vennligst skriv inn et romnavn"); + return; + } + const event = { - title: "", - start: selectionInfo.start, - end: selectionInfo.end, + title: roomInput, + start: currentSelection.start, + end: currentSelection.end, }; - selectionInfo.view.calendar.addEvent(event); + + const calendarApi = currentSelection.view.calendar; + calendarApi.addEvent(event); + calendarApi.render(); + addCell([ - selectionInfo.start.toISOString(), - selectionInfo.end.toISOString(), + roomInput, + currentSelection.start.toISOString(), + currentSelection.end.toISOString(), ]); + + setRoomInput(""); + setIsModalOpen(false); + setCalendarEvents((prevEvents) => [...prevEvents, event]); }; const submit = async (e: BaseSyntheticEvent) => { @@ -127,6 +191,7 @@ const CommitteeInterviewTimes = ({ toast.success("Tidene er sendt inn!"); setHasAlreadySubmitted(true); + setUnsavedChanges(false); } catch (error) { toast.error("Kunne ikke sende inn!"); } @@ -139,23 +204,28 @@ const CommitteeInterviewTimes = ({ ) ); event.remove(); + setUnsavedChanges(true); }; const addCell = (cell: string[]) => { - setMarkedCells([...markedCells, { start: cell[0], end: cell[1] }]); + setMarkedCells([ + ...markedCells, + { title: cell[0], start: cell[1], end: cell[2] }, + ]); + setUnsavedChanges(true); }; const updateInterviewInterval = (e: BaseSyntheticEvent) => { setInterviewInterval(parseInt(e.target.value)); + setUnsavedChanges(true); }; const renderEventContent = (eventContent: any) => { return ( -
- {eventContent.timeText} +
{!hasAlreadySubmitted && ( )} +

+ {eventContent.event.title} +

); }; @@ -187,6 +260,7 @@ const CommitteeInterviewTimes = ({ const startDateTime = new Date(startDateTimeString); const endDateTime = new Date(endDatetimeString); return { + room: event.title, start: startDateTime.toISOString(), end: endDateTime.toISOString(), }; @@ -196,6 +270,7 @@ const CommitteeInterviewTimes = ({ const handleTimeslotSelection = (e: React.ChangeEvent) => { setSelectedTimeslot(e.target.value); + setUnsavedChanges(true); }; const deleteSubmission = async (e: BaseSyntheticEvent) => { @@ -217,6 +292,7 @@ const CommitteeInterviewTimes = ({ setHasAlreadySubmitted(false); setCalendarEvents([]); + setUnsavedChanges(false); } catch (error: any) { console.error("Error deleting submission:", error); toast.error("Klarte ikke å slette innsendingen"); @@ -234,12 +310,16 @@ const CommitteeInterviewTimes = ({ }, [period]); const getSubmissionDeadline = (): string => { - const deadlineIso = period!.interviewPeriod.start; + const deadlineIso = period!.applicationPeriod.end; - if (deadlineIso != null) { + if (deadlineIso != null && !deadLineHasPassed) { const deadlineDate = new Date(deadlineIso); const now = new Date(); + if (now > deadlineDate) { + setDeadLineHasPassed(true); + } + let delta = Math.floor((deadlineDate.getTime() - now.getTime()) / 1000); const days = Math.floor(delta / 86400); @@ -265,11 +345,25 @@ const CommitteeInterviewTimes = ({ return ""; }; - if (!session || !session.user?.isCommitee) { + const calculateInterviewsPlanned = () => { + const totalMinutes = calendarEvents.reduce((acc, event) => { + const start = new Date(event.start); + const end = new Date(event.end); + const duration = (end.getTime() - start.getTime()) / 1000 / 60; + return acc + duration; + }, 0); + + const plannedInterviews = Math.floor( + totalMinutes / parseInt(selectedTimeslot) + ); + setInterviewsPlanned(plannedInterviews); + }; + + if (!session || !session.user?.isCommittee) { return ; } - if (period!.interviewPeriod.start < new Date()) { + if (deadLineHasPassed) { return (

@@ -319,9 +413,11 @@ const CommitteeInterviewTimes = ({

)} +

{`${interviewsPlanned} intervjuer planlagt`}

+ + {isModalOpen && ( +
+
+

+ Skriv inn navn på rom: +

+ setRoomInput(e.target.value)} + /> +
+
+
+
+ )}
); }; diff --git a/components/committee/SendCommitteeMessage.tsx b/components/committee/SendCommitteeMessage.tsx index 4f8218a4..443f1e35 100644 --- a/components/committee/SendCommitteeMessage.tsx +++ b/components/committee/SendCommitteeMessage.tsx @@ -13,6 +13,7 @@ interface Props { } const SendCommitteeMessage = ({ + period, committee, committeeInterviewTimes, }: Props) => { @@ -77,6 +78,16 @@ const SendCommitteeMessage = ({ } }; + if (new Date(period!.applicationPeriod.end) < new Date()) { + return ( +
+

+ Det er ikke lenger mulig å legge inn tider! +

+
+ ); + } + return (

diff --git a/components/form/ApplicationForm.tsx b/components/form/ApplicationForm.tsx index 388a77dd..59a2f107 100644 --- a/components/form/ApplicationForm.tsx +++ b/components/form/ApplicationForm.tsx @@ -5,7 +5,8 @@ import SelectInput from "./SelectInput"; import Line from "./Line"; import { DeepPartial, applicantType } from "../../lib/types/types"; import { changeDisplayName } from "../../lib/utils/toString"; -import { useState } from "react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; interface Props { applicationData: DeepPartial; @@ -15,6 +16,7 @@ interface Props { } export const ApplicationForm = (props: Props) => { + const [isNtnuEmail, setIsNtnuEmail] = useState(false); const availableCommittees = [["Ingen", ""]]; const [selectedOptionalCommittees, setSelectedOptionalCommittees] = useState< string[] @@ -48,9 +50,31 @@ export const ApplicationForm = (props: Props) => { }); }; + useEffect(() => { + if ( + props.applicationData.email && + props.applicationData.email.includes("ntnu.no") + ) { + setIsNtnuEmail(true); + // toast.error( + // "Vi har problemer med å sende e-post til ntnu.no-adresser. Vennligst bruk en annen e-postadresse." + // ); + } else { + setIsNtnuEmail(false); + } + }, [props.applicationData.email]); + return (
+ {isNtnuEmail && ( +
+

+ Vi har problemer med å sende e-post til NTNU e-poster. Vennligst + bruk en annen e-postadresse. +

+
+ )} { ["Usikker (gjerne spør om mer info på intervjuet)", "maybe"], ]} label={ - "Er du interessert i å være økonomiansvarlig i komiteen (tilleggsverv i Bankkom)?" + "Er du interessert i å være økonomiansvarlig i komiteen (tilleggsverv i Bankom)?" } updateInputValues={(value: boolean) => props.setApplicationData({ diff --git a/components/applicantoverview/ApplicantOverviewSkeleton.tsx b/components/skeleton/ApplicantOverviewSkeleton.tsx similarity index 100% rename from components/applicantoverview/ApplicantOverviewSkeleton.tsx rename to components/skeleton/ApplicantOverviewSkeleton.tsx diff --git a/components/skeleton/TableSkeleton.tsx b/components/skeleton/TableSkeleton.tsx new file mode 100644 index 00000000..3e306c43 --- /dev/null +++ b/components/skeleton/TableSkeleton.tsx @@ -0,0 +1,69 @@ +import React from "react"; + +type ColumnType = { + label: string; + field: string; +}; +interface Props { + columns: ColumnType[]; +} + +export const TableSkeleton = ({ columns }: Props) => { + return ( + <> +
+ + + + {columns.map((column) => ( + + {column.label !== "Delete" ? ( + + ) : ( + + )} + + ))} + + + + {Array(3) + .fill(null) + .map((_, index) => ( + + {columns.map((column) => ( + + ))} + + ))} + +
+ {column.label} +
+
+
+
+ +
+
+
+
+
+
+
+
+ + ); +}; diff --git a/lib/api/applicantApi.ts b/lib/api/applicantApi.ts new file mode 100644 index 00000000..ac0ef8c5 --- /dev/null +++ b/lib/api/applicantApi.ts @@ -0,0 +1,63 @@ +import { QueryFunctionContext } from "@tanstack/react-query"; +import { applicantType } from "../types/types"; + +export const fetchApplicantByPeriodAndId = async ( + context: QueryFunctionContext +) => { + const periodId = context.queryKey[1]; + const applicantId = context.queryKey[2]; + return fetch(`/api/applicants/${periodId}/${applicantId}`).then((res) => + res.json() + ); +}; + +export const fetchApplicantsByPeriodId = async ( + context: QueryFunctionContext +) => { + const periodId = context.queryKey[1]; + return fetch(`/api/applicants/${periodId}`).then((res) => res.json()); +}; + +export const fetchApplicantsByPeriodIdAndCommittee = async ( + context: QueryFunctionContext +) => { + const periodId = context.queryKey[1]; + const committee = context.queryKey[2]; + return fetch(`/api/committees/applicants/${periodId}/${committee}`).then( + (res) => res.json() + ); +}; + +export const createApplicant = async (applicant: applicantType) => { + const response = await fetch(`/api/applicants/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(applicant), + }); + + const data = await response.json(); + if (!response.ok) { + throw new Error(data.error || "Unknown error occurred"); + } + return data; +}; + +export const deleteApplicant = async ({ + periodId, + owId, +}: { + periodId: string; + owId: string; +}) => { + const response = await fetch(`/api/applicants/${periodId}/${owId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete the application"); + } + + return response; +}; diff --git a/lib/api/committees.ts b/lib/api/committees.ts new file mode 100644 index 00000000..e14b45a9 --- /dev/null +++ b/lib/api/committees.ts @@ -0,0 +1,3 @@ +export const fetchOwCommittees = async () => { + return fetch(`/api/periods/ow-committees`).then((res) => res.json()); +}; diff --git a/lib/api/periodApi.ts b/lib/api/periodApi.ts new file mode 100644 index 00000000..a5662f53 --- /dev/null +++ b/lib/api/periodApi.ts @@ -0,0 +1,27 @@ +import { QueryFunctionContext } from "@tanstack/react-query"; +import { periodType } from "../types/types"; + +export const fetchPeriodById = async (context: QueryFunctionContext) => { + const id = context.queryKey[1]; + return fetch(`/api/periods/${id}`).then((res) => res.json()); +}; + +export const fetchPeriods = async () => { + return fetch(`/api/periods`).then((res) => res.json()); +}; + +export const deletePeriodById = async (id: string) => { + return fetch(`/api/periods/${id}`, { + method: "DELETE", + }); +}; + +export const createPeriod = async (period: periodType) => { + return fetch(`/api/periods`, { + method: "POST", + body: JSON.stringify(period), + headers: { + "Content-Type": "application/json", + }, + }); +}; diff --git a/lib/email/applicantEmailTemplate.ts b/lib/email/applicantEmailTemplate.ts new file mode 100644 index 00000000..ea428b19 --- /dev/null +++ b/lib/email/applicantEmailTemplate.ts @@ -0,0 +1,68 @@ +import { emailDataType } from "../types/types"; + +export const generateApplicantEmail = (emailData: emailDataType) => { + return ` + + + + + + + + +
+
+ Online Logo + Vi har mottatt din søknad! +
+
+

Dette er en bekreftelse på at vi har mottatt din søknad. Du vil motta en ny e-post med intervjutider etter søkeperioden er over.

+

Her er en oppsummering av din søknad:

+

E-post: ${emailData.emails[0]}

+

Fullt navn: ${emailData.name}

+

Telefonnummer: ${emailData.phone}

+

Trinn: ${emailData.grade}

+

Førstevalg: ${emailData.firstChoice}

+

Andrevalg: ${emailData.secondChoice}

+

Tredjevalg: ${emailData.thirdChoice}

+

Ønsker du å være økonomiansvarlig: ${emailData.bankom}

+

Komiteer søkt i tillegg: ${emailData.optionalCommittees}

+

Kort om deg selv:

+

${emailData.about}

+
+
+ + + `; +}; diff --git a/lib/utils/sendEmail.ts b/lib/email/sendEmail.ts similarity index 87% rename from lib/utils/sendEmail.ts rename to lib/email/sendEmail.ts index 74be47cd..053fcae2 100644 --- a/lib/utils/sendEmail.ts +++ b/lib/email/sendEmail.ts @@ -1,18 +1,18 @@ import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; interface SendEmailProps { - sesClient: SESClient; - fromEmail: string; toEmails: string[]; subject: string; htmlContent: string; } export default async function sendEmail(emailParams: SendEmailProps) { + const sesClient = new SESClient({ region: "eu-north-1" }); + const fromEmail = "opptak@online.ntnu.no"; + try { - const sesClient = emailParams.sesClient; const params = { - Source: emailParams.fromEmail, + Source: fromEmail, Destination: { ToAddresses: emailParams.toEmails, CcAddresses: [], @@ -32,7 +32,7 @@ export default async function sendEmail(emailParams: SendEmailProps) { }, ReplyToAddresses: [], }; - + const command = new SendEmailCommand(params); await sesClient.send(command); diff --git a/lib/mongo/committees.ts b/lib/mongo/committees.ts index 66ca43d2..40009831 100644 --- a/lib/mongo/committees.ts +++ b/lib/mongo/committees.ts @@ -104,6 +104,17 @@ export const createCommittee = async ( return { error: "User does not have access to this committee" }; } + const existingCommitteeTime = await committees.findOne({ + committee: committeeData.committee, + periodId: committeeData.periodId, + }); + + if (existingCommitteeTime) { + return { + error: "409 Committee has already submited times for this period", + }; + } + if (!ObjectId.isValid(periodId)) { return { error: "Invalid periodId" }; } diff --git a/lib/types/next-auth.d.ts b/lib/types/next-auth.d.ts index 2905e63a..9637c7ab 100644 --- a/lib/types/next-auth.d.ts +++ b/lib/types/next-auth.d.ts @@ -13,5 +13,5 @@ export interface User { phone?: string; grade?: number; committees?: string[]; - isCommitee: boolean; + isCommittee: boolean; } diff --git a/lib/types/types.ts b/lib/types/types.ts index 852a43fa..a60a4dc0 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -76,9 +76,11 @@ export type periodType = { }; committees: string[]; optionalCommittees: string[]; + hasSentInterviewTimes: boolean; }; export type AvailableTime = { + room: string; start: string; end: string; }; diff --git a/lib/utils/apiChecks.ts b/lib/utils/apiChecks.ts index eeb7ff41..bb35799b 100644 --- a/lib/utils/apiChecks.ts +++ b/lib/utils/apiChecks.ts @@ -25,7 +25,7 @@ export const checkOwId = (res: NextApiResponse, session: any, id: string) => { }; export const isInCommitee = (res: NextApiResponse, session: any) => { - if (!session?.user?.isCommitee) { + if (!session?.user?.isCommittee) { res.status(403).json({ error: "Access denied, unauthorized" }); return false; } diff --git a/lib/utils/dateUtils.ts b/lib/utils/dateUtils.ts index 1e85ee88..24f496bb 100644 --- a/lib/utils/dateUtils.ts +++ b/lib/utils/dateUtils.ts @@ -10,15 +10,15 @@ export const formatDate = (inputDate: undefined | Date) => { export const formatDateNorwegian = (inputDate?: Date): string => { const date = new Date(inputDate || ""); - const day = date.getDate().toString().padStart(2, "0"); + const day = date.getDate().toString().padStart(2); const monthsNorwegian = [ "jan", "feb", - "mars", - "april", + "mar", + "apr", "mai", - "juni", - "juli", + "jun", + "jul", "aug", "sep", "okt", diff --git a/lib/utils/unSavedChangesWarning.ts b/lib/utils/unSavedChangesWarning.ts new file mode 100644 index 00000000..4fef5a8e --- /dev/null +++ b/lib/utils/unSavedChangesWarning.ts @@ -0,0 +1,42 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; + +const useUnsavedChangesWarning = () => { + const [unsavedChanges, setUnsavedChanges] = useState(false); + const router = useRouter(); + + useEffect(() => { + const handleBeforeUnload = (e: any) => { + if (unsavedChanges) { + const message = + "Du har endringer som ikke er lagret, er du sikker på at du vil forlate siden?"; + e.returnValue = message; + return message; + } + }; + + const handleRouteChange = (url: any) => { + if ( + unsavedChanges && + !window.confirm( + "Du har endringer som ikke er lagret, er du sikker på at du vil forlate siden?" + ) + ) { + router.events.emit("routeChangeError"); + throw `Route change to "${url}" was aborted (this error can be safely ignored).`; + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + router.events.on("routeChangeStart", handleRouteChange); + + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + router.events.off("routeChangeStart", handleRouteChange); + }; + }, [unsavedChanges, router.events]); + + return { unsavedChanges, setUnsavedChanges }; +}; + +export default useUnsavedChangesWarning; diff --git a/lib/utils/validateApplication.ts b/lib/utils/validateApplication.ts index 547c88de..514ba6bf 100644 --- a/lib/utils/validateApplication.ts +++ b/lib/utils/validateApplication.ts @@ -8,6 +8,14 @@ export const validateApplication = (applicationData: any) => { return false; } + // Check if ntnu email is used + if (applicationData.email.includes("ntnu.no")) { + toast.error( + "Vi har problemer med å sende e-post til NTNU e-poster. Vennligst bruk en annen e-postadresse." + ); + return false; + } + // Check if phone number is valid if (!validator.isMobilePhone(applicationData.phone, "nb-NO")) { toast.error("Fyll inn et gyldig mobilnummer"); diff --git a/lib/utils/validators.ts b/lib/utils/validators.ts index 66356d35..60bf17fb 100644 --- a/lib/utils/validators.ts +++ b/lib/utils/validators.ts @@ -65,6 +65,53 @@ export const isCommitteeType = (data: any): data is commiteeType => { return hasBasicFields; }; + +export const validateCommittee = (data: any, period: periodType): boolean => { + const hasBasicFields = + typeof data.period_name === "string" && + typeof data.committee === "string" && + typeof data.timeslot === "string" && + Array.isArray(data.availabletimes) && + data.availabletimes.every( + (time: { start: string; end: string }) => + typeof time.start === "string" && typeof time.end === "string" + ); + + const isPeriodNameValid = data.periodId === String(period._id); + + const isBeforeDeadline = new Date() <= new Date(period.applicationPeriod.end); + + const committeeExists = + period.committees.some((committee) => { + return committee.toLowerCase() === data.committee.toLowerCase(); + }) || + period.optionalCommittees.some((committee) => { + return committee.toLowerCase() === data.committee.toLowerCase(); + }); + + const isWithinInterviewPeriod = data.availabletimes.every( + (time: { start: string; end: string }) => { + const startTime = new Date(time.start); + const endTime = new Date(time.end); + + return ( + startTime >= new Date(period.interviewPeriod.start) && + startTime <= new Date(period.interviewPeriod.end) && + endTime <= new Date(period.interviewPeriod.end) && + endTime >= new Date(period.interviewPeriod.start) + ); + } + ); + + return ( + hasBasicFields && + isPeriodNameValid && + committeeExists && + isWithinInterviewPeriod && + isBeforeDeadline + ); +}; + export const isPeriodType = (data: any): data is periodType => { const isDateString = (str: any): boolean => { return typeof str === "string" && !isNaN(Date.parse(str)); diff --git a/package-lock.json b/package-lock.json index a9716c01..cb5d214a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@next/eslint-plugin-next": "^14.2.5", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "github:tailwindcss/typography", + "@tanstack/react-query": "^5.51.11", "@types/mongodb": "^4.0.7", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", @@ -39,7 +40,7 @@ "eslint": "^8.24.0", "eslint-config-next": "12.3.1", "eslint-plugin-react": "^7.35.0", - "postcss": "^8.4.16", + "postcss": "^8.4.40", "prettier": "3.0.3", "tailwindcss": "^3.1.8", "typescript": "4.8.3" @@ -1633,6 +1634,30 @@ "tailwindcss": ">=3.0.0 || insiders" } }, + "node_modules/@tanstack/query-core": { + "version": "5.51.9", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.51.9.tgz", + "integrity": "sha512-HsAwaY5J19MD18ykZDS3aVVh+bAt0i7m6uQlFC2b77DLV9djo+xEN7MWQAQQTR8IM+7r/zbozTQ7P0xr0bHuew==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.51.11", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.51.11.tgz", + "integrity": "sha512-4Kq2x0XpDlpvSnaLG+8pHNH60zEc3mBvb3B2tOMDjcPCi/o+Du3p/9qpPLwJOTliVxxPJAP27fuIhLrsRdCr7A==", + "dependencies": { + "@tanstack/query-core": "5.51.9" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -5133,9 +5158,9 @@ } }, "node_modules/postcss": { - "version": "8.4.39", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", - "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", + "version": "8.4.40", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", + "integrity": "sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index bd5c735e..19d37dd7 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@next/eslint-plugin-next": "^14.2.5", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "github:tailwindcss/typography", + "@tanstack/react-query": "^5.51.11", "@types/mongodb": "^4.0.7", "@typescript-eslint/eslint-plugin": "^7.17.0", "@typescript-eslint/parser": "^7.17.0", @@ -42,7 +43,7 @@ "eslint": "^8.24.0", "eslint-config-next": "12.3.1", "eslint-plugin-react": "^7.35.0", - "postcss": "^8.4.16", + "postcss": "^8.4.40", "prettier": "3.0.3", "tailwindcss": "^3.1.8", "typescript": "4.8.3" diff --git a/pages/_app.tsx b/pages/_app.tsx index b3015108..33f1c254 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -8,6 +8,15 @@ import Navbar from "../components/Navbar"; import Footer from "../components/Footer"; import LoadingPage from "../components/LoadingPage"; import Signature from "../lib/utils/Signature"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient({ + defaultOptions: { // TODO: go over default options + queries: { + staleTime: 1000 * 60 * 10, + }, + }, +}); const SessionHandler: React.FC<{ children: React.ReactNode }> = ({ children, @@ -18,6 +27,7 @@ const SessionHandler: React.FC<{ children: React.ReactNode }> = ({ //Tihi useEffect(() => { console.log(Signature); + console.log("jo tester"); }, []); useEffect(() => { @@ -70,12 +80,14 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: any) {
- - -
- -
-
+ + + +
+ +
+
+
diff --git a/pages/admin/[period-id]/index.tsx b/pages/admin/[period-id]/index.tsx index d1f828db..421f24a2 100644 --- a/pages/admin/[period-id]/index.tsx +++ b/pages/admin/[period-id]/index.tsx @@ -4,40 +4,33 @@ import router from "next/router"; import { periodType } from "../../../lib/types/types"; import NotFound from "../../404"; import ApplicantsOverview from "../../../components/applicantoverview/ApplicantsOverview"; +import { useQuery } from "@tanstack/react-query"; +import { fetchPeriodById } from "../../../lib/api/periodApi"; +import LoadingPage from "../../../components/LoadingPage"; +import ErrorPage from "../../../components/ErrorPage"; const Admin = () => { const { data: session } = useSession(); const periodId = router.query["period-id"]; + const [period, setPeriod] = useState(null); const [committees, setCommittees] = useState(null); - useEffect(() => { - const fetchPeriod = async () => { - if (!session || session.user?.role !== "admin") { - return; - } - if (periodId === undefined) return; - - try { - const response = await fetch(`/api/periods/${periodId}`); - const data = await response.json(); - if (response.ok) { - setPeriod(data.period); - setCommittees(data.period.committees); - } else { - throw new Error(data.error || "Unknown error"); - } - } catch (error) { - console.error("Error checking period:", error); - } - }; + const { data, isError, isLoading } = useQuery({ + queryKey: ["periods", periodId], + queryFn: fetchPeriodById, + }); - fetchPeriod(); - }, [session?.user?.owId, periodId]); + useEffect(() => { + setPeriod(data?.period); + setCommittees( + data?.period.committees.concat(data?.period.optionalCommittees) + ); + }, [data, session?.user?.owId]); - if (!session || session.user?.role !== "admin") { - return ; - } + if (session?.user?.role !== "admin") return ; + if (isLoading) return ; + if (isError) return ; return ( { + const queryClient = useQueryClient(); const { data: session } = useSession(); const [periods, setPeriods] = useState([]); - const fetchPeriods = async () => { - try { - const response = await fetch("/api/periods"); - const data = await response.json(); - setPeriods( - data.periods.map((period: periodType) => { - return { - id: period._id, - name: period.name, - application: - formatDate(period.applicationPeriod.start) + - " til " + - formatDate(period.applicationPeriod.end), - interview: - formatDate(period.interviewPeriod.start) + - " til " + - formatDate(period.interviewPeriod.end), - committees: period.committees, - link: `/admin/${period._id}`, - }; - }) - ); - } catch (error) { - console.error("Failed to fetch application periods:", error); - } - }; + const { + data: periodsData, + isError: periodsIsError, + isLoading: periodsIsLoading, + } = useQuery({ + queryKey: ["periods"], + queryFn: fetchPeriods, + }); + + const deletePeriodByIdMutation = useMutation({ + mutationFn: deletePeriodById, + onSuccess: () => + queryClient.invalidateQueries({ + // TODO: try to update cache instead + queryKey: ["periods"], + }), + }); useEffect(() => { - fetchPeriods(); - }, []); + if (!periodsData) return; + + setPeriods( + periodsData.periods.map((period: periodType) => { + return { + id: period._id, + name: period.name, + application: + formatDate(period.applicationPeriod.start) + + " til " + + formatDate(period.applicationPeriod.end), + interview: + formatDate(period.interviewPeriod.start) + + " til " + + formatDate(period.interviewPeriod.end), + committees: period.committees, + link: `/admin/${period._id}`, + }; + }) + ); + }, [periodsData]); const deletePeriod = async (id: string, name: string) => { const isConfirmed = window.confirm( `Er det sikker på at du ønsker å slette ${name}?` ); if (!isConfirmed) return; - - try { - await fetch(`/api/periods/${id}`, { - method: "DELETE", - }); - setPeriods(periods.filter((period) => period.id !== id)); - } catch (error) { - console.error("Failed to delete period:", error); - } + deletePeriodByIdMutation.mutate(id); }; + useEffect(() => { + if (deletePeriodByIdMutation.isSuccess) toast.success("Periode slettet"); + if (deletePeriodByIdMutation.isError) + toast.error("Noe gikk galt, prøv igjen"); + }, [deletePeriodByIdMutation]); + const periodsColumns = [ { label: "Navn", field: "name" }, { label: "Søknad", field: "application" }, @@ -65,9 +80,8 @@ const Admin = () => { { label: "Delete", field: "delete" }, ]; - if (!session || session.user?.role !== "admin") { - return ; - } + if (!session || session.user?.role !== "admin") return ; + if (periodsIsError) return ; return (
@@ -83,7 +97,9 @@ const Admin = () => { />
- {periods.length > 0 && ( + {periodsIsLoading ? ( + + ) : ( { + const queryClient = useQueryClient(); const router = useRouter(); const [showPreview, setShowPreview] = useState(false); @@ -28,8 +33,40 @@ const NewPeriod = () => { }, committees: [], optionalCommittees: [], + hasSentInterviewTimes: false, }); + const { + data: owCommitteeData, + isError: owCommitteeIsError, + isLoading: owCommitteeIsLoading, + } = useQuery({ + queryKey: ["ow-committees"], + queryFn: fetchOwCommittees, + }); + + const createPeriodMutation = useMutation({ + mutationFn: createPeriod, + onSuccess: () => + queryClient.invalidateQueries({ + // TODO: try to update cache instead + queryKey: ["periods"], + }), + }); + + useEffect(() => { + if (!owCommitteeData) return; + setAvailableCommittees( + owCommitteeData.map( + ({ name_short, email }: { name_short: string; email: string }) => ({ + name: name_short, + value: name_short, + description: email, + }) + ) + ); + }, [owCommitteeData]); + const updateApplicationPeriodDates = ({ start, end, @@ -65,61 +102,27 @@ const NewPeriod = () => { const [availableCommittees, setAvailableCommittees] = useState< { name: string; value: string; description: string }[] >([]); - const [isLoadingCommittees, setIsLoadingCommittees] = useState(true); useEffect(() => { - const fetchCommittees = async () => { - setIsLoadingCommittees(true); - try { - const response = await fetch("/api/periods/ow-committees"); - if (!response.ok) throw new Error("Failed to fetch committees"); - const committees = await response.json(); - setAvailableCommittees( - committees.map( - ({ name_short, email }: { name_short: string; email: string }) => ({ - name: name_short, - value: name_short, - description: email, - }) - ) - ); - } catch (error) { - console.error(error); - toast.error("Failed to load committees"); - } finally { - setIsLoadingCommittees(false); - } - }; - fetchCommittees(); - }, []); - - const handleAddPeriod = async () => { - if (!validatePeriod(periodData)) { - return; - } - try { - const response = await fetch("/api/periods", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(periodData), - }); - if (!response.ok) { - throw new Error(`Error creating applicant: ${response.statusText}`); - } - + if (createPeriodMutation.isSuccess) { toast.success("Periode opprettet"); router.push("/admin"); - } catch (error) { - toast.error("Det skjedde en feil, vennligst prøv igjen"); } + if (createPeriodMutation.isError) toast.error("Noe gikk galt, prøv igjen"); + }, [createPeriodMutation, router]); + + const handleAddPeriod = async () => { + if (!validatePeriod(periodData)) return; + + createPeriodMutation.mutate(periodData as periodType); }; const handlePreviewPeriod = () => { setShowPreview((prev) => !prev); }; + if (owCommitteeIsError) return ; + return ( <>
@@ -162,7 +165,7 @@ const NewPeriod = () => { updateDates={updateInterviewPeriodDates} /> - {isLoadingCommittees ? ( + {owCommitteeIsLoading ? (
Laster komiteer...
) : (
diff --git a/pages/api/applicants/index.ts b/pages/api/applicants/index.ts index 022b72aa..5f971757 100644 --- a/pages/api/applicants/index.ts +++ b/pages/api/applicants/index.ts @@ -6,10 +6,10 @@ import { getServerSession } from "next-auth"; import { emailDataType } from "../../../lib/types/types"; import { isApplicantType } from "../../../lib/utils/validators"; import { isAdmin, hasSession, checkOwId } from "../../../lib/utils/apiChecks"; -import { SESClient } from "@aws-sdk/client-ses"; import capitalizeFirstLetter from "../../../lib/utils/capitalizeFirstLetter"; -import sendEmail from "../../../lib/utils/sendEmail"; +import sendEmail from "../../../lib/email/sendEmail"; import { changeDisplayName } from "../../../lib/utils/toString"; +import { generateApplicantEmail } from "../../../lib/email/applicantEmailTemplate"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const session = await getServerSession(req, res, authOptions); @@ -55,8 +55,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { applicant, error } = await createApplicant(requestBody); if (error) throw new Error(error); - const sesClient = new SESClient({ region: "eu-north-1" }); - if (applicant != null) { let optionalCommitteesString = ""; if (applicant.optionalCommittees.length > 0) { @@ -103,11 +101,9 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { try { await sendEmail({ - sesClient: sesClient, - fromEmail: "opptak@online.ntnu.no", toEmails: emailData.emails, subject: "Vi har mottatt din søknad!", - htmlContent: `Dette er en bekreftelse på at vi har mottatt din søknad. Du vil motta en ny e-post med intervjutider etter søkeperioden er over. Her er en oppsummering av din søknad:

E-post: ${emailData.emails[0]}

Fullt navn: ${emailData.name}

Telefonnummer: ${emailData.phone}

Trinn: ${emailData.grade}

Førstevalg: ${emailData.firstChoice}

Andrevalg: ${emailData.secondChoice}

Tredjevalg: ${emailData.thirdChoice}

Ønsker du å være økonomiansvarlig: ${emailData.bankom}

Andre valg: ${emailData.optionalCommittees}

Kort om deg selv:
${emailData.about}`, + htmlContent: generateApplicantEmail(emailData), }); console.log("Email sent to: ", emailData.emails); diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index f32d487d..4252c98c 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -57,7 +57,7 @@ export const authOptions: NextAuthOptions = { committees: committeeData.results.map((committee: any) => committee.name_short.toLowerCase() ), - isCommitee: userInfo.is_committee, + isCommittee: userInfo.is_committee, }; }, }), @@ -75,7 +75,7 @@ export const authOptions: NextAuthOptions = { token.grade = user.grade; token.subId = user.subId; token.committees = user.committees; - token.isCommitee = user.isCommitee; + token.isCommittee = user.isCommittee; token.role = adminEmails.includes(user.email) ? "admin" : "user"; } return token; @@ -91,7 +91,7 @@ export const authOptions: NextAuthOptions = { session.user.grade = token.grade as number; session.user.id = token.id as string; session.user.committees = token.committees as string[]; - session.user.isCommitee = token.isCommitee as boolean; + session.user.isCommittee = token.isCommittee as boolean; } return session; }, diff --git a/pages/api/committees/applicants/[period-id]/[committee].ts b/pages/api/committees/applicants/[period-id]/[committee].ts index 29f91c82..ab0dcf17 100644 --- a/pages/api/committees/applicants/[period-id]/[committee].ts +++ b/pages/api/committees/applicants/[period-id]/[committee].ts @@ -23,7 +23,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { .json({ error: "Invalid or missing periodId parameter" }); } - if (!session.user?.isCommitee || !session.user.committees) { + if (!session.user?.isCommittee || !session.user.committees) { return res.status(403).json({ error: "Access denied, unauthorized" }); } diff --git a/pages/api/committees/times/[period-id]/[committee].ts b/pages/api/committees/times/[period-id]/[committee].ts index 62716157..ff20f15e 100644 --- a/pages/api/committees/times/[period-id]/[committee].ts +++ b/pages/api/committees/times/[period-id]/[committee].ts @@ -7,6 +7,7 @@ import { import { getServerSession } from "next-auth"; import { authOptions } from "../../../auth/[...nextauth]"; import { hasSession, isInCommitee } from "../../../../../lib/utils/apiChecks"; +import { getPeriodById } from "../../../../../lib/mongo/periods"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const session = await getServerSession(req, res, authOptions); @@ -47,6 +48,19 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { const { message } = req.body; try { + if (typeof message !== "string") { + return res.status(400).json({ error: "Invalid message parameter" }); + } + + const { period } = await getPeriodById(String(periodId)); + if (!period) { + return res.status(400).json({ error: "Invalid periodId" }); + } + + if (new Date() > new Date(period.applicationPeriod.end)) { + return res.status(400).json({ error: "Application period has ended" }); + } + const { updatedMessage, error } = await updateCommitteeMessage( selectedCommittee, periodId, @@ -63,6 +77,15 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (req.method === "DELETE") { try { + const { period } = await getPeriodById(String(periodId)); + if (!period) { + return res.status(400).json({ error: "Invalid periodId" }); + } + + if (new Date() > new Date(period.applicationPeriod.end)) { + return res.status(400).json({ error: "Application period has ended" }); + } + const { error } = await deleteCommittee( selectedCommittee, periodId, diff --git a/pages/api/committees/times/[period-id]/index.ts b/pages/api/committees/times/[period-id]/index.ts index 50221e43..15851b53 100644 --- a/pages/api/committees/times/[period-id]/index.ts +++ b/pages/api/committees/times/[period-id]/index.ts @@ -3,7 +3,12 @@ import {createCommittee } from "../../../../../lib/mongo/committees"; import { getServerSession } from "next-auth"; import { authOptions } from "../../../auth/[...nextauth]"; import { hasSession, isInCommitee } from "../../../../../lib/utils/apiChecks"; -import { isCommitteeType } from "../../../../../lib/utils/validators"; +import { + isCommitteeType, + validateCommittee, +} from "../../../../../lib/utils/validators"; +import { commiteeType } from "../../../../../lib/types/types"; +import { getPeriodById } from "../../../../../lib/mongo/periods"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const session = await getServerSession(req, res, authOptions); @@ -19,12 +24,25 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (!isInCommitee(res, session)) return; if (req.method === "POST") { - const committeeData = req.body; + const committeeData: commiteeType = req.body; if (!isCommitteeType(req.body)) { return res.status(400).json({ error: "Invalid data format" }); } + const { period } = await getPeriodById(String(committeeData.periodId)); + if (!period) { + return res.status(400).json({ error: "Invalid periodId" }); + } + + if (new Date() > new Date(period.applicationPeriod.end)) { + return res.status(400).json({ error: "Application period has ended" }); + } + + if (!validateCommittee(committeeData, period)) { + return res.status(400).json({ error: "Invalid data format" }); + } + try { const { committee, error } = await createCommittee( committeeData, diff --git a/pages/application/[period-id].tsx b/pages/application/[period-id].tsx index 9a469f51..88064337 100644 --- a/pages/application/[period-id].tsx +++ b/pages/application/[period-id].tsx @@ -16,24 +16,23 @@ import ApplicantCard from "../../components/applicantoverview/ApplicantCard"; import LoadingPage from "../../components/LoadingPage"; import { formatDateNorwegian } from "../../lib/utils/dateUtils"; import PageTitle from "../../components/PageTitle"; - -interface FetchedApplicationData { - exists: boolean; - application: applicantType; -} +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { fetchPeriodById } from "../../lib/api/periodApi"; +import { + createApplicant, + deleteApplicant, + fetchApplicantByPeriodAndId, +} from "../../lib/api/applicantApi"; +import ErrorPage from "../../components/ErrorPage"; const Application: NextPage = () => { + const queryClient = useQueryClient(); const { data: session } = useSession(); const router = useRouter(); const periodId = router.query["period-id"] as string; - - const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(true); - const [periodExists, setPeriodExists] = useState(false); - const [fetchedApplicationData, setFetchedApplicationData] = - useState(null); + const applicantId = session?.user?.owId; const [activeTab, setActiveTab] = useState(0); - const [isLoading, setIsLoading] = useState(true); const [applicationData, setApplicationData] = useState< DeepPartial >({ @@ -53,114 +52,69 @@ const Application: NextPage = () => { const [period, setPeriod] = useState(); const [isApplicationPeriodOver, setIsApplicationPeriodOver] = useState(false); - useEffect(() => { - if (!period) { - return; - } - - const currentDate = new Date().toISOString(); - if (new Date(period.applicationPeriod.end) < new Date(currentDate)) { - setIsApplicationPeriodOver(true); - } - }, [period]); - - useEffect(() => { - const checkPeriodAndApplicationStatus = async () => { - if (!periodId || !session?.user?.owId) return; - - try { - const periodResponse = await fetch(`/api/periods/${periodId}`); - const periodData = await periodResponse.json(); - if (periodResponse.ok) { - setPeriod(periodData.period); - setPeriodExists(periodData.exists); - fetchApplicationData(); - } else { - throw new Error(periodData.error || "Unknown error"); - } - } catch (error) { - console.error("Error checking period:", error); - } - - try { - const applicationResponse = await fetch( - `/api/applicants/${periodId}/${session.user.owId}` - ); - const applicationData = await applicationResponse.json(); - - if (!applicationResponse.ok) { - throw new Error(applicationData.error || "Unknown error"); - } - } catch (error) { - console.error("Error checking application status:", error); - } finally { - setIsLoading(false); - } - }; - - checkPeriodAndApplicationStatus(); - }, [session?.user?.owId, periodId]); + const { + data: periodData, + isError: periodIsError, + isLoading: periodIsLoading, + } = useQuery({ + queryKey: ["periods", periodId], + queryFn: fetchPeriodById, + }); - const handleSubmitApplication = async () => { - if (!validateApplication(applicationData)) return; + const { + data: applicantData, + isError: applicantIsError, + isLoading: applicantIsLoading, + } = useQuery({ + queryKey: ["applicant", periodId, applicantId], + queryFn: fetchApplicantByPeriodAndId, + }); - try { - applicationData.periodId = periodId as string; - const response = await fetch("/api/applicants", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(applicationData), + const createApplicantMutation = useMutation({ + mutationFn: createApplicant, + onSuccess: () => { + queryClient.setQueryData(["applicant", periodId, applicantId], { + applicant: applicationData, + exists: true, }); - - const responseData = await response.json(); - - if (response.ok) { - toast.success("Søknad sendt inn"); - setHasAlreadySubmitted(true); - } else { - if ( - responseData.error === - "409 Application already exists for this period" - ) { - toast.error("Du har allerede søkt for denne perioden"); - } else { - throw new Error(`Error creating applicant: ${response.statusText}`); - } - } - } catch (error) { - if (error instanceof Error) { - toast.error(error.message); + toast.success("Søknad sendt inn"); + }, + onError: (error) => { + if (error.message === "409 Application already exists for this period") { + toast.error("Du har allerede søkt for denne perioden"); } else { toast.error("Det skjedde en feil, vennligst prøv igjen"); } - } finally { - fetchApplicationData(); - } - }; + }, + }); - const fetchApplicationData = async () => { - if (!session?.user?.owId || !periodId) return; + const deleteApplicantMutation = useMutation({ + mutationFn: deleteApplicant, + onSuccess: () => { + queryClient.setQueryData(["applicant", periodId, applicantId], null); + toast.success("Søknad trukket tilbake"); + setActiveTab(0); + }, + onError: () => toast.error("Det skjedde en feil, vennligst prøv igjen"), + }); - try { - const response = await fetch( - `/api/applicants/${periodId}/${session.user.owId}` - ); - const data = await response.json(); - if (!data.exists) { - setHasAlreadySubmitted(false); - } else { - setFetchedApplicationData(data); - } + useEffect(() => { + if (!periodData || !periodData.period) return; - if (!response.ok) { - throw new Error(data.error || "Unknown error"); - } - } catch (error) { - console.error("Error fetching application data:", error); - toast.error("Failed to fetch application data."); + setPeriod(periodData.period); + + const currentDate = new Date().toISOString(); + if ( + new Date(periodData.period.applicationPeriod.end) < new Date(currentDate) + ) { + setIsApplicationPeriodOver(true); } + }, [periodData]); + + const handleSubmitApplication = async () => { + if (!validateApplication(applicationData)) return; + applicationData.periodId = periodId as string; + createApplicantMutation.mutate(applicationData as applicantType); }; const handleDeleteApplication = async () => { @@ -171,28 +125,19 @@ const Application: NextPage = () => { if (!confirm("Er du sikker på at du vil trekke tilbake søknaden?")) return; - try { - const response = await fetch( - `/api/applicants/${periodId}/${session.user.owId}`, - { - method: "DELETE", - } - ); - - if (!response.ok) { - throw new Error("Failed to delete the application"); - } - - toast.success("Søknad trukket tilbake"); - setHasAlreadySubmitted(false); - } catch (error) { - toast.error("Det skjedde en feil, vennligst prøv igjen"); - } + deleteApplicantMutation.mutate({ periodId, owId: session?.user?.owId }); }; - if (isLoading) return ; + if ( + periodIsLoading || + applicantIsLoading || + createApplicantMutation.isPending || + deleteApplicantMutation.isPending + ) + return ; + if (periodIsError || applicantIsError) return ; - if (!periodExists) { + if (!periodData?.exists) return (

@@ -200,9 +145,8 @@ const Application: NextPage = () => {

); - } - if (hasAlreadySubmitted) { + if (applicantData?.exists) return (
@@ -224,22 +168,25 @@ const Application: NextPage = () => { onClick={handleDeleteApplication} /> )} - {fetchedApplicationData && ( + {applicantData?.application && (
)}
); - } return (
- + { - const { data: session } = useSession(); const [currentPeriods, setCurrentPeriods] = useState([]); - const [isLoading, setIsLoading] = useState(false); + + const { + data: periodsData, + isError: periodsIsError, + isLoading: periodsIsLoading, + } = useQuery({ + queryKey: ["periods"], + queryFn: fetchPeriods, + }); useEffect(() => { - const fetchPeriods = async () => { - try { - setIsLoading(true); - const res = await fetch("/api/periods"); - const data = await res.json(); - const today = new Date(); + if (!periodsData) return; - setCurrentPeriods( - data.periods.filter((period: periodType) => { - const startDate = new Date(period.applicationPeriod.start || ""); - const endDate = new Date(period.applicationPeriod.end || ""); + const today = new Date(); - return startDate <= today && endDate >= today; - }) - ); - setIsLoading(false); - } catch (error) { - console.error("Failed to fetch application periods:", error); - } - }; + setCurrentPeriods( + periodsData.periods.filter((period: periodType) => { + const startDate = new Date(period.applicationPeriod.start || ""); + const endDate = new Date(period.applicationPeriod.end || ""); - session && fetchPeriods(); - }, [session]); + return startDate <= today && endDate >= today; + }) + ); + }, [periodsData]); - if (isLoading) return ; + if (periodsIsLoading) return ; + if (periodsIsError) return ; return (
@@ -44,29 +45,28 @@ const Apply = () => {

Ingen åpne opptak for øyeblikket

Opptak til{" "} - - komiteene - {" "} + + + komiteene + + {" "} skjer vanligvis i august etter fadderuka. Noen komiteer har - vanligvis suppleringsopptak i februar.{

}

Følg - med på{" "} - - online.ntnu.no - {" "} + vanligvis suppleringsopptak i februar. +
+
+ Følg med på{" "} + + + online.ntnu.no + + {" "} eller på vår{" "} - - Facebook - {" "} - side for kunngjøringer! + + + Facebook-gruppe + + {" "} + for kunngjøringer!

) : ( @@ -74,7 +74,7 @@ const Apply = () => {

Nåværende opptaksperioder

-
+
{currentPeriods.map((period: periodType, index: number) => ( ))} diff --git a/pages/committee/[period-id]/[committee]/index.tsx b/pages/committee/[period-id]/[committee]/index.tsx index 60b74059..ac065304 100644 --- a/pages/committee/[period-id]/[committee]/index.tsx +++ b/pages/committee/[period-id]/[committee]/index.tsx @@ -20,6 +20,9 @@ import { changeDisplayName } from "../../../../lib/utils/toString"; import Custom404 from "../../../404"; import PageTitle from "../../../../components/PageTitle"; import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { fetchPeriodById } from "../../../../lib/api/periodApi"; +import ErrorPage from "../../../../components/ErrorPage"; const CommitteeApplicantOverView: NextPage = () => { const { data: session } = useSession(); @@ -38,21 +41,14 @@ const CommitteeApplicantOverView: NextPage = () => { const [singleCommitteeInPeriod, setSingleCommitteeInPeriod] = useState(true); - useEffect(() => { - if (!session || !periodId) return; - - const fetchPeriod = async () => { - try { - const res = await fetch(`/api/periods/${periodId}`); - const data = await res.json(); - setPeriod(data.period); - } catch (error) { - console.error("Failed to fetch interview periods:", error); - } - }; + const { data: periodData, isError: periodIsError, isLoading: periodIsLoading } = useQuery({ + queryKey: ['periods', periodId], + queryFn: fetchPeriodById, + }); - fetchPeriod(); - }, [periodId]); + useEffect(() => { + setPeriod(periodData?.period); + }, [periodData]); useEffect(() => { const userCommittees = session?.user?.committees?.map(c => c.toLowerCase()) || []; @@ -64,7 +60,7 @@ const CommitteeApplicantOverView: NextPage = () => { if (!session || !periodId || !committee) return; const fetchCommitteeInterviewTimes = async () => { - if (!session) { + if (!session.user?.committees?.includes(committee)) { return; } if (period?._id === undefined) return; @@ -74,6 +70,7 @@ const CommitteeApplicantOverView: NextPage = () => { `/api/committees/times/${period?._id}/${committee}` ); const data = await response.json(); + if (response.ok) { setCommitteeInterviewTimes(data.committees[0]); } else { @@ -120,13 +117,9 @@ const CommitteeApplicantOverView: NextPage = () => { checkAccess(); }, [period]); - if (loading) { - return ; - } - - if (!session || !hasAccess) { - return ; - } + if (loading || periodIsLoading) return ; + if (!hasAccess) return ; + if (periodIsError) return ; const interviewPeriodEnd = period?.interviewPeriod.end ? new Date(period.interviewPeriod.end) diff --git a/pages/committee/[period-id]/index.tsx b/pages/committee/[period-id]/index.tsx index 73637161..bbd34915 100644 --- a/pages/committee/[period-id]/index.tsx +++ b/pages/committee/[period-id]/index.tsx @@ -3,58 +3,51 @@ import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; import LoadingPage from "../../../components/LoadingPage"; import CommitteeCard from "../../../components/committee/CommitteeCard"; +import { useQuery } from "@tanstack/react-query"; +import { fetchPeriodById } from "../../../lib/api/periodApi"; +import ErrorPage from "../../../components/ErrorPage"; +import NotFound from "../../404"; const ChooseCommittee = () => { const { data: session } = useSession(); const router = useRouter(); const periodId = router.query["period-id"]; + const [committees, setCommittees] = useState(null); - const [loading, setLoading] = useState(true); + + const { data, isError, isLoading } = useQuery({ + queryKey: ['periods', periodId], + queryFn: fetchPeriodById, + }); useEffect(() => { - const fetchPeriod = async () => { - if (!session || !periodId) return; - - try { - const res = await fetch(`/api/periods/${periodId}`); - const data = await res.json(); - - if (data.period) { - const userCommittees = session!.user!.committees; - const periodCommittees = data.period.committees; - - if (data.period.optionalCommittees != null) { - periodCommittees.push(...data.period.optionalCommittees); - } - - const filteredCommittees = periodCommittees.filter( - (committee: string) => - userCommittees?.includes(committee.toLowerCase()) - ); - setCommittees(filteredCommittees); - } - } catch (error) { - console.error("Failed to fetch interview periods:", error); - } finally { - setLoading(false); - } - }; - fetchPeriod(); - }, [periodId, session]); - - if (loading) { - return ; - } + if(!data) return; + + const userCommittees = session!.user!.committees; + const periodCommittees = [...data.period?.committees, ...data.period?.optionalCommittees]; + + const matchingCommittees = periodCommittees.filter( + (committee: string) => + userCommittees?.includes(committee.toLowerCase()) + ); + setCommittees(matchingCommittees); + + }, [data, session]) + + if (session?.user?.committees?.length === 0) return ; + if (isLoading) return ; + if (isError) return ; return ( -
+

Velg komite

- {committees?.map((committee) => - CommitteeCard({ - committee, - link: `${periodId}/${committee.toLowerCase()}`, - }) - )} + {committees?.map((committee) => ( + + ))}
); }; diff --git a/pages/committee/index.tsx b/pages/committee/index.tsx index 0f88306b..79485a3e 100644 --- a/pages/committee/index.tsx +++ b/pages/committee/index.tsx @@ -5,75 +5,77 @@ import Table from "../../components/Table"; import { formatDate } from "../../lib/utils/dateUtils"; import { periodType } from "../../lib/types/types"; import LoadingPage from "../../components/LoadingPage"; +import { useQuery } from "@tanstack/react-query"; +import { fetchPeriods } from "../../lib/api/periodApi"; +import ErrorPage from "../../components/ErrorPage"; +import { TableSkeleton } from "../../components/skeleton/TableSkeleton"; const Committee: NextPage = () => { const { data: session } = useSession(); const [periods, setPeriods] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const fetchPeriods = async () => { - try { - const response = await fetch("/api/periods"); - const data = await response.json(); - const userCommittees = session?.user?.committees || []; + const { + data: periodsData, + isError: periodsIsError, + isLoading: periodsIsLoading, + } = useQuery({ + queryKey: ["periods"], + queryFn: fetchPeriods, + }); - // Viser bare aktuelle perioder - const filteredPeriods = data.periods.filter((period: periodType) => - period.committees.some((committee: string) => - userCommittees.includes(committee.toLowerCase()) - ) - ); + useEffect(() => { + if (!periodsData) return; - setPeriods( - filteredPeriods.map((period: periodType) => { - const userCommittees = session?.user?.committees?.map((committee) => - committee.toLowerCase() - ); - const periodCommittees = period.committees.map((committee) => - committee.toLowerCase() - ); + const userCommittees = session?.user?.committees || []; - period.optionalCommittees.forEach((committee) => { - periodCommittees.push(committee.toLowerCase()); - }); + // Viser bare aktuelle perioder + const filteredPeriods = periodsData.periods.filter((period: periodType) => + period.committees.some((committee: string) => + userCommittees.includes(committee.toLowerCase()) + ) + ); - const commonCommittees = userCommittees!.filter((committee) => - periodCommittees.includes(committee) - ); + setPeriods( + filteredPeriods.map((period: periodType) => { + const userCommittees = session?.user?.committees?.map((committee) => + committee.toLowerCase() + ); + const periodCommittees = period.committees.map((committee) => + committee.toLowerCase() + ); - let uriLink = ""; + period.optionalCommittees.forEach((committee) => { + periodCommittees.push(committee.toLowerCase()); + }); - if (commonCommittees.length > 1) { - uriLink = `committee/${period._id}`; - } else { - uriLink = `committee/${period._id}/${commonCommittees[0]}`; - } + const commonCommittees = userCommittees!.filter((committee) => + periodCommittees.includes(committee) + ); - return { - name: period.name, - application: - formatDate(period.applicationPeriod.start) + - " til " + - formatDate(period.applicationPeriod.end), - interview: - formatDate(period.interviewPeriod.start) + - " til " + - formatDate(period.interviewPeriod.end), - committees: period.committees, - link: uriLink, - }; - }) - ); - } catch (error) { - console.error("Failed to fetch application periods:", error); - } finally { - setIsLoading(false); - } - }; + let uriLink = ""; - useEffect(() => { - fetchPeriods(); - }, []); + if (commonCommittees.length > 1) { + uriLink = `committee/${period._id}`; + } else { + uriLink = `committee/${period._id}/${commonCommittees[0]}`; + } + + return { + name: period.name, + application: + formatDate(period.applicationPeriod.start) + + " til " + + formatDate(period.applicationPeriod.end), + interview: + formatDate(period.interviewPeriod.start) + + " til " + + formatDate(period.interviewPeriod.end), + committees: period.committees, + link: uriLink, + }; + }) + ); + }, [periodsData, session]); const periodsColumns = [ { label: "Navn", field: "name" }, @@ -81,19 +83,16 @@ const Committee: NextPage = () => { { label: "Intervju", field: "interview" }, ]; - if (!session || !session.user?.isCommitee) { - return

Ingen tilgang!

; - } - - if (isLoading) { - return ; - } + if (!session || !session.user?.isCommittee) return

Ingen tilgang!

; + if (periodsIsError) return ; return (

Velg opptak

- {periods.length > 0 && ( + {periodsIsLoading ? ( + + ) : (
)} diff --git a/pages/committees.tsx b/pages/committees.tsx index 0ab4a6b4..ce28e9e1 100644 --- a/pages/committees.tsx +++ b/pages/committees.tsx @@ -2,75 +2,54 @@ import { useEffect, useState } from "react"; import LoadingPage from "../components/LoadingPage"; import { owCommitteeType, periodType } from "../lib/types/types"; import CommitteeAboutCard from "../components/CommitteeAboutCard"; +import { useQuery } from "@tanstack/react-query"; +import { fetchOwCommittees } from "../lib/api/committees"; +import ErrorPage from "../components/ErrorPage"; +import { fetchPeriods } from "../lib/api/periodApi"; + +const excludedCommittees = ["Faddere", "Output"]; const Committees = () => { - const [isLoading, setIsLoading] = useState(true); const [committees, setCommittees] = useState([]); const [periods, setPeriods] = useState([]); - const excludedCommittees = ["Faddere"]; + const { + data: owCommitteeData, + isError: owCommitteeIsError, + isLoading: owCommitteeIsLoading, + } = useQuery({ + queryKey: ["ow-committees"], + queryFn: fetchOwCommittees, + }); + + const { + data: periodsData, + isError: periodsIsError, + isLoading: periodsIsLoading, + } = useQuery({ + queryKey: ["periods"], + queryFn: fetchPeriods, + }); - const filterCommittees = (committees: owCommitteeType[]) => { - return committees.filter( - (committee) => !excludedCommittees.includes(committee.name_short) - ); - }; - - const fetchPeriods = async () => { - try { - const response = await fetch("/api/periods"); - const data = await response.json(); - setPeriods(data.periods); - } catch (error) { - console.error("Failed to fetch periods:", error); - } - }; - - const fetchCommittees = async () => { - try { - const response = await fetch("/api/periods/ow-committees"); - const data = await response.json(); - - const filteredData = filterCommittees(data); + useEffect(() => { + if (!owCommitteeData) return; - const cachedData = JSON.parse( - localStorage.getItem("committeesCache") || "[]" - ); + const filteredCommittees = owCommitteeData.filter( + (committee: owCommitteeType) => + !excludedCommittees.includes(committee.name_short) + ); - if (JSON.stringify(filteredData) !== JSON.stringify(cachedData)) { - localStorage.setItem("committeesCache", JSON.stringify(filteredData)); - setCommittees(filteredData); - } else { - setCommittees(cachedData); - } - console.log(filteredData); - } catch (error) { - console.error("Failed to fetch committees:", error); - const cachedData = JSON.parse( - localStorage.getItem("committeesCache") || "[]" - ); - setCommittees(cachedData); - } finally { - setIsLoading(false); - } - }; + setCommittees(filteredCommittees); + }, [owCommitteeData]); useEffect(() => { - const cachedData = JSON.parse( - localStorage.getItem("committeesCache") || "[]" - ); - if (cachedData.length > 0) { - setCommittees(cachedData); - setIsLoading(false); - } - fetchPeriods(); - fetchCommittees(); - }, []); + if (!periodsData) return; - const hasPeriod = (committee: any) => { - if (!Array.isArray(periods)) { - return false; - } + setPeriods(periodsData.periods); + }, [periodsData]); + + const hasPeriod = (committee: owCommitteeType) => { + if (!Array.isArray(periods)) return false; const today = new Date(); @@ -95,7 +74,8 @@ const Committees = () => { }); }; - if (isLoading) return ; + if (owCommitteeIsLoading || periodsIsLoading) return ; + if (owCommitteeIsError || periodsIsError) return ; return (