diff --git a/components/committeeoverview/committeeCard.tsx b/components/committeeoverview/committeeCard.tsx
new file mode 100644
index 00000000..9fa39556
--- /dev/null
+++ b/components/committeeoverview/committeeCard.tsx
@@ -0,0 +1,166 @@
+import { useQuery } from "@tanstack/react-query";
+import {
+ applicantType,
+ committeeInterviewType,
+ 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 unknown as { committee: string }[]).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 51d24a7e..df1d37a9 100644
--- a/pages/admin/[period-id]/index.tsx
+++ b/pages/admin/[period-id]/index.tsx
@@ -6,8 +6,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";
@@ -110,14 +115,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;