-
Notifications
You must be signed in to change notification settings - Fork 2
feat(admin): add committee overview with interview capacity status an… #433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
114e106
693a078
6ec33be
77f4a7b
7372d76
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,3 +13,12 @@ export const fetchCommitteeTimes = async (context: QueryFunctionContext) => { | |
| res.json(), | ||
| ); | ||
| }; | ||
|
|
||
| export const fetchCommitteesByPeriod = async ( | ||
| context: QueryFunctionContext, | ||
| ) => { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. legg til returtype på funksjonen |
||
| const periodId = context.queryKey[1]; | ||
| return fetch(`/api/committees/by-period/${periodId}`).then((res) => | ||
| res.json(), | ||
| ); | ||
| }; | ||
| 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tenker for API-strukturens del at det kanskje kunne vært bedre å ha på en fil |
||
|
|
||
| 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; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Var det en spesiell grunn til at den var sånn dobbelt før?