diff --git a/components/Table.tsx b/components/Table.tsx index 78039b12..67281dc9 100644 --- a/components/Table.tsx +++ b/components/Table.tsx @@ -3,7 +3,7 @@ import { FaTrash } from "react-icons/fa"; import React from "react"; import { useRouter } from "next/router"; -type ColumnType = { +export type ColumnType = { label: string; field: string; }; @@ -52,9 +52,8 @@ const Table = ({ rows, columns, onDelete }: TableProps) => { {columns.map((column) => ( {column.field === "delete" && onDelete ? (
diff --git a/components/admin/RoomOverview.tsx b/components/admin/RoomOverview.tsx new file mode 100644 index 00000000..72cf4f41 --- /dev/null +++ b/components/admin/RoomOverview.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from "react"; +import { periodType, RoomBooking } from "../../lib/types/types"; +import Button from "../Button"; +import Table, { ColumnType } from "../Table"; +import DateInput from "../form/DateInput"; +import TextInput from "../form/TextInput"; +import TimeRangeInput from "../form/TimeRangeInput"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { WithId } from "mongodb"; +import toast from "react-hot-toast"; +import { createRoom, deleteRoom, fetchRoomsByPeriodId } from "../../lib/api/roomApi"; + +interface Props { + period?: periodType; +} + +const RoomInterview = ({ + period +}: Props) => { + + const queryClient = useQueryClient(); + + // TODO: Fix correct tabbing + const [roomBookings, setRoomBookings] = useState[]>([]) + + const [date, setDate] = useState(""); + const [startTime, setStartTime] = useState(""); + const [endTime, setEndTime] = useState(""); + const [room, setRoom] = useState(""); + + const isValidBooking = () => { + if (!room) { + toast.error("Vennligst fyll inn rom") + return false; + } + if (!date) { + toast.error("Vennligst velg dato") + return false; + } + if (!startTime || !endTime) { + toast.error("Vennligst velg tidspunkt") + return false; + } + if (Date.parse("2003-07-26T" + startTime) - Date.parse("2003-07-26T" + endTime) > 0) { + toast.error("Starttid må være før sluttid") + return false; + } + + // TODO: Make sure time is within interviewPeriod + + return true; + } + + const { + data: roomData, + } = useQuery({ + queryKey: ["rooms", period?._id], + queryFn: fetchRoomsByPeriodId + }) + + const createRoomBookingMutation = useMutation({ + mutationFn: createRoom, + onSuccess: () => + queryClient.invalidateQueries({ + // TODO: try to update cache instead + queryKey: ["rooms", period?._id], + }), + }); + + const deleteRoomBookingMutation = useMutation({ + mutationFn: (roomId: string) => deleteRoom(roomId, String(period!._id)), + onSuccess: () => + queryClient.invalidateQueries({ + // TODO: try to update cache instead + queryKey: ["rooms", period?._id], + }), + }); + + useEffect(() => { + if (!roomData) return + const { rooms } = roomData + console.log(rooms) + setRoomBookings(rooms) + }, [roomData]) + + const handleAddBooking = () => { + if (!isValidBooking()) return; + addBooking() + setRoom("") + } + + const addBooking = () => { + const booking: RoomBooking = { + periodId: String(period?._id), + room: room, + startDate: new Date(date.split("T")[0] + "T" + startTime), + endDate: new Date(date.split("T")[0] + "T" + endTime) + } + + createRoomBookingMutation.mutate(booking) + } + + const handleDeleteBooking = async (booking: WithId) => { + if (!period) return; + const isConfirmed = window.confirm( + `Er det sikker på at du ønsker å fjerne bookingen av ${booking.room} fra ${booking.startDate} til ${booking.endDate}?` + ); + if (!isConfirmed) return; + + setRoomBookings(roomBookings.filter((bookingA) => bookingA._id != booking._id)) + + deleteRoomBookingMutation.mutate(String(booking._id)) + }; + + const columns: ColumnType[] = [ + { label: "Rom", field: "room" }, + { label: "Dato", field: "date" }, + { label: "Fra", field: "from" }, + { label: "Til", field: "to" }, + { label: "Slett", field: "delete" }, + ] + + return
+

Legg inn romvalg

+
+
+ { + setRoom(input) + }} + label="Romnavn" + className="mx-0" + /> + { + setDate(date) + }} + label="Test" + /> + { + setStartTime(times.start) + setEndTime(times.end) + }} + className="mx-0" + /> +
+ +
+

Alle tilgjengelige romvalg

+ {roomBookings?.length ? { + return { + id: String(roomBooking._id), + room: roomBooking.room, + date: new Date(roomBooking.startDate).toLocaleDateString(), + from: new Date(roomBooking.startDate).toLocaleTimeString(), + to: new Date(roomBooking.endDate).toLocaleTimeString() + } + })} onDelete={(id: string, name: string) => { + const deletedBooking = roomBookings.find((booking, index, array) => { + return String(booking._id) == id + }) + handleDeleteBooking(deletedBooking!) + }} /> + :

Legg inn et rom, så dukker det opp her.

} + +}; + +export default RoomInterview; diff --git a/components/form/DateInput.tsx b/components/form/DateInput.tsx new file mode 100644 index 00000000..eaff3c9b --- /dev/null +++ b/components/form/DateInput.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; +interface Props { + label?: string; + updateDate: (date: string) => void; +} + +const DateRangeInput = (props: Props) => { + const [date, setDate] = useState(""); + + useEffect(() => { + const dateString = date ? `${date}T00:00` : ""; + props.updateDate(dateString); + }, [date]); + + return ( +
+ setDate(e.target.value)} + className="border text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 border-gray-300 text-gray-900 dark:border-gray-600 dark:bg-online-darkBlue dark:text-gray-200" + /> +
+ ); +}; + +export default DateRangeInput; diff --git a/components/form/DatePickerInput.tsx b/components/form/DateRangeInput.tsx similarity index 95% rename from components/form/DatePickerInput.tsx rename to components/form/DateRangeInput.tsx index 6ef4474c..e3182dbf 100644 --- a/components/form/DatePickerInput.tsx +++ b/components/form/DateRangeInput.tsx @@ -4,7 +4,7 @@ interface Props { updateDates: (dates: { start: string; end: string }) => void; } -const DatePickerInput = (props: Props) => { +const DateRangeInput = (props: Props) => { const [fromDate, setFromDate] = useState(""); const [toDate, setToDate] = useState(""); @@ -42,4 +42,4 @@ const DatePickerInput = (props: Props) => { ); }; -export default DatePickerInput; +export default DateRangeInput; diff --git a/components/form/TextInput.tsx b/components/form/TextInput.tsx index d54d130b..aa4e2d48 100644 --- a/components/form/TextInput.tsx +++ b/components/form/TextInput.tsx @@ -4,6 +4,7 @@ interface Props { disabled?: boolean; placeholder?: string; defaultValue?: string; + className?: string; } const TextInput = (props: Props) => { @@ -12,7 +13,7 @@ const TextInput = (props: Props) => { }; return ( -
+
void; + className?: string; +} + +const TimeRangeInput = (props: Props) => { + const [fromTime, setFromTime] = useState(""); + const [toTime, setToTime] = useState(""); + new Date() + + useEffect(() => { + const startTime = fromTime ? `${fromTime}` : ""; + const endTime = toTime ? `${toTime}` : ""; + props.updateTimes({ start: startTime, end: endTime }); + }, [fromTime, toTime]); + + return ( +
+ +
+ setFromTime(e.target.value)} + className="border text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 border-gray-300 text-gray-900 dark:border-gray-600 dark:bg-online-darkBlue dark:text-gray-200" + /> + til + setToTime(e.target.value)} + className="border text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 border-gray-300 text-gray-900 dark:border-gray-600 dark:bg-online-darkBlue dark:text-gray-200" + /> +
+
+ ); +}; + +export default TimeRangeInput; diff --git a/lib/api/roomApi.ts b/lib/api/roomApi.ts new file mode 100644 index 00000000..8fd9bf3c --- /dev/null +++ b/lib/api/roomApi.ts @@ -0,0 +1,41 @@ +import { QueryFunctionContext } from "@tanstack/react-query"; +import { RoomBooking } from "../types/types"; + +export const fetchRoomByPeriodAndId = async (context: QueryFunctionContext) => { + const periodId = context.queryKey[1]; + const roomId = context.queryKey[2]; + return fetch(`/api/rooms/${periodId}/${roomId}`).then((res) => res.json()); +}; + +export const fetchRoomsByPeriodId = async (context: QueryFunctionContext) => { + const periodId = context.queryKey[1]; + return fetch(`/api/rooms/${periodId}`).then((res) => res.json()); +}; + +export const createRoom = async (room: RoomBooking): Promise => { + const response = await fetch(`/api/rooms/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(room), + }); + + const { message, data, error } = await response.json(); + if (!response.ok) { + throw new Error(error || "Unknown error occurred"); + } + return data; +}; + +export const deleteRoom = async (roomId: string, periodId: string) => { + const response = await fetch(`/api/rooms/${periodId}/${roomId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete the room"); + } + + return response; +}; diff --git a/lib/mongo/rooms.ts b/lib/mongo/rooms.ts new file mode 100644 index 00000000..2a42d923 --- /dev/null +++ b/lib/mongo/rooms.ts @@ -0,0 +1,133 @@ +import { Collection, Db, MongoClient, ObjectId } from "mongodb"; +import { RoomBooking } from "../types/types"; +import clientPromise from "./mongodb"; + +let client: MongoClient; +let db: Db; +let rooms: Collection; + +async function init() { + if (db) return; + try { + client = await clientPromise; + db = client.db(); + rooms = db.collection("rooms"); + } catch (error) { + console.error(error); + throw new Error("Failed to establish connection to database"); + } +} + +(async () => { + await init(); +})(); + +export const createRoom = async (roomData: RoomBooking) => { + try { + if (!rooms) await init(); + + const existingRoom = await rooms.findOne({ + room: roomData.room, + periodId: roomData.periodId, + $or: [ + /* Tillater ikke overlappende tidspunkt på samme rom. */ + { + $and: [ + { + startDate: { $gte: roomData.startDate }, + }, + { startDate: { $lte: roomData.endDate } }, + ], + }, + { + $and: [ + { + endDate: { $gte: roomData.startDate }, + }, + { endDate: { $lte: roomData.endDate } }, + ], + }, + ], + }); + + if (existingRoom) { + return { error: "409 Room booking already exists for this period" }; + } + + const result = await rooms.insertOne(roomData); + if (result.insertedId) { + const insertedRoom = await rooms.findOne({ + _id: result.insertedId, + }); + if (insertedRoom) { + return { room: insertedRoom }; + } else { + return { error: "Failed to retrieve the created room" }; + } + } else { + return { error: "Failed to create room" }; + } + } catch (error) { + console.error(error); + return { error: "Failed to create room" }; + } +}; + +export const getRooms = async () => { + try { + if (!rooms) await init(); + const result = await rooms.find({}).toArray(); + return { rooms: result }; + } catch (error) { + return { error: "Failed to fetch rooms" }; + } +}; + +export const getRoom = async (roomId: string) => { + try { + if (!rooms) await init(); + + const result = await rooms.findOne({ + _id: new ObjectId(roomId), + }); + + return { room: result, exists: !!result }; + } catch (error) { + console.error(error); + return { error: "Failed to fetch room", exists: false }; + } +}; + +export const getRoomsByPeriod = async (periodId: string) => { + try { + if (!rooms) await init(); + + const result = await rooms + .find({ periodId: periodId }) // No ObjectId conversion needed + .toArray(); + + return { rooms: result, exists: result.length > 0 }; + } catch (error) { + console.error(error); + return { error: "Failed to fetch rooms" }; + } +}; + +export const deleteRoom = async (id: string) => { + try { + if (!rooms) await init(); + + const result = await rooms.deleteOne({ + _id: new ObjectId(id), + }); + + if (result.deletedCount === 1) { + return { message: "Room deleted successfully" }; + } else { + return { error: "Room not found or already deleted" }; + } + } catch (error) { + console.error(error); + return { error: "Failed to delete room" }; + } +}; diff --git a/lib/types/types.ts b/lib/types/types.ts index 26a48323..35f85c86 100644 --- a/lib/types/types.ts +++ b/lib/types/types.ts @@ -142,3 +142,11 @@ export type emailApplicantInterviewType = { }; }[]; }; + +export interface RoomBooking { + periodId: string; + room: String; + startDate: Date; + endDate: Date; + committeeId?: String; +} diff --git a/lib/utils/validators.ts b/lib/utils/validators.ts index e4433c4f..52aed83d 100644 --- a/lib/utils/validators.ts +++ b/lib/utils/validators.ts @@ -3,6 +3,7 @@ import { committeeInterviewType, periodType, preferencesType, + RoomBooking, } from "../types/types"; export const isApplicantType = ( @@ -151,11 +152,11 @@ export const validateCommittee = (data: any, period: periodType): boolean => { ); }; -export const isPeriodType = (data: any): data is periodType => { - const isDateString = (str: any): boolean => { - return typeof str === "string" && !isNaN(Date.parse(str)); - }; +const isDateString = (str: any): boolean => { + return typeof str === "string" && !isNaN(Date.parse(str)); +}; +export const isPeriodType = (data: any): data is periodType => { const isValidPeriod = (period: any): boolean => { return ( typeof period === "object" && @@ -191,3 +192,21 @@ export const isPeriodType = (data: any): data is periodType => { return hasBasicFields; }; + +export const isRoomBookings = (data: any): data is RoomBooking[] => { + if (!Array.isArray(data)) return false; + return data.every(isRoomBooking); +}; + +export const isRoomBooking = (data: any): data is RoomBooking => { + return ( + typeof data === "object" && + data !== null && + typeof data.periodId === "string" && + typeof data.room === "string" && + isDateString(data.startDate) && + isDateString(data.endDate) && + (typeof data.committeeId === "string" || + typeof data.committeeId === "undefined") + ); +}; diff --git a/package-lock.json b/package-lock.json index c99b5b06..355d0a2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@vercel/analytics": "^1.1.2", "lucide-react": "^0.441.0", "mongodb": "^6.1.0", + "online-opptak": "file:", "next": "^14.2.16", "next-auth": "^4.24.10", "react": "18.2.0", @@ -5133,6 +5134,10 @@ "wrappy": "1" } }, + "node_modules/online-opptak": { + "resolved": "", + "link": true + }, "node_modules/openid-client": { "version": "5.6.5", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.6.5.tgz", diff --git a/package.json b/package.json index 336c3ed5..843fc93a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@vercel/analytics": "^1.1.2", "lucide-react": "^0.441.0", "mongodb": "^6.1.0", + "online-opptak": "file:", "next": "^14.2.16", "next-auth": "^4.24.10", "react": "18.2.0", diff --git a/pages/admin/[period-id]/index.tsx b/pages/admin/[period-id]/index.tsx index d025954d..081bc142 100644 --- a/pages/admin/[period-id]/index.tsx +++ b/pages/admin/[period-id]/index.tsx @@ -4,8 +4,9 @@ import router from "next/router"; import { periodType } from "../../../lib/types/types"; import NotFound from "../../404"; import ApplicantsOverview from "../../../components/applicantoverview/ApplicantsOverview"; +import RoomOverview from "../../../components/admin/RoomOverview"; import { Tabs } from "../../../components/Tabs"; -import { CalendarIcon, InboxIcon } from "@heroicons/react/24/solid"; +import { CalendarIcon, InboxIcon, BuildingOffice2Icon } from "@heroicons/react/24/solid"; import Button from "../../../components/Button"; import { useQuery } from "@tanstack/react-query"; import { fetchPeriodById } from "../../../lib/api/periodApi"; @@ -87,26 +88,35 @@ const Admin = () => { /> ), }, + { + title: "Romoppsett", + icon: , + content: ( + + ) + }, //Super admin :) ...(session?.user?.email && - ["fhansteen@gmail.com", "jotto0214@gmail.com"].includes( - session.user.email - ) + ["fhansteen@gmail.com", "jotto0214@gmail.com"].includes( + session.user.email + ) ? [ - { - title: "Send ut", - icon: , - content: ( -
-
- ), - }, - ] + { + title: "Send ut", + icon: , + content: ( +
+
+ ), + }, + ] : []), ]} /> diff --git a/pages/admin/new-period.tsx b/pages/admin/new-period.tsx index ee9035d1..90a4f935 100644 --- a/pages/admin/new-period.tsx +++ b/pages/admin/new-period.tsx @@ -5,7 +5,7 @@ import { useRouter } from "next/router"; import Button from "../../components/Button"; import ApplicationForm from "../../components/form/ApplicationForm"; import CheckboxInput from "../../components/form/CheckboxInput"; -import DatePickerInput from "../../components/form/DatePickerInput"; +import DateRangeInput from "../../components/form/DateRangeInput"; import TextAreaInput from "../../components/form/TextAreaInput"; import TextInput from "../../components/form/TextInput"; import { DeepPartial, periodType } from "../../lib/types/types"; @@ -154,11 +154,11 @@ const NewPeriod = () => { />
- - @@ -214,7 +214,7 @@ const NewPeriod = () => {
{}} + setApplicationData={() => { }} availableCommittees={ (periodData.committees?.filter(Boolean) as string[]) || [] } diff --git a/pages/api/periods/[id].ts b/pages/api/periods/[id].ts index 55e0787b..f026da16 100644 --- a/pages/api/periods/[id].ts +++ b/pages/api/periods/[id].ts @@ -1,8 +1,8 @@ import { NextApiRequest, NextApiResponse } from "next"; import { getServerSession } from "next-auth"; -import { authOptions } from "../auth/[...nextauth]"; import { deletePeriodById, getPeriodById } from "../../../lib/mongo/periods"; import { hasSession, isAdmin } from "../../../lib/utils/apiChecks"; +import { authOptions } from "../auth/[...nextauth]"; const handler = async (req: NextApiRequest, res: NextApiResponse) => { const session = await getServerSession(req, res, authOptions); diff --git a/pages/api/rooms/[period-id]/[id].ts b/pages/api/rooms/[period-id]/[id].ts new file mode 100644 index 00000000..ce2f5f92 --- /dev/null +++ b/pages/api/rooms/[period-id]/[id].ts @@ -0,0 +1,61 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { authOptions } from "../../auth/[...nextauth]"; +import { hasSession, isAdmin } from "../../../../lib/utils/apiChecks"; +import { getPeriodById } from "../../../../lib/mongo/periods"; +import { deleteRoom, getRoom } from "../../../../lib/mongo/rooms"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getServerSession(req, res, authOptions); + + if (!hasSession(res, session)) return; + + const id = req.query.id; + const periodId = req.query["period-id"]; + + if (typeof id !== "string" || typeof periodId !== "string") { + return res.status(400).json({ error: "Invalid ID format" }); + } + + const { period } = await getPeriodById(periodId); + + if (!period) { + return res.status(404).json({ error: "Period not found" }); + } + + if (!isAdmin(res, session)) + return res.status(403).json({ error: "Unauthorized" }); + + try { + if (req.method === "GET") { + const { room, exists, error } = await getRoom(id); + + if (error) { + return res.status(500).json({ error }); + } + + if (!exists) { + return res.status(404).json({ message: "Room not found" }); + } + + return res.status(200).json({ exists, room }); + } else if (req.method === "DELETE") { + const { error } = await deleteRoom(id); + + if (error) throw new Error(error); + + return res.status(204).end(); + } + } catch (error) { + if (error instanceof Error) { + return res.status(500).json({ error: error.message }); + } + + return res.status(500).json("Unexpected error occurred"); + } + + res.setHeader("Allow", ["GET", "DELETE"]); + res.status(405).end(`Method ${req.method} Not Allowed`); +}; + +export default handler; diff --git a/pages/api/rooms/[period-id]/index.ts b/pages/api/rooms/[period-id]/index.ts new file mode 100644 index 00000000..9ff0c5cc --- /dev/null +++ b/pages/api/rooms/[period-id]/index.ts @@ -0,0 +1,33 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { getRoomsByPeriod } from "../../../../lib/mongo/rooms"; +import { hasSession } from "../../../../lib/utils/apiChecks"; +import { authOptions } from "../../auth/[...nextauth]"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getServerSession(req, res, authOptions); + + if (!hasSession(res, session)) return; + + const periodId = req.query["period-id"]; + + try { + if (req.method === "GET") { + if (typeof periodId != "string") { + throw new Error("Not a valid period id string"); + } + const { rooms, error } = await getRoomsByPeriod(periodId); + + if (error) throw new Error(error); + + return res.status(200).json({ rooms }); + } + } catch { + res.status(500).json("An error occurred"); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(`Method ${req.method} is not allowed.`); +}; + +export default handler; diff --git a/pages/api/rooms/index.ts b/pages/api/rooms/index.ts new file mode 100644 index 00000000..eb6ba759 --- /dev/null +++ b/pages/api/rooms/index.ts @@ -0,0 +1,46 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { getServerSession } from "next-auth"; +import { createRoom, getRooms } from "../../../lib/mongo/rooms"; +import { RoomBooking } from "../../../lib/types/types"; +import { hasSession, isAdmin } from "../../../lib/utils/apiChecks"; +import { isRoomBooking } from "../../../lib/utils/validators"; +import { authOptions } from "../auth/[...nextauth]"; + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + const session = await getServerSession(req, res, authOptions); + + if (!hasSession(res, session)) return; + + try { + if (req.method === "GET") { + const { rooms, error } = await getRooms(); + + if (error) throw new Error(error); + + return res.status(200).json({ rooms }); + } + + if (req.method === "POST") { + if (!isAdmin(res, session)) return; + const room = req.body as RoomBooking; + + if (!isRoomBooking(req.body)) { + return res.status(400).json({ error: "Invalid data format" }); + } + + const { room: createdRoom, error } = await createRoom(room); + if (error) throw new Error(error); + return res.status(201).json({ + message: "Period created successfully", + data: createdRoom, + }); + } + } catch { + res.status(500).json("An error occurred"); + } + + res.setHeader("Allow", ["GET", "POST"]); + res.status(405).end(`Method ${req.method} is not allowed.`); +}; + +export default handler;