Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions components/committeeoverview/committeeCard.tsx
Original file line number Diff line number Diff line change
@@ -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 <div className="px-5">Mangler periodId.</div>;
}

if (isLoading || applicationsIsLoading)
return <TableSkeleton columns={columns} />;
if (isError || applicationsIsError) return <ErrorPage />;

const committees: CommitteeRow[] = data?.committees ?? [];
const applications: applicantType[] = applicationsData?.applications ?? [];

if (committees.length === 0) {
return <div className="px-5">Ingen komiteer har levert tider enda.</div>;
}

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: <span className="block text-center">{committee.committee}</span>,
applications: <span className="block text-center">{applicationsCount}</span>,
interviewsPlanned: (
<span className="inline-flex w-full items-center justify-center gap-2">
<span className={`rounded-full px-2 py-0.5 text-xs ${statusClasses}`}>
{`${interviewsPlanned} / ${availableCount}`}
</span>
<InformationCircleIcon
className="h-4 w-4 text-gray-400"
title={status.message}
/>
</span>
),
timeslot: <span className="block text-center">{committee.timeslot} min</span>,
};
});

return (
<div className="w-full mx-auto max-w-6xl">
<Table columns={columns} rows={rows} />
</div>
);
};

export default CommitteeCard;
9 changes: 9 additions & 0 deletions lib/api/committeesApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
};
23 changes: 16 additions & 7 deletions pages/admin/[period-id]/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -110,14 +115,18 @@ const Admin = () => {
content={[
{
title: "Søkere",
icon: <CalendarIcon className="w-5 h-5" />,
icon: <CalendarIcon className="w-5 h-5"/>,
content: (
<ApplicantsOverview
period={period}
committees={committees}
includePreferences={true}
/>
<ApplicantsOverview
period={period}
committees={committees}
includePreferences={true}
/>
),
},{
title: "Komitéoversikt",
icon: <UserGroupIcon className="w-5 h-5" />,
content: <CommitteeCard periodId={period?._id?.toString()} />,
},
//Super admin :)
...(session?.user?.email &&
Expand Down
33 changes: 33 additions & 0 deletions pages/api/committees/by-period/[period-id].ts
Original file line number Diff line number Diff line change
@@ -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;