diff --git a/.env.local.template b/.env.local.template index 3e58d9e5..a68bc96b 100644 --- a/.env.local.template +++ b/.env.local.template @@ -12,3 +12,6 @@ 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 + +TWILIO_ACCOUNT_SID=#twilio account sid +TWILIO_AUTH_TOKEN=#twilio auth token diff --git a/.github/workflows/algorithm.yml b/.github/workflows/algorithm.yml index 96ba20c3..ce22a1e0 100644 --- a/.github/workflows/algorithm.yml +++ b/.github/workflows/algorithm.yml @@ -1,10 +1,10 @@ on: push: paths: - - "algorithm/*" + - "algorithm/**" pull_request: paths: - - "algorithm/*" + - "algorithm/**" workflow_dispatch: jobs: diff --git a/algorithm/bridge/fetch_applicants_and_committees.py b/algorithm/bridge/fetch_applicants_and_committees.py index b01cf645..e50a4662 100644 --- a/algorithm/bridge/fetch_applicants_and_committees.py +++ b/algorithm/bridge/fetch_applicants_and_committees.py @@ -1,29 +1,61 @@ from pymongo import MongoClient from dotenv import load_dotenv -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone import os import certifi +from typing import List, Dict + +from mip_matching.Committee import Committee +from mip_matching.TimeInterval import TimeInterval +from mip_matching.Applicant import Applicant +from mip_matching.match_meetings import match_meetings, MeetingMatch 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: + #or period["name"] == "Juli Opptak" + if (application_end < now and period["hasSentInterviewTimes"] == False): applicants = fetch_applicants(periodId) committee_times = fetch_committee_times(periodId) - print(applicants) - print(committee_times) - return applicants, committee_times + committee_objects = create_committee_objects(committee_times) + + all_committees = {committee.name: committee for committee in committee_objects} + + applicant_objects = create_applicant_objects(applicants, all_committees) + + print(applicant_objects) + print(committee_objects) + + match_result = match_meetings(applicant_objects, committee_objects) + + send_to_db(match_result, applicants, periodId) + return match_result +def send_to_db(match_result: MeetingMatch, applicants: List[dict], periodId): + load_dotenv() + formatted_results = format_match_results(match_result, applicants, periodId) + print("Sending to db") + print(formatted_results) + + 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["interviews"] + + collection.insert_many(formatted_results) + + client.close() def connect_to_db(collection_name): load_dotenv() @@ -40,37 +72,95 @@ def connect_to_db(collection_name): return collection, client def fetch_periods(): - collection, client = connect_to_db("period") - - periods = collection.find() + collection, client = connect_to_db("periods") - periods = list(periods) + periods = list(collection.find()) client.close() return periods def fetch_applicants(periodId): - collection, client = connect_to_db("applicant") - - applicants = collection.find({"periodId": periodId}) + collection, client = connect_to_db("applications") - applicants = list(applicants) + applicants = list(collection.find({"periodId": periodId})) client.close() return applicants def fetch_committee_times(periodId): - collection, client = connect_to_db("committee") + collection, client = connect_to_db("committees") - committee_times = collection.find({"periodId": periodId}) - - committee_times = list(committee_times) + committee_times = list(collection.find({"periodId": periodId})) client.close() return committee_times +def format_match_results(match_results: MeetingMatch, applicants: List[dict], periodId) -> List[Dict]: + transformed_results = {} + + for result in match_results['matchings']: + applicant_id = str(result[0]) + + if applicant_id not in transformed_results: + transformed_results[applicant_id] = { + "periodId": periodId, + "applicantId": applicant_id, + "interviews": [] + } + + committee = result[1] + time_interval = result[2] + start = time_interval.start.isoformat() + end = time_interval.end.isoformat() + + transformed_results[applicant_id]["interviews"].append({ + "start": start, + "end": end, + "committeeName": committee.name + }) + + return list(transformed_results.values()) + +def create_applicant_objects(applicants_data: List[dict], all_committees: dict[str, Committee]) -> set[Applicant]: + applicants = set() + for data in applicants_data: + applicant = Applicant(name=str(data['_id'])) + + optional_committee_names = data.get('optionalCommittees', []) + optional_committees = {all_committees[name] for name in optional_committee_names if name in all_committees} + applicant.add_committees(optional_committees) + + preferences = data.get('preferences', {}) + preference_committees = {all_committees[committee_name] for committee_name in preferences.values() if committee_name in all_committees} + applicant.add_committees(preference_committees) + + for interval_data in data['selectedTimes']: + interval = TimeInterval( + start=datetime.fromisoformat(interval_data['start'].replace("Z", "+00:00")), + end=datetime.fromisoformat(interval_data['end'].replace("Z", "+00:00")) + ) + applicant.add_interval(interval) + + applicants.add(applicant) + return applicants + +def create_committee_objects(committee_data: List[dict]) -> set[Committee]: + committees = set() + for data in committee_data: + committee = Committee(name=data['committee'], interview_length=timedelta(minutes=int(data["timeslot"]))) + for interval_data in data['availabletimes']: + interval = TimeInterval( + start=datetime.fromisoformat(interval_data['start'].replace("Z", "+00:00")), + end=datetime.fromisoformat(interval_data['end'].replace("Z", "+00:00")) + ) + capacity = interval_data.get('capacity', 1) + committee.add_interval(interval, capacity) + committees.add(committee) + return committees + + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/algorithm/src/Modellering.md b/algorithm/src/Modellering.md index 3fea01e0..30fc6439 100644 --- a/algorithm/src/Modellering.md +++ b/algorithm/src/Modellering.md @@ -10,43 +10,53 @@ ## Variabler `p` + - Person `k` + - Komité `t` + - Timeslot (Må gjøres til intervaller etter hvert) `m(p, k, t)` + - Binær variabel - Person `p` har møte med komité `k` i timeslot `t` ## Hjelpevariabler `c(p, t)` + - Binære variabler - Tidspunkt `t` passer for person `p` `c(k, t)` + - Heltallsvariabel - Kapasitet for komité `k` på tidspunkt `t` (hvor mange intervju de kan ha på det gitte tidspunktet) ## Begrensninger For alle `p`: - -- `m(p, k, t) <= 1` dersom - - `p` har søkt på komité `k` - - `c(p, t) => 1` - - `c(k, t) => 1` + +- `m(p, k, t_1) + m(p, k, t_2) < 2` for alle gyldige `k, t_1` og `k, t_2`, hvor t_1 og t_2 overlapper eller er innenfor et gitt buffer-intervall. +- `m(p, k, t) <= 1` dersom + - `p` har søkt på komité `k` + - `c(p, t) => 1` + - `c(k, t) => 1` - `m(p, k, t) <= 0` ellers For alle `k`: -- `sum(m(p, k, t)) <= c(k, t)` for alle personer `p` og tidspunkt `t` - +- `sum(m(p, k, t)) <= c(k, t)` for alle personer `p` og tidspunkt `t` ## Mål Maksimere `sum(m(p, k, t))` for alle `p`, `k` og `t` + +### Sekundærmål + +- [Ikke enda implementert] La det være færrest mulig og minst mulig mellomrom mellom intervjuene for komitéene. diff --git a/algorithm/src/mip_matching/Applicant.py b/algorithm/src/mip_matching/Applicant.py index d769ebc7..6172e5fb 100644 --- a/algorithm/src/mip_matching/Applicant.py +++ b/algorithm/src/mip_matching/Applicant.py @@ -83,4 +83,4 @@ def __str__(self) -> str: return self.name def __repr__(self) -> str: - return str(self) + return str(self) \ No newline at end of file diff --git a/algorithm/src/mip_matching/Committee.py b/algorithm/src/mip_matching/Committee.py index 115debc4..8f2c28f5 100644 --- a/algorithm/src/mip_matching/Committee.py +++ b/algorithm/src/mip_matching/Committee.py @@ -1,17 +1,10 @@ from __future__ import annotations from datetime import timedelta -import sys -print(sys.path) -print(__name__) -# sys.path.append("C:\\Users\\Jørgen Galdal\\Documents\\lokalSkoleprogrammering\\appkom\\OnlineOpptak\\algorithm\\mip_matching") from mip_matching.Applicant import Applicant +from mip_matching.TimeInterval import TimeInterval from typing import Iterator -# from typing import TYPE_CHECKING -# if TYPE_CHECKING: -# # Unngår cyclic import -from mip_matching.TimeInterval import TimeInterval class Committee: @@ -84,4 +77,4 @@ def __repr__(self): if __name__ == "__main__": - print("running") + print("running") \ No newline at end of file diff --git a/algorithm/src/mip_matching/TimeInterval.py b/algorithm/src/mip_matching/TimeInterval.py index f2156d5e..9228aa28 100644 --- a/algorithm/src/mip_matching/TimeInterval.py +++ b/algorithm/src/mip_matching/TimeInterval.py @@ -64,6 +64,9 @@ def get_contained_slots(self, slots: list[TimeInterval]): def divide(self, length: timedelta) -> list[TimeInterval]: return TimeInterval.divide_interval(self, length) + def is_within_distance(self, other: TimeInterval, distance: timedelta) -> bool: + return (self.end <= other.start and self.end + distance > other.start) or (other.end <= self.start and other.end + distance > self.start) + @staticmethod def divide_interval(interval: TimeInterval, length: timedelta) -> list[TimeInterval]: """ @@ -85,29 +88,3 @@ def divide_interval(interval: TimeInterval, length: timedelta) -> list[TimeInter local_end += length return result - - -""" -Dette er gammel kode som nå er flyttet til de passende komité-/søker-klassene. -Foreløpig beholdt for referanse. -""" -# class TimeIntervals: -# def __init__(self, initial_list: list[TimeInterval] = None): -# self.list: list[TimeInterval] = initial_list if initial_list else [] - -# def add(self, interval: TimeInterval): -# self.list.append(interval) - -# def recursive_intersection(self, other: TimeIntervals): -# """ -# Returnerer alle tidsintervallene i *other* som er inneholdt i et av *self* sine intervaller""" -# result = TimeIntervals() - -# for self_interval, other_interval in itertools.product(self.list, other.list): -# if self_interval.contains(other_interval): -# result.add(other_interval) - -# return result - -# def __iter__(self): -# return self.list.__iter__() diff --git a/algorithm/src/mip_matching/match_meetings.py b/algorithm/src/mip_matching/match_meetings.py index 9bbd512e..27f6d6bb 100644 --- a/algorithm/src/mip_matching/match_meetings.py +++ b/algorithm/src/mip_matching/match_meetings.py @@ -5,7 +5,21 @@ from mip_matching.Applicant import Applicant import mip -# from typing import TypedDict +from datetime import timedelta, time +from itertools import combinations + +from mip_matching.utils import subtract_time + + +# Hvor stort buffer man ønsker å ha mellom intervjuene +APPLICANT_BUFFER_LENGTH = timedelta(minutes=15) + +# Et mål på hvor viktig det er at intervjuer er i nærheten av hverandre +CLUSTERING_WEIGHT = 0.001 + +# Når på dagen man helst vil ha intervjuene rundt +CLUSTERING_TIME_BASELINE = time(12, 00) +MAX_SCALE_CLUSTERING_TIME = timedelta(seconds=43200) # TODO: Rename variable class MeetingMatch(TypedDict): @@ -20,7 +34,7 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me """Matches meetings and returns a MeetingMatch-object""" model = mip.Model(sense=mip.MAXIMIZE) - m = {} + m: dict[tuple[Applicant, Committee, TimeInterval], mip.Var] = {} # Lager alle maksimeringsvariabler for applicant in applicants: @@ -44,22 +58,38 @@ def match_meetings(applicants: set[Applicant], committees: set[Committee]) -> Me # type: ignore for interval in applicant.get_fitting_committee_slots(committee)) <= 1 - # Legger inn begrensninger for at en person kun kan ha ett intervju på hvert tidspunkt + # Legger inn begrensninger for at en søker ikke kan ha overlappende intervjutider + # og minst har et buffer mellom hvert intervju som angitt for applicant in applicants: - potential_intervals = set() + potential_interviews: set[tuple[Committee, TimeInterval]] = set() for applicant_candidate, committee, interval in m: if applicant == applicant_candidate: - potential_intervals.add(interval) + potential_interviews.add((committee, interval)) - for interval in potential_intervals: + for interview_a, interview_b in combinations(potential_interviews, r=2): + if interview_a[1].intersects(interview_b[1]) or interview_a[1].is_within_distance(interview_b[1], APPLICANT_BUFFER_LENGTH): + model += m[(applicant, *interview_a)] + \ + m[(applicant, *interview_b)] <= 1 # type: ignore - model += mip.xsum(m[(applicant, committee, interval)] - for committee in applicant.get_committees() - # type: ignore - if (applicant, committee, interval) in m) <= 1 + # Legger til sekundærmål om at man ønsker å sentrere intervjuer rundt CLUSTERING_TIME_BASELINE + clustering_objectives = [] - # Setter mål til å være maksimering av antall møter - model.objective = mip.maximize(mip.xsum(m.values())) + for name, variable in m.items(): + applicant, committee, interval = name + if interval.start.time() < CLUSTERING_TIME_BASELINE: + relative_distance_from_baseline = subtract_time(CLUSTERING_TIME_BASELINE, + interval.end.time()) / MAX_SCALE_CLUSTERING_TIME + else: + relative_distance_from_baseline = subtract_time(interval.start.time(), + CLUSTERING_TIME_BASELINE) / MAX_SCALE_CLUSTERING_TIME + + clustering_objectives.append( + CLUSTERING_WEIGHT * relative_distance_from_baseline * variable) # type: ignore + + # Setter mål til å være maksimering av antall møter + # med sekundærmål om å samle intervjuene rundt CLUSTERING_TIME_BASELINE + model.objective = mip.maximize( + mip.xsum(m.values()) + mip.xsum(clustering_objectives)) # Kjør optimeringen solver_status = model.optimize() diff --git a/algorithm/src/mip_matching/utils.py b/algorithm/src/mip_matching/utils.py new file mode 100644 index 00000000..b248ae3e --- /dev/null +++ b/algorithm/src/mip_matching/utils.py @@ -0,0 +1,41 @@ +from mip_matching.Applicant import Applicant +from mip_matching.Committee import Committee +from mip_matching.TimeInterval import TimeInterval + +from datetime import time, date, datetime, timedelta + + +def group_by_committee(meetings: list[tuple[Applicant, Committee, TimeInterval]]) -> dict[Committee, list[tuple[Applicant, Committee, TimeInterval]]]: + result = {} + + for applicant, committee, interval in meetings: + if committee not in result: + result[committee] = [] + + result[committee].append((applicant, committee, interval)) + + return result + + +def measure_clustering(meetings: list[tuple[Applicant, Committee, TimeInterval]]) -> int: + grouped_meetings = group_by_committee(meetings) + + holes = 0 + + for _, committee_meetings in grouped_meetings.items(): + committee_meetings.sort(key=lambda meeting: meeting[2].end) + + previous_interval: TimeInterval = committee_meetings[0][2] + for _, _, interval in committee_meetings[1:]: + if not previous_interval.is_within_distance(interval, timedelta(minutes=1)): + holes += 1 + previous_interval = interval + + return holes + + +def subtract_time(minuend: time, subtrahend: time) -> timedelta: + minuend_date = datetime.combine(date.min, minuend) + subtrahend_date = datetime.combine(date.min, subtrahend) + + return minuend_date - subtrahend_date diff --git a/algorithm/tests/mip_test.py b/algorithm/tests/mip_test.py index a8b3a9f8..ec173ea2 100644 --- a/algorithm/tests/mip_test.py +++ b/algorithm/tests/mip_test.py @@ -1,6 +1,5 @@ from __future__ import annotations from datetime import datetime, timedelta, date, time -# import ..algorithm.mip_matching.core.Applicant.py as applicant from mip_matching.TimeInterval import TimeInterval from mip_matching.Committee import Committee @@ -12,6 +11,7 @@ import unittest import random +from itertools import combinations def print_matchings(committees: list[Committee], @@ -59,6 +59,20 @@ def check_constraints(self, matchings: list[tuple[Applicant, Committee, TimeInte self.assertGreaterEqual(committee.get_capacity(interval), load, f"Constraint \"Number of interviews per slot per committee cannot exceed capacity\" failed for Committee {committee} and interval {interval}") + # Overlapping interviews per applicant + interviews_per_applicant: dict[Applicant, + set[tuple[Committee, TimeInterval]]] = {} + for applicant, committee, interval in matchings: + if applicant not in interviews_per_applicant: + interviews_per_applicant[applicant] = set() + + interviews_per_applicant[applicant].add((committee, interval)) + + for applicant, interviews in interviews_per_applicant.items(): + for interview_a, interview_b in combinations(interviews, r=2): + self.assertFalse(interview_a[1].intersects(interview_b[1]), f"Constraint \"Applicant cannot have time-overlapping interviews\" failed for { + applicant}'s interviews with {interview_a[0]} ({interview_a[1]}) and {interview_b[0]} ({interview_b[1]})") + def test_fixed_small(self): """Small, fixed test with all capacities set to one""" diff --git a/components/CommitteeAboutCard.tsx b/components/CommitteeAboutCard.tsx index 502719b3..6699e2b7 100644 --- a/components/CommitteeAboutCard.tsx +++ b/components/CommitteeAboutCard.tsx @@ -15,19 +15,21 @@ const CommitteeAboutCard = ({ return (
{name_long} -
+

{name_long} {name_long !== name_short && `(${name_short})`}

{hasPeriod && ( - Har opptak! + + Har opptak! + )} -
+

{email}

{application_description || "Ingen opptaksbeskrivelse"} diff --git a/components/applicantoverview/ApplicantCard.tsx b/components/applicantoverview/ApplicantCard.tsx index b77f73c9..d19e3d0b 100644 --- a/components/applicantoverview/ApplicantCard.tsx +++ b/components/applicantoverview/ApplicantCard.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { applicantType } from "../../lib/types/types"; import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import { changeDisplayName, getBankomValue } from "../../lib/utils/toString"; +import { changeDisplayName } from "../../lib/utils/toString"; interface Props { applicant: applicantType | undefined; @@ -71,11 +71,9 @@ const ApplicantCard = ({ applicant, includePreferences }: Props) => {

)} -

Om:

-

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

-
+

Om:

+

Ønsker å være økonomiansvarlig: {applicant?.bankom}

+

{applicant?.about}

diff --git a/components/applicantoverview/ApplicantsOverview.tsx b/components/applicantoverview/ApplicantsOverview.tsx index 4bd62739..fe229cd4 100644 --- a/components/applicantoverview/ApplicantsOverview.tsx +++ b/components/applicantoverview/ApplicantsOverview.tsx @@ -14,7 +14,6 @@ import { fetchApplicantsByPeriodIdAndCommittee, } from "../../lib/api/applicantApi"; import ErrorPage from "../ErrorPage"; -import { getBankomValue } from "../../lib/utils/toString"; import ApplicantCard from "./ApplicantCard"; import { SimpleTitle } from "../Typography"; @@ -55,7 +54,7 @@ const ApplicantsOverview = ({ const [applicants, setApplicants] = useState([]); const years: string[] = ["1", "2", "3", "4", "5"]; - const bankomOptions: bankomOptionsType[] = ["yes", "no", "maybe"]; + const bankomOptions: bankomOptionsType[] = ["ja", "nei", "kanskje"]; const { data: applicantsData, @@ -155,7 +154,7 @@ const ApplicantsOverview = ({ if (applicantsIsError) return ; return ( -
+
{showPeriodName && }
@@ -225,9 +224,9 @@ const ApplicantsOverview = ({ } > - {bankomOptions.map((bankom) => ( - ))} @@ -250,7 +249,7 @@ const ApplicantsOverview = ({
{filteredApplicants?.map((applicant) => ( diff --git a/components/committee/CommitteeInterviewTimes.tsx b/components/committee/CommitteeInterviewTimes.tsx index 5dc23a02..7ba55fa9 100644 --- a/components/committee/CommitteeInterviewTimes.tsx +++ b/components/committee/CommitteeInterviewTimes.tsx @@ -11,8 +11,13 @@ import Button from "../Button"; import ImportantNote from "../ImportantNote"; import useUnsavedChangesWarning from "../../lib/utils/unSavedChangesWarning"; import { SimpleTitle } from "../Typography"; +import { useQuery } from "@tanstack/react-query"; +import { fetchApplicantsByPeriodIdAndCommittee } from "../../lib/api/applicantApi"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon } from "@heroicons/react/24/solid"; interface Interview { + id: string; title: string; start: string; end: string; @@ -32,8 +37,6 @@ const CommitteeInterviewTimes = ({ committeeInterviewTimes, }: Props) => { const { data: session } = useSession(); - - const [markedCells, setMarkedCells] = useState([]); const [interviewInterval, setInterviewInterval] = useState(15); const [visibleRange, setVisibleRange] = useState({ start: "", end: "" }); @@ -55,6 +58,8 @@ const CommitteeInterviewTimes = ({ const { unsavedChanges, setUnsavedChanges } = useUnsavedChangesWarning(); + const [numberOfApplications, setNumberOfApplications] = useState(0); + useEffect(() => { if (period) { setVisibleRange({ @@ -64,6 +69,21 @@ const CommitteeInterviewTimes = ({ } }, [period]); + const { + data: applicantsData, + isError: applicantsIsError, + isLoading: applicantsIsLoading, + } = useQuery({ + queryKey: ["applicants", period?._id, committee], + queryFn: fetchApplicantsByPeriodIdAndCommittee, + }); + + useEffect(() => { + if (applicantsData) { + setNumberOfApplications(applicantsData.applicants.length); + } + }, [applicantsData]); + useEffect(() => { if (committee && committeeInterviewTimes) { const cleanString = (input: string) => @@ -79,6 +99,7 @@ const CommitteeInterviewTimes = ({ setHasAlreadySubmitted(true); const events = committeeInterviewTimes.availabletimes.map( (at: any) => ({ + id: crypto.getRandomValues(new Uint32Array(1))[0].toString(), title: at.room, start: new Date(at.start).toISOString(), end: new Date(at.end).toISOString(), @@ -123,6 +144,10 @@ const CommitteeInterviewTimes = ({ if (calendarEvents.length > 0) { calculateInterviewsPlanned(); } + + if (!calendarEvents || calendarEvents.length === 0) { + setInterviewsPlanned(0); + } }, [calendarEvents, selectedTimeslot]); const handleDateSelect = (selectionInfo: any) => { @@ -137,22 +162,17 @@ const CommitteeInterviewTimes = ({ return; } - const event = { + const event: Interview = { + id: crypto.getRandomValues(new Uint32Array(1))[0].toString(), title: roomInput, - start: currentSelection.start, - end: currentSelection.end, + start: currentSelection.start.toISOString(), + end: currentSelection.end.toISOString(), }; const calendarApi = currentSelection.view.calendar; calendarApi.addEvent(event); calendarApi.render(); - addCell([ - roomInput, - currentSelection.start.toISOString(), - currentSelection.end.toISOString(), - ]); - setRoomInput(""); setIsModalOpen(false); setCalendarEvents((prevEvents) => [...prevEvents, event]); @@ -160,12 +180,19 @@ const CommitteeInterviewTimes = ({ const submit = async (e: BaseSyntheticEvent) => { e.preventDefault(); - const formattedEvents = formatEventsForExport(markedCells); + const formattedEvents = formatEventsForExport(calendarEvents); if (formattedEvents.length === 0) { toast.error("Fyll inn minst et gyldig tidspunkt"); return; } + if (interviewsPlanned < numberOfApplications) { + toast.error( + "Du har valgt færre tider enn antall søkere. Vennligst legg til flere tider." + ); + return; + } + const dataToSend = { periodId: period!._id, period_name: period!.name, @@ -197,21 +224,11 @@ const CommitteeInterviewTimes = ({ } }; - const removeCell = (event: any) => { - setMarkedCells((prevCells) => - prevCells.filter( - (cell) => cell.start !== event.startStr && cell.end !== event.endStr - ) + const removeCell = (event: Interview) => { + setCalendarEvents((prevEvents) => + prevEvents.filter((evt) => evt.id !== event.id) ); - event.remove(); - setUnsavedChanges(true); - }; - const addCell = (cell: string[]) => { - setMarkedCells([ - ...markedCells, - { title: cell[0], start: cell[1], end: cell[2] }, - ]); setUnsavedChanges(true); }; @@ -220,7 +237,7 @@ const CommitteeInterviewTimes = ({ setUnsavedChanges(true); }; - const renderEventContent = (eventContent: any) => { + const calendarEventStyle = (eventContent: { event: Interview }) => { return (
{!hasAlreadySubmitted && ( @@ -231,9 +248,10 @@ const CommitteeInterviewTimes = ({ e.stopPropagation(); removeCell({ - startStr: eventContent.event.start.toISOString(), - endStr: eventContent.event.end.toISOString(), - remove: () => eventContent.event.remove(), + id: eventContent.event.id, + start: eventContent.event.start, + end: eventContent.event.end, + title: eventContent.event.title, }); }} > @@ -251,21 +269,16 @@ const CommitteeInterviewTimes = ({ ); }; - const formatEventsForExport = (events: any[]) => { - return events - .map((event) => { - const startDateTimeString = `${event.start}`; - const endDatetimeString = `${event.end}`; - - const startDateTime = new Date(startDateTimeString); - const endDateTime = new Date(endDatetimeString); - return { - room: event.title, - start: startDateTime.toISOString(), - end: endDateTime.toISOString(), - }; - }) - .filter((event) => event !== null); + const formatEventsForExport = (events: Interview[]) => { + return events.map((event) => { + const startDateTime = new Date(event.start); + const endDateTime = new Date(event.end); + return { + room: event.title, + start: startDateTime.toISOString(), + end: endDateTime.toISOString(), + }; + }); }; const handleTimeslotSelection = (e: React.ChangeEvent) => { @@ -292,6 +305,7 @@ const CommitteeInterviewTimes = ({ setHasAlreadySubmitted(false); setCalendarEvents([]); + setInterviewsPlanned(0); setUnsavedChanges(false); } catch (error: any) { console.error("Error deleting submission:", error); @@ -411,7 +425,7 @@ const CommitteeInterviewTimes = ({
)} -

{`${interviewsPlanned} intervjuer planlagt`}

+

{`${interviewsPlanned} / ${numberOfApplications} intervjuer planlagt`}

{ const start = selectInfo.start; @@ -488,12 +503,18 @@ const CommitteeInterviewTimes = ({ onChange={(e) => setRoomInput(e.target.value)} />
- +
diff --git a/components/committee/Schedule.tsx b/components/committee/Schedule.tsx index c3be4373..82778fec 100644 --- a/components/committee/Schedule.tsx +++ b/components/committee/Schedule.tsx @@ -35,9 +35,10 @@ export default function Schedule({ const getDatesWithinPeriod = ( periodTime: any ): { [date: string]: string } => { - const startDate = new Date(periodTime?.start); + if (!periodTime) return {}; + const startDate = new Date(periodTime.start); startDate.setHours(startDate.getHours() + 2); - const endDate = new Date(periodTime?.end); + const endDate = new Date(periodTime.end); endDate.setHours(endDate.getHours() + 2); const dates: { [date: string]: string } = {}; const dayNames = ["Søn", "Man", "Tir", "Ons", "Tor", "Fre", "Lør"]; diff --git a/components/committee/SendCommitteeMessage.tsx b/components/committee/SendCommitteeMessage.tsx deleted file mode 100644 index abc00d8a..00000000 --- a/components/committee/SendCommitteeMessage.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useState } from "react"; -import Button from "../Button"; -import TextAreaInput from "../form/TextAreaInput"; -import { useRouter } from "next/router"; -import { committeeInterviewType, periodType } from "../../lib/types/types"; -import toast from "react-hot-toast"; -import { SimpleTitle } from "../Typography"; - -interface Props { - period: periodType | null; - committee: string; - committeeInterviewTimes: committeeInterviewType | null; -} - -const SendCommitteeMessage = ({ - period, - committee, - committeeInterviewTimes, -}: Props) => { - const router = useRouter(); - const periodId = router.query["period-id"] as string; - - const [committeeHasSubmitedTimes, setCommitteeHasSubmitedTimes] = - useState(false); - - const [committeeHasSubmitedMessage, setCommitteeHasSubmitedMessage] = - useState(false); - const [message, setMessage] = useState(""); - - useEffect(() => { - if (committeeInterviewTimes) { - setCommitteeHasSubmitedTimes(true); - if (committeeInterviewTimes.message === "") { - setCommitteeHasSubmitedMessage(false); - } else { - setCommitteeHasSubmitedMessage(true); - setMessage(committeeInterviewTimes.message); - } - setMessage(committeeInterviewTimes.message || ""); - } else { - setCommitteeHasSubmitedTimes(false); - setCommitteeHasSubmitedMessage(false); - setMessage(""); - } - }, [committeeInterviewTimes]); - - const handleMessageChange = (value: string) => { - setMessage(value); - }; - - const handleSubmit = async () => { - try { - const res = await fetch( - `/api/committees/times/${periodId}/${committee}`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - message, - }), - } - ); - - if (!res.ok) { - toast.error("Det skjedde en feil under innsendingen!"); - throw new Error("Failed to update message"); - } - - const updatedData = await res.json(); - setMessage(updatedData.message); - setCommitteeHasSubmitedMessage(true); - toast.success("Innsending er vellykket!"); - } catch (error) { - toast.error("Det skjede en feil under innsendingen!"); - console.error("Error updating message:", error); - } - }; - - if (new Date(period!.applicationPeriod.end) < new Date()) - return ( - - ); - - return ( -
- - - {!committeeHasSubmitedTimes && ( -

- For å sende en egendefinert melding må du først fylle ut intervju - tider for valgt komitee. -

- )} - - {committeeHasSubmitedTimes && !committeeHasSubmitedMessage && ( -
- - -
- )} - {committeeHasSubmitedTimes && committeeHasSubmitedMessage && ( -
- -
-
-
- )} -
- ); -}; - -export default SendCommitteeMessage; diff --git a/components/form/ApplicationForm.tsx b/components/form/ApplicationForm.tsx index 59a2f107..e32a968c 100644 --- a/components/form/ApplicationForm.tsx +++ b/components/form/ApplicationForm.tsx @@ -6,7 +6,8 @@ import Line from "./Line"; import { DeepPartial, applicantType } from "../../lib/types/types"; import { changeDisplayName } from "../../lib/utils/toString"; import { useEffect, useState } from "react"; -import toast from "react-hot-toast"; +import CustomPhoneInput from "./CustomPhoneInput"; +import "react-phone-input-2/lib/bootstrap.css"; interface Props { applicationData: DeepPartial; @@ -35,9 +36,9 @@ export const ApplicationForm = (props: Props) => { const addOptionalCommittee = (committee: string, value: string) => { let updatedCommittees = [...selectedOptionalCommittees]; - if (value === "yes" && !updatedCommittees.includes(committee)) { + if (value === "ja" && !updatedCommittees.includes(committee)) { updatedCommittees.push(committee); - } else if (value === "no" && updatedCommittees.includes(committee)) { + } else if (value === "nei" && updatedCommittees.includes(committee)) { updatedCommittees = updatedCommittees.filter( (item) => item !== committee ); @@ -56,17 +57,14 @@ export const ApplicationForm = (props: Props) => { 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 && (

@@ -76,26 +74,28 @@ export const ApplicationForm = (props: Props) => {

)} props.setApplicationData({ ...props.applicationData, email: value }) } /> props.setApplicationData({ ...props.applicationData, name: value }) } /> - props.setApplicationData({ ...props.applicationData, phone: value }) } + label="Telefonnummer" + defaultValue={props.applicationData.phone} /> + { ["4.", 4], ["5.", 5], ]} - label={"Hvilket trinn går du?"} + label="Hvilket trinn går du?" updateInputValues={(value: number) => props.setApplicationData({ ...props.applicationData, @@ -120,7 +120,7 @@ export const ApplicationForm = (props: Props) => { /> props.setApplicationData({ ...props.applicationData, about: value }) } @@ -130,8 +130,8 @@ export const ApplicationForm = (props: Props) => {
{ {availableCommittees.length > 2 && ( props.setApplicationData({ ...props.applicationData, @@ -167,7 +167,7 @@ export const ApplicationForm = (props: Props) => { {availableCommittees.length > 3 && ( props.setApplicationData({ ...props.applicationData, @@ -182,13 +182,11 @@ export const ApplicationForm = (props: Props) => { props.setApplicationData({ ...props.applicationData, @@ -200,8 +198,8 @@ export const ApplicationForm = (props: Props) => {
{ + const theme = useTheme(); + + const inputStyle = { + width: "100%", + paddingTop: "0.5rem", + paddingBottom: "0.5rem", + backgroundColor: "transparent", + color: theme === "dark" ? "#fff" : "#000", + borderColor: theme === "dark" ? "#4b5563" : "#d1d5db", + }; + + const dropdownStyle = { + backgroundColor: theme === "dark" ? "#202c34" : "#fff", + color: theme === "dark" ? "#fff" : "#000", + borderColor: theme === "dark" ? "#4b5563" : "d1d5db", + }; + + return ( +
+
+ + props.updateInputValues(text)} + /> +
+
+ ); +}; + +export default CustomPhoneInput; diff --git a/components/form/SelectInput.tsx b/components/form/SelectInput.tsx index 2dd202d9..d3feec3b 100644 --- a/components/form/SelectInput.tsx +++ b/components/form/SelectInput.tsx @@ -34,7 +34,7 @@ const SelectInput = (props: Props) => { diff --git a/components/form/TextAreaInput.tsx b/components/form/TextAreaInput.tsx index 741fc5a5..b93fd200 100644 --- a/components/form/TextAreaInput.tsx +++ b/components/form/TextAreaInput.tsx @@ -22,14 +22,14 @@ const TextAreaInput = (props: Props) => { maxLength={props.maxLength} id="textAreaComponent" placeholder={props.placeholder} - className="block w-full px-3 py-2 m-0 text-base text-gray-700 transition bg-white border border-gray-300 rounded shadow-sm dark:text-white peer bg-clip-padding focus:outline-none placeholder:text-sm dark:bg-gray-900 dark:border-gray-600" + className="block w-full px-3 py-2 m-0 text-base text-gray-700 transition bg-white border border-gray-300 rounded dark:text-white peer bg-clip-padding focus:outline-none placeholder:text-sm dark:bg-gray-900 dark:border-gray-600" onChange={handleInputChange} value={props.value} required > diff --git a/components/form/TextInput.tsx b/components/form/TextInput.tsx index 621fd7e4..d54d130b 100644 --- a/components/form/TextInput.tsx +++ b/components/form/TextInput.tsx @@ -24,7 +24,7 @@ const TextInput = (props: Props) => { onChange={(e) => { handleInputChange(e.target); }} - className="block w-full px-3 py-2 transition border border-gray-300 rounded-md shadow-sm disabled:bg-white disabled:cursor-not-allowed disabled:text-gray-500 placeholder:text-sm dark:bg-gray-900 dark:border-gray-600" + className="block w-full px-3 py-2 transition border border-gray-300 rounded-md disabled:bg-white disabled:cursor-not-allowed disabled:text-gray-500 placeholder:text-sm dark:bg-gray-900 dark:border-gray-600" />
+ ); } diff --git a/pages/admin/[period-id]/index.tsx b/pages/admin/[period-id]/index.tsx index de1d2e2b..d025954d 100644 --- a/pages/admin/[period-id]/index.tsx +++ b/pages/admin/[period-id]/index.tsx @@ -4,17 +4,22 @@ import router from "next/router"; import { periodType } from "../../../lib/types/types"; import NotFound from "../../404"; import ApplicantsOverview from "../../../components/applicantoverview/ApplicantsOverview"; +import { Tabs } from "../../../components/Tabs"; +import { CalendarIcon, InboxIcon } from "@heroicons/react/24/solid"; +import Button from "../../../components/Button"; import { useQuery } from "@tanstack/react-query"; import { fetchPeriodById } from "../../../lib/api/periodApi"; import LoadingPage from "../../../components/LoadingPage"; import ErrorPage from "../../../components/ErrorPage"; +import toast from "react-hot-toast"; const Admin = () => { const { data: session } = useSession(); - const periodId = router.query["period-id"]; - + const periodId = router.query["period-id"] as string; const [period, setPeriod] = useState(null); const [committees, setCommittees] = useState(null); + const [activeTab, setActiveTab] = useState(0); + const [tabClicked, setTabClicked] = useState(0); const { data, isError, isLoading } = useQuery({ queryKey: ["periods", periodId], @@ -28,6 +33,34 @@ const Admin = () => { ); }, [data, session?.user?.owId]); + const sendOutInterviewTimes = async ({ periodId }: { periodId: string }) => { + const confirm = window.confirm( + "Er du sikker på at du vil sende ut intervju tider?" + ); + + if (!confirm) return; + + try { + const response = await fetch( + `/api/periods/send-interview-times/${periodId}`, + { + method: "POST", + } + ); + if (!response.ok) { + throw new Error("Failed to send out interview times"); + } + const data = await response.json(); + if (data.error) { + throw new Error(data.error); + } + toast.success("Intervjutider er sendt ut! (Sjekk konsoll loggen)"); + return data; + } catch (error) { + toast.error("Failed to send out interview times"); + } + }; + console.log(committees); if (session?.user?.role !== "admin") return ; @@ -35,12 +68,49 @@ const Admin = () => { if (isError) return ; return ( - +
+ { + setActiveTab(index); + setTabClicked(index); + }} + content={[ + { + title: "Søkere", + icon: , + content: ( + + ), + }, + //Super admin :) + ...(session?.user?.email && + ["fhansteen@gmail.com", "jotto0214@gmail.com"].includes( + session.user.email + ) + ? [ + { + title: "Send ut", + icon: , + content: ( +
+
+ ), + }, + ] + : []), + ]} + /> +
); }; diff --git a/pages/api/applicants/index.ts b/pages/api/applicants/index.ts index b615d517..bd27c46b 100644 --- a/pages/api/applicants/index.ts +++ b/pages/api/applicants/index.ts @@ -65,23 +65,23 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { // optionalCommitteesString = "Ingen"; // } - // const emailData: emailDataType = { - // name: applicant.name, - // emails: [applicant.email], - // phone: applicant.phone, - // grade: applicant.grade, - // about: applicant.about.replace(/\n/g, "
"), - // firstChoice: "Tom", - // secondChoice: "Tom", - // thirdChoice: "Tom", - // bankom: - // applicant.bankom == "yes" - // ? "Ja" - // : applicant.bankom == "no" - // ? "Nei" - // : "Kanskje", - // optionalCommittees: optionalCommitteesString, - // }; + const emailData: emailDataType = { + name: applicant.name, + emails: [applicant.email], + phone: applicant.phone, + grade: applicant.grade, + about: applicant.about.replace(/\n/g, "
"), + firstChoice: "Tom", + secondChoice: "Tom", + thirdChoice: "Tom", + bankom: + applicant.bankom == "ja" + ? "Ja" + : applicant.bankom == "nei" + ? "Nei" + : "Kanskje", + optionalCommittees: optionalCommitteesString, + }; // //Type guard // if (!Array.isArray(applicant.preferences)) { @@ -99,19 +99,17 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { // : capitalizeFirstLetter(applicant.preferences.third); // } - // try { - // await sendEmail({ - // toEmails: emailData.emails, - // subject: "Vi har mottatt din søknad!", - // htmlContent: generateApplicantEmail(emailData), - // }); - - // console.log("Email sent to: ", emailData.emails); - // } catch (error) { - // console.error("Error sending email: ", error); - // throw error; - // } - // } + try { + await sendEmail({ + toEmails: emailData.emails, + subject: "Vi har mottatt din søknad!", + htmlContent: generateApplicantEmail(emailData), + }); + } catch (error) { + console.error("Error sending email: ", error); + throw error; + } + } return res.status(201).json({ applicant }); } diff --git a/pages/api/committees/times/[period-id]/[committee].ts b/pages/api/committees/times/[period-id]/[committee].ts index ff20f15e..1705b33a 100644 --- a/pages/api/committees/times/[period-id]/[committee].ts +++ b/pages/api/committees/times/[period-id]/[committee].ts @@ -2,7 +2,6 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getCommittees, deleteCommittee, - updateCommitteeMessage, } from "../../../../../lib/mongo/committees"; import { getServerSession } from "next-auth"; import { authOptions } from "../../../auth/[...nextauth]"; @@ -44,37 +43,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } - if (req.method === "PUT") { - 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, - message, - session!.user?.committees ?? [] - ); - if (error) throw new Error(error); - - return res.status(200).json({ message: updatedMessage }); - } catch (error: any) { - return res.status(500).json({ error: error.message }); - } - } - if (req.method === "DELETE") { try { const { period } = await getPeriodById(String(periodId)); @@ -102,7 +70,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { } } - res.setHeader("Allow", ["GET", "PUT", "DELETE"]); + res.setHeader("Allow", ["GET", "DELETE"]); res.status(405).end(`Method ${req.method} is not allowed.`); }; diff --git a/pages/api/committees/times/[period-id]/index.ts b/pages/api/committees/times/[period-id]/index.ts index 9cd92f1b..de2259ac 100644 --- a/pages/api/committees/times/[period-id]/index.ts +++ b/pages/api/committees/times/[period-id]/index.ts @@ -1,10 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; -import { - getCommittees, - createCommittee, - deleteCommittee, - updateCommitteeMessage, -} from "../../../../../lib/mongo/committees"; +import { createCommittee } from "../../../../../lib/mongo/committees"; import { getServerSession } from "next-auth"; import { authOptions } from "../../../auth/[...nextauth]"; import { hasSession, isInCommitee } from "../../../../../lib/utils/apiChecks"; @@ -12,8 +7,8 @@ import { isCommitteeType, validateCommittee, } from "../../../../../lib/utils/validators"; -import { commiteeType } from "../../../../../lib/types/types"; import { getPeriodById } from "../../../../../lib/mongo/periods"; +import { committeeInterviewType } from "../../../../../lib/types/types"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const session = await getServerSession(req, res, authOptions); @@ -29,7 +24,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (!isInCommitee(res, session)) return; if (req.method === "POST") { - const committeeData: commiteeType = req.body; + const committeeData: committeeInterviewType = req.body; if (!isCommitteeType(req.body)) { return res.status(400).json({ error: "Invalid data format" }); diff --git a/pages/api/periods/send-interview-times/[period-id].ts b/pages/api/periods/send-interview-times/[period-id].ts new file mode 100644 index 00000000..94a6f8aa --- /dev/null +++ b/pages/api/periods/send-interview-times/[period-id].ts @@ -0,0 +1,40 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { sendOutInterviewTimes } from "../../../../lib/sendInterviewTimes/sendInterviewTimes"; +import { hasSession, isAdmin } from "../../../../lib/utils/apiChecks"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../auth/[...nextauth]"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const periodId = req.query["period-id"]; + + if (typeof periodId !== "string") { + return res.status(400).json({ error: "Invalid ID format" }); + } + + const session = await getServerSession(req, res, authOptions); + + if (!hasSession(res, session)) return; + + try { + if (req.method === "POST") { + if (!isAdmin(res, session)) { + return res.status(401).json({ error: "Unauthorized" }); + } + + const result = await sendOutInterviewTimes({ periodId }); + if (result === undefined) { + throw new Error("An error occurred"); + } + const { error } = result; + if (error) throw new Error(error); + return res.status(201).json({ message: "Period created successfully" }); + } + } catch { + return res.status(500).json("An error occurred"); + } + + res.setHeader("Allow", ["POST"]); + res.status(405).end(`Method ${req.method} is not allowed.`); +}; + +export default handler; diff --git a/pages/apply/[period-id].tsx b/pages/apply/[period-id].tsx index 0b314be4..e4cc99da 100644 --- a/pages/apply/[period-id].tsx +++ b/pages/apply/[period-id].tsx @@ -62,7 +62,7 @@ const Application: NextPage = () => { }); const { - data: applicantData, + data: fetchedApplicationData, isError: applicantIsError, isLoading: applicantIsLoading, } = useQuery({ @@ -112,7 +112,17 @@ const Application: NextPage = () => { }, [periodData]); const handleSubmitApplication = async () => { - // if (!validateApplication(applicationData)) return; + if (!validateApplication(applicationData)) return; + + // Validate selected times + if ( + applicationData.selectedTimes && + applicationData.selectedTimes.length === 0 + ) { + toast.error("Velg minst én tilgjengelig tid"); + return false; + } + applicationData.periodId = periodId as string; createApplicantMutation.mutate(applicationData as applicantType); }; @@ -140,38 +150,41 @@ const Application: NextPage = () => { if (!periodData?.exists) return ; - // if (applicantData?.exists) - // return ( - //
- // - //

- // Vi har mottatt din søknad og sendt deg en bekreftelse på e-post! - //

- //

- // Du vil få enda en e-post med intervjutider når søknadsperioden er over - // (rundt {formatDateNorwegian(period?.applicationPeriod?.end)}). - //

- //

- // (Hvis du ikke finner e-posten din, sjekk søppelpost- eller - // spam-mappen.) - //

- // {!isApplicationPeriodOver && ( - //
- // ); + if (fetchedApplicationData?.exists) + return ( +
+ +

+ Vi har mottatt din søknad og sendt deg en bekreftelse på e-post! +

+

+ Du vil få enda en e-post med intervjutider når søknadsperioden er over + (rundt {formatDateNorwegian(period?.applicationPeriod?.end)}). +

+

+ (Hvis du ikke finner e-posten din, sjekk søppelpost- eller + spam-mappen.) +

+ {!isApplicationPeriodOver && ( +
+ ); return (
diff --git a/pages/committee/[period-id]/[committee]/index.tsx b/pages/committee/[period-id]/[committee]/index.tsx index 6c43412c..a2f9bd45 100644 --- a/pages/committee/[period-id]/[committee]/index.tsx +++ b/pages/committee/[period-id]/[committee]/index.tsx @@ -13,7 +13,6 @@ import { UserGroupIcon, } from "@heroicons/react/24/solid"; import { Tabs } from "../../../../components/Tabs"; -import SendCommitteeMessage from "../../../../components/committee/SendCommitteeMessage"; import CommitteeInterviewTimes from "../../../../components/committee/CommitteeInterviewTimes"; import LoadingPage from "../../../../components/LoadingPage"; import { changeDisplayName } from "../../../../lib/utils/toString"; @@ -153,17 +152,7 @@ const CommitteeApplicantOverview: NextPage = () => { /> ), }, - { - title: "Melding", - icon: , - content: ( - - ), - }, + { title: "Søkere", icon: , diff --git a/pages/committees.tsx b/pages/committees.tsx index a272109b..753e38c2 100644 --- a/pages/committees.tsx +++ b/pages/committees.tsx @@ -7,12 +7,46 @@ import { fetchOwCommittees } from "../lib/api/committeesApi"; import ErrorPage from "../components/ErrorPage"; import { fetchPeriods } from "../lib/api/periodApi"; import { MainTitle } from "../components/Typography"; +import { UsersIcon } from "@heroicons/react/24/outline"; +import { Tabs } from "../components/Tabs"; +import { UserIcon } from "@heroicons/react/24/solid"; +import { shuffleList } from "../lib/utils/shuffleList"; const excludedCommittees = ["Faddere", "Output"]; +const otherCommittees = ["Jubkom", "Velkom", "Ekskom", "Debug"]; + +const hasPeriod = (committee: owCommitteeType, periods: periodType[]) => { + if (!Array.isArray(periods)) return false; + + const today = new Date(); + + if (committee.name_short === "Bankom") { + return periods.some((period) => { + const applicationStart = new Date(period.applicationPeriod.start); + const applicationEnd = new Date(period.applicationPeriod.end); + return applicationStart <= today && applicationEnd >= today; + }); + } + + return periods.some((period) => { + const applicationStart = new Date(period.applicationPeriod.start); + const applicationEnd = new Date(period.applicationPeriod.end); + + return ( + applicationStart <= today && + applicationEnd >= today && + (period.committees.includes(committee.name_short) || + period.optionalCommittees.includes(committee.name_short)) + ); + }); +}; + const Committees = () => { const [committees, setCommittees] = useState([]); + const [nodeCommittees, setNodeCommittees] = useState([]); const [periods, setPeriods] = useState([]); + const [activeTab, setActiveTab] = useState(0); const { data: owCommitteeData, @@ -35,12 +69,19 @@ const Committees = () => { useEffect(() => { if (!owCommitteeData) return; - const filteredCommittees = owCommitteeData.filter( + const filterNodeCommittees = owCommitteeData.filter( (committee: owCommitteeType) => - !excludedCommittees.includes(committee.name_short) + otherCommittees.includes(committee.name_short) ); + setNodeCommittees(shuffleList(filterNodeCommittees)); - setCommittees(filteredCommittees); + let filteredCommittees = owCommitteeData.filter( + (committee: owCommitteeType) => + !excludedCommittees.includes(committee.name_short) && + !otherCommittees.includes(committee.name_short) + ); + + setCommittees(shuffleList(filteredCommittees)); }, [owCommitteeData]); useEffect(() => { @@ -49,57 +90,68 @@ const Committees = () => { setPeriods(periodsData.periods); }, [periodsData]); - const hasPeriod = (committee: owCommitteeType) => { - if (!Array.isArray(periods)) return false; - - const today = new Date(); - - if (committee.name_short === "Bankom") { - return periods.some((period) => { - const applicationStart = new Date(period.applicationPeriod.start); - const applicationEnd = new Date(period.applicationPeriod.end); - return applicationStart <= today && applicationEnd >= today; - }); - } - - return periods.some((period) => { - const applicationStart = new Date(period.applicationPeriod.start); - const applicationEnd = new Date(period.applicationPeriod.end); - - return ( - applicationStart <= today && - applicationEnd >= today && - (period.committees.includes(committee.name_short) || - period.optionalCommittees.includes(committee.name_short)) - ); - }); - }; - if (owCommitteeIsLoading || periodsIsLoading) return ; if (owCommitteeIsError || periodsIsError) return ; return ( -
-
- -
-
- {committees?.map((committee, index) => { +
+ + + { + setActiveTab(index); + }} + content={[ + { + title: "Komiteer", + icon: , + content: , + }, + { + title: "Nodekomiteer", + icon: , + content: ( + + ), + }, + ]} + /> +
+ ); +}; + +export default Committees; + +const CommitteList = ({ + committees, + periods, +}: { + committees: owCommitteeType[]; + periods: periodType[]; +}) => ( +
+
+ {committees + ?.sort( + (a, b) => + Number(hasPeriod(b, periods)) - Number(hasPeriod(a, periods)) + ) + .map((committee, index) => { return ( ); })} -
- ); -}; - -export default Committees; +
+); diff --git a/pages/index.tsx b/pages/index.tsx index 5055ff0f..7aa0dadd 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -27,11 +27,12 @@ const Home = () => {

- opptak.online + Online Opptak

- Her skal det stå masse kult om komitéopptak og sånn og om at man må - bli med i komité og at dette er det bra opptakssystem og sånn. + Her kan du søke for å bli en del av en av Onlines komiteer. + Komitémedlemmene våre får Online til å gå rundt, og arbeider for at + alle informatikkstudenter skal ha en flott studiehverdag.

-
+