From 114e106828a47165dbe0be963631e5bc983ae846 Mon Sep 17 00:00:00 2001 From: Aksel Date: Sun, 1 Mar 2026 16:41:08 +0100 Subject: [PATCH 1/2] feat(admin): add committee overview with interview capacity status and period API --- .../committeeoverview/committeeCard.tsx | 167 ++++++++++++++++++ lib/api/committeesApi.ts | 9 + pages/admin/[period-id]/index.tsx | 23 ++- pages/api/committees/by-period/[period-id].ts | 33 ++++ 4 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 components/committeeoverview/committeeCard.tsx create mode 100644 pages/api/committees/by-period/[period-id].ts diff --git a/components/committeeoverview/committeeCard.tsx b/components/committeeoverview/committeeCard.tsx new file mode 100644 index 00000000..a8fa9883 --- /dev/null +++ b/components/committeeoverview/committeeCard.tsx @@ -0,0 +1,167 @@ +import { useQuery } from "@tanstack/react-query"; +import { + applicantType, + committeeInterviewType, + committeePreferenceType, + preferencesType, +} from "../../lib/types/types"; +import { fetchCommitteesByPeriod } from "../../lib/api/committeesApi"; +import { fetchApplicantsByPeriodId } from "../../lib/api/applicantApi"; +import Table, { RowType } from "../Table"; +import { TableSkeleton } from "../skeleton/TableSkeleton"; +import ErrorPage from "../ErrorPage"; +import { InformationCircleIcon } from "@heroicons/react/24/outline"; + +type CommitteeRow = committeeInterviewType & { + _id?: string; +}; + +const columns = [ + { label: "Komité", field: "committee" }, + { label: "Søknader", field: "applications" }, + { label: "Intervjukapasitet / tidsblokker", field: "interviewsPlanned" }, + { label: "Intervjulengde", field: "timeslot" }, +]; + +const getApplicantCommittees = (applicant: applicantType): string[] => { + const preferences = applicant.preferences; + const preferenceCommittees: string[] = + (preferences as preferencesType).first !== undefined + ? [ + (preferences as preferencesType).first, + (preferences as preferencesType).second, + (preferences as preferencesType).third, + ] + : (preferences as committeePreferenceType[]).map( + (preference) => preference.committee, + ); + + return [ + ...preferenceCommittees.filter((committee) => committee), + ...(applicant.optionalCommittees ?? []), + ]; +}; + +const calculateInterviewsPlanned = (committee: committeeInterviewType) => { + const timeslotMinutes = parseInt(committee.timeslot, 10); + if (!timeslotMinutes || !committee.availabletimes) return 0; + + const totalMinutes = committee.availabletimes.reduce((acc, time) => { + const start = new Date(time.start); + const end = new Date(time.end); + const duration = (end.getTime() - start.getTime()) / 1000 / 60; + return acc + duration; + }, 0); + + return Math.floor(totalMinutes / timeslotMinutes); +}; + +const getInterviewStatus = ( + planned: number, + applications: number, + thresholdRatio = 1.5 +) => { + if (applications === 0) { + return { + tone: "green", + message: "Ingen søkere ennå. Behold gjerne tider tilgjengelig.", + }; + } + + const ratio = planned / applications; + + if (ratio >= thresholdRatio) { + return { + tone: "green", + message: "God dekning av intervjutider.", + }; + } + + if (ratio >= 1) { + return { + tone: "yellow", + message: `Bør legge til flere tider (mål: ${thresholdRatio}x søkere).`, + }; + } + + return { + tone: "red", + message: "For få tider. Komitéen må legge inn flere intervjutider.", + }; +}; + +const CommitteeCard = ({ periodId }: { periodId?: string }) => { + const { data, isError, isLoading } = useQuery({ + queryKey: ["committees-by-period", periodId], + queryFn: fetchCommitteesByPeriod, + enabled: !!periodId, + }); + + const { + data: applicationsData, + isError: applicationsIsError, + isLoading: applicationsIsLoading, + } = useQuery({ + queryKey: ["applications-by-period", periodId], + queryFn: fetchApplicantsByPeriodId, + enabled: !!periodId, + }); + + if (!periodId) { + return
Mangler periodId.
; + } + + if (isLoading || applicationsIsLoading) + return ; + if (isError || applicationsIsError) return ; + + const committees: CommitteeRow[] = data?.committees ?? []; + const applications: applicantType[] = applicationsData?.applications ?? []; + + if (committees.length === 0) { + return
Ingen komiteer har levert tider enda.
; + } + + const rows: RowType[] = committees.map((committee, index) => { + const interviewsPlanned = calculateInterviewsPlanned(committee); + const availableCount = committee.availabletimes?.length ?? 0; + const applicationsCount = applications.filter((application) => { + const applicantCommittees = getApplicantCommittees(application).map( + (value) => value.toLowerCase(), + ); + return applicantCommittees.includes(committee.committee.toLowerCase()); + }).length; + const status = getInterviewStatus(interviewsPlanned, applicationsCount); + const statusClasses = + status.tone === "green" + ? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" + : status.tone === "yellow" + ? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + : "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"; + return { + id: committee._id?.toString?.() ?? `${committee.committee}-${index}`, + committee: {committee.committee}, + applications: {applicationsCount}, + interviewsPlanned: ( + + + {`${interviewsPlanned} / ${availableCount}`} + + + + ), + timeslot: {committee.timeslot} min, + }; + }); + + return ( +
+ + + ); +}; + +export default CommitteeCard; diff --git a/lib/api/committeesApi.ts b/lib/api/committeesApi.ts index a4fbe4c5..152ac816 100644 --- a/lib/api/committeesApi.ts +++ b/lib/api/committeesApi.ts @@ -13,3 +13,12 @@ export const fetchCommitteeTimes = async (context: QueryFunctionContext) => { res.json(), ); }; + +export const fetchCommitteesByPeriod = async ( + context: QueryFunctionContext, +) => { + const periodId = context.queryKey[1]; + return fetch(`/api/committees/by-period/${periodId}`).then((res) => + res.json(), + ); +}; diff --git a/pages/admin/[period-id]/index.tsx b/pages/admin/[period-id]/index.tsx index 0e606c78..18feff53 100644 --- a/pages/admin/[period-id]/index.tsx +++ b/pages/admin/[period-id]/index.tsx @@ -5,8 +5,13 @@ 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 { + CalendarIcon, + InboxIcon, + UserGroupIcon, +} from "@heroicons/react/24/solid"; import Button from "../../../components/Button"; +import CommitteeCard from "../../../components/committeeoverview/committeeCard"; import { useQuery } from "@tanstack/react-query"; import { fetchPeriodById } from "../../../lib/api/periodApi"; import LoadingPage from "../../../components/LoadingPage"; @@ -78,14 +83,18 @@ const Admin = () => { content={[ { title: "Søkere", - icon: , + icon: , content: ( - + ), + },{ + title: "Komitéoversikt", + icon: , + content: , }, //Super admin :) ...(session?.user?.email && diff --git a/pages/api/committees/by-period/[period-id].ts b/pages/api/committees/by-period/[period-id].ts new file mode 100644 index 00000000..c980b935 --- /dev/null +++ b/pages/api/committees/by-period/[period-id].ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../auth/[...nextauth]"; +import { hasSession, isAdmin } from "../../../../lib/utils/apiChecks"; +import { getCommitteesByPeriod } from "../../../../lib/mongo/committees"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getServerSession(req, res, authOptions); + + if (!hasSession(res, session)) return; + if (!isAdmin(res, session)) return; + + const periodId = req.query["period-id"]; + if (!periodId || typeof periodId !== "string") { + return res.status(400).json({ error: "Invalid or missing periodId" }); + } + + if (req.method !== "GET") { + res.setHeader("Allow", ["GET"]); + return res.status(405).end(`Method ${req.method} is not allowed.`); + } + + try { + const { result, error } = await getCommitteesByPeriod(periodId); + if (error) throw new Error(error); + + return res.status(200).json({ committees: result }); + } catch (error: any) { + return res.status(500).json({ error: error.message }); + } +}; + +export default handler; From 77f4a7bd4c966378c2193973e382cdfe37d8f482 Mon Sep 17 00:00:00 2001 From: Aksel Date: Sun, 1 Mar 2026 17:16:46 +0100 Subject: [PATCH 2/2] fix: inline committeePreferenceType to fix build error The type was never exported from types.ts, causing the build to fail after merging main. Co-Authored-By: Claude Opus 4.6 --- components/committeeoverview/committeeCard.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/committeeoverview/committeeCard.tsx b/components/committeeoverview/committeeCard.tsx index a8fa9883..9fa39556 100644 --- a/components/committeeoverview/committeeCard.tsx +++ b/components/committeeoverview/committeeCard.tsx @@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query"; import { applicantType, committeeInterviewType, - committeePreferenceType, preferencesType, } from "../../lib/types/types"; import { fetchCommitteesByPeriod } from "../../lib/api/committeesApi"; @@ -32,7 +31,7 @@ const getApplicantCommittees = (applicant: applicantType): string[] => { (preferences as preferencesType).second, (preferences as preferencesType).third, ] - : (preferences as committeePreferenceType[]).map( + : (preferences as unknown as { committee: string }[]).map( (preference) => preference.committee, );