diff --git a/booking-app/app/api/bookings/export/route.ts b/booking-app/app/api/bookings/export/route.ts new file mode 100644 index 00000000..717916af --- /dev/null +++ b/booking-app/app/api/bookings/export/route.ts @@ -0,0 +1,57 @@ +import { Booking, BookingStatus } from "@/components/src/types"; +import { NextRequest, NextResponse } from "next/server"; + +import { TableNames } from "@/components/src/policy"; +import { parse } from "json2csv"; +import { serverFetchAllDataFromCollection } from "@/lib/firebase/server/adminDb"; + +export async function GET(request: NextRequest) { + const bookings = await serverFetchAllDataFromCollection( + TableNames.BOOKING, + ); + const statuses = await serverFetchAllDataFromCollection( + TableNames.BOOKING_STATUS, + ); + + // need to find corresponding status row for booking row + const idsToData: { + [key: string]: { + booking: Booking; + status: BookingStatus; + }; + } = {}; + + for (let booking of bookings) { + const calendarEventId = booking.calendarEventId; + const statusMatch = statuses.filter( + row => row.calendarEventId === calendarEventId, + )[0]; + idsToData[calendarEventId] = { + booking, + status: statusMatch, + }; + } + + const rows = Object.entries(idsToData) + .map(([_, { booking, status }]) => { + const { requestNumber, ...otherBooking } = booking; + return { requestNumber, ...otherBooking, ...status }; + }) + .sort((a, b) => a.requestNumber - b.requestNumber); + + try { + const csv = parse(rows); + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv", + "Content-Disposition": 'attachment; filename="data.csv"', + }, + }); + } catch (error) { + return NextResponse.json( + { error: "Failed to fetch CSV data" }, + { status: 400 }, + ); + } +} diff --git a/booking-app/app/api/bookings/route.ts b/booking-app/app/api/bookings/route.ts index 468ccb85..0a854178 100644 --- a/booking-app/app/api/bookings/route.ts +++ b/booking-app/app/api/bookings/route.ts @@ -9,26 +9,27 @@ import { declineUrl, getBookingToolDeployUrl, } from "@/components/src/server/ui"; +import { deleteEvent, insertEvent } from "@/components/src/server/calendars"; import { firstApproverEmails, serverApproveInstantBooking, + serverBookingContents, serverDeleteDataByCalendarEventId, serverUpdateDataByCalendarEventId, } from "@/components/src/server/admin"; -import { deleteEvent, insertEvent } from "@/components/src/server/calendars"; +import { + serverFormatDate, + toFirebaseTimestampFromString, +} from "@/components/src/client/utils/serverDate"; +import { + serverGetNextSequentialId, + serverSaveDataToFirestore, +} from "@/lib/firebase/server/adminDb"; import { DateSelectArg } from "fullcalendar"; import { TableNames } from "@/components/src/policy"; import { Timestamp } from "firebase-admin/firestore"; import { sendHTMLEmail } from "@/app/lib/sendHTMLEmail"; -import { - serverGetNextSequentialId, - serverSaveDataToFirestore, -} from "@/lib/firebase/server/adminDb"; -import { - serverFormatDate, - toFirebaseTimestampFromString, -} from "@/components/src/client/utils/serverDate"; async function createBookingCalendarEvent( selectedRooms: RoomSetting[], @@ -188,6 +189,7 @@ export async function PUT(request: NextRequest) { const { email, selectedRooms, + allRooms, bookingCalendarInfo, data, isAutoApproval, @@ -200,13 +202,20 @@ export async function PUT(request: NextRequest) { { status: 500 }, ); } + + const existingContents = await serverBookingContents(calendarEventId); + const oldRoomIds = existingContents.roomId.split(",").map(x => x.trim()); + const oldRooms = allRooms.filter((room: RoomSetting) => + oldRoomIds.includes(room.roomId + ""), + ); + const selectedRoomIds = selectedRooms .map((r: { roomId: number }) => r.roomId) .join(", "); // delete existing cal events await Promise.all( - selectedRooms.map(async room => { + oldRooms.map(async room => { await deleteEvent(room.calendarId, calendarEventId, room.roomId); }), ); diff --git a/booking-app/app/api/calendarEvents/route.ts b/booking-app/app/api/calendarEvents/route.ts index 11db3bce..4e8cfabc 100644 --- a/booking-app/app/api/calendarEvents/route.ts +++ b/booking-app/app/api/calendarEvents/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { deleteEvent, insertEvent, - updateEventPrefix, + updateCalendarEvent, } from "@/components/src/server/calendars"; import { getCalendarClient } from "@/lib/googleClient"; @@ -101,9 +101,9 @@ export async function GET(req: NextRequest) { } export async function PUT(req: NextRequest) { - const { calendarEventId, newPrefix } = await req.json(); + const { calendarEventId, newValues } = await req.json(); - if (!calendarEventId || !newPrefix) { + if (!calendarEventId || !newValues) { return NextResponse.json( { error: "Missing required fields" }, { status: 400 }, @@ -111,7 +111,7 @@ export async function PUT(req: NextRequest) { } const contents = await serverBookingContents(calendarEventId); try { - await updateEventPrefix(calendarEventId, newPrefix, contents); + await updateCalendarEvent(calendarEventId, newValues, contents); return NextResponse.json( { message: "Event updated successfully" }, { status: 200 }, diff --git a/booking-app/app/book/confirmation/page.tsx b/booking-app/app/book/confirmation/page.tsx index 4fcf5261..da2bd04e 100644 --- a/booking-app/app/book/confirmation/page.tsx +++ b/booking-app/app/book/confirmation/page.tsx @@ -1,12 +1,7 @@ // app/book/confirmation/page.tsx -import BookingForm from "@/components/src/client/routes/booking/BookingForm"; import BookingFormConfirmationPage from "@/components/src/client/routes/booking/formPages/BookingFormConfirmationPage"; import React from "react"; -const Role: React.FC = () => ( - - - -); +const Role: React.FC = () => ; export default Role; diff --git a/booking-app/app/book/form/page.tsx b/booking-app/app/book/form/page.tsx index e4c4e625..3d049666 100644 --- a/booking-app/app/book/form/page.tsx +++ b/booking-app/app/book/form/page.tsx @@ -2,14 +2,9 @@ "use client"; -import BookingForm from "@/components/src/client/routes/booking/BookingForm"; import BookingFormDetailsPage from "@/components/src/client/routes/booking/formPages/BookingFormDetailsPage"; import React from "react"; -const Form: React.FC = () => ( - - - -); +const Form: React.FC = () => ; export default Form; diff --git a/booking-app/app/book/layout.tsx b/booking-app/app/book/layout.tsx new file mode 100644 index 00000000..4b76ee25 --- /dev/null +++ b/booking-app/app/book/layout.tsx @@ -0,0 +1,14 @@ +// app/book/layout.tsx +import BookingForm from "@/components/src/client/routes/booking/BookingForm"; +import { FormContextLevel } from "@/components/src/types"; +import React from "react"; + +type LayoutProps = { + children: React.ReactNode; +}; + +const BookingLayout: React.FC = ({ children }) => ( + {children} +); + +export default BookingLayout; diff --git a/booking-app/app/book/role/page.tsx b/booking-app/app/book/role/page.tsx index 22a2ecd8..79987ee5 100644 --- a/booking-app/app/book/role/page.tsx +++ b/booking-app/app/book/role/page.tsx @@ -1,12 +1,7 @@ +import React from "react"; // app/book/form/page.tsx -import BookingForm from "@/components/src/client/routes/booking/BookingForm"; import UserRolePage from "@/components/src/client/routes/booking/formPages/UserRolePage"; -import React from "react"; -const Role: React.FC = () => ( - - - -); +const Role: React.FC = () => ; export default Role; diff --git a/booking-app/app/book/selectRoom/page.tsx b/booking-app/app/book/selectRoom/page.tsx index 62d64c06..91521adc 100644 --- a/booking-app/app/book/selectRoom/page.tsx +++ b/booking-app/app/book/selectRoom/page.tsx @@ -2,14 +2,9 @@ "use client"; -import BookingForm from "@/components/src/client/routes/booking/BookingForm"; -import SelectRoomPage from "@/components/src/client/routes/booking/formPages/SelectRoomPage"; import React from "react"; +import SelectRoomPage from "@/components/src/client/routes/booking/formPages/SelectRoomPage"; -const SelectRoom: React.FC = () => ( - - - -); +const SelectRoom: React.FC = () => ; export default SelectRoom; diff --git a/booking-app/app/edit/form/[id]/page.tsx b/booking-app/app/edit/form/[id]/page.tsx index 18cc0282..8125dc08 100644 --- a/booking-app/app/edit/form/[id]/page.tsx +++ b/booking-app/app/edit/form/[id]/page.tsx @@ -3,10 +3,14 @@ "use client"; import BookingFormDetailsPage from "@/components/src/client/routes/booking/formPages/BookingFormDetailsPage"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; const Form: React.FC = ({ params }: { params: { id: string } }) => ( - + ); export default Form; diff --git a/booking-app/app/edit/layout.tsx b/booking-app/app/edit/layout.tsx index 8617f7a7..bca0fb3b 100644 --- a/booking-app/app/edit/layout.tsx +++ b/booking-app/app/edit/layout.tsx @@ -1,5 +1,6 @@ // app/edit/layout.tsx import BookingForm from "@/components/src/client/routes/booking/BookingForm"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; type LayoutProps = { @@ -7,7 +8,7 @@ type LayoutProps = { }; const BookingLayout: React.FC = ({ children }) => ( - {children} + {children} ); export default BookingLayout; diff --git a/booking-app/app/edit/role/[id]/page.tsx b/booking-app/app/edit/role/[id]/page.tsx index c13fe9ab..d81b0722 100644 --- a/booking-app/app/edit/role/[id]/page.tsx +++ b/booking-app/app/edit/role/[id]/page.tsx @@ -1,9 +1,13 @@ +import { FormContextLevel } from "@/components/src/types"; // app/edit/role/[id].tsx import React from "react"; import UserRolePage from "@/components/src/client/routes/booking/formPages/UserRolePage"; const Role: React.FC = ({ params }: { params: { id: string } }) => ( - + ); export default Role; diff --git a/booking-app/app/edit/selectRoom/[id]/page.tsx b/booking-app/app/edit/selectRoom/[id]/page.tsx index 2078cc7f..bd0c9ce2 100644 --- a/booking-app/app/edit/selectRoom/[id]/page.tsx +++ b/booking-app/app/edit/selectRoom/[id]/page.tsx @@ -2,11 +2,15 @@ "use client"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; import SelectRoomPage from "@/components/src/client/routes/booking/formPages/SelectRoomPage"; const SelectRoom: React.FC = ({ params }: { params: { id: string } }) => ( - + ); export default SelectRoom; diff --git a/booking-app/app/modification/[id]/page.tsx b/booking-app/app/modification/[id]/page.tsx new file mode 100644 index 00000000..57794c67 --- /dev/null +++ b/booking-app/app/modification/[id]/page.tsx @@ -0,0 +1,12 @@ +// app/modification/[id].tsx + +"use client"; + +import ModificationLandingPage from "@/components/src/client/routes/modification/ModificationLandingPage"; +import React from "react"; + +const HomePage: React.FC = ({ params }: { params: { id: string } }) => ( + +); + +export default HomePage; diff --git a/booking-app/app/modification/form/[id]/page.tsx b/booking-app/app/modification/form/[id]/page.tsx new file mode 100644 index 00000000..425aa8c2 --- /dev/null +++ b/booking-app/app/modification/form/[id]/page.tsx @@ -0,0 +1,16 @@ +// app/modification/form/[id].tsx + +"use client"; + +import BookingFormDetailsPage from "@/components/src/client/routes/booking/formPages/BookingFormDetailsPage"; +import { FormContextLevel } from "@/components/src/types"; +import React from "react"; + +const Form: React.FC = ({ params }: { params: { id: string } }) => ( + +); + +export default Form; diff --git a/booking-app/app/modification/layout.tsx b/booking-app/app/modification/layout.tsx new file mode 100644 index 00000000..61b1fc17 --- /dev/null +++ b/booking-app/app/modification/layout.tsx @@ -0,0 +1,16 @@ +// app/modification/layout.tsx +import BookingForm from "@/components/src/client/routes/booking/BookingForm"; +import { FormContextLevel } from "@/components/src/types"; +import React from "react"; + +type LayoutProps = { + children: React.ReactNode; +}; + +const BookingLayout: React.FC = ({ children }) => ( + + {children} + +); + +export default BookingLayout; diff --git a/booking-app/app/modification/selectRoom/[id]/page.tsx b/booking-app/app/modification/selectRoom/[id]/page.tsx new file mode 100644 index 00000000..2acf0093 --- /dev/null +++ b/booking-app/app/modification/selectRoom/[id]/page.tsx @@ -0,0 +1,16 @@ +// app/modification/selectRoom/[id].tsx + +"use client"; + +import { FormContextLevel } from "@/components/src/types"; +import React from "react"; +import SelectRoomPage from "@/components/src/client/routes/booking/formPages/SelectRoomPage"; + +const SelectRoom: React.FC = ({ params }: { params: { id: string } }) => ( + +); + +export default SelectRoom; diff --git a/booking-app/app/walk-in/form/page.tsx b/booking-app/app/walk-in/form/page.tsx index 8b89434e..4a4f1b32 100644 --- a/booking-app/app/walk-in/form/page.tsx +++ b/booking-app/app/walk-in/form/page.tsx @@ -3,8 +3,11 @@ "use client"; import BookingFormDetailsPage from "@/components/src/client/routes/booking/formPages/BookingFormDetailsPage"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; -const Form: React.FC = () => ; +const Form: React.FC = () => ( + +); export default Form; diff --git a/booking-app/app/walk-in/layout.tsx b/booking-app/app/walk-in/layout.tsx index 3d06f4a3..99210e04 100644 --- a/booking-app/app/walk-in/layout.tsx +++ b/booking-app/app/walk-in/layout.tsx @@ -1,5 +1,6 @@ // app/walk-in/layout.tsx import BookingForm from "@/components/src/client/routes/booking/BookingForm"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; type LayoutProps = { @@ -7,7 +8,7 @@ type LayoutProps = { }; const BookingLayout: React.FC = ({ children }) => ( - {children} + {children} ); export default BookingLayout; diff --git a/booking-app/app/walk-in/role/page.tsx b/booking-app/app/walk-in/role/page.tsx index 42b7c5ee..c17a842f 100644 --- a/booking-app/app/walk-in/role/page.tsx +++ b/booking-app/app/walk-in/role/page.tsx @@ -1,7 +1,10 @@ +import { FormContextLevel } from "@/components/src/types"; // app/walk-in/form/page.tsx import React from "react"; import UserRolePage from "@/components/src/client/routes/booking/formPages/UserRolePage"; -const Role: React.FC = () => ; +const Role: React.FC = () => ( + +); export default Role; diff --git a/booking-app/app/walk-in/selectRoom/page.tsx b/booking-app/app/walk-in/selectRoom/page.tsx index 1f2108bd..c691d852 100644 --- a/booking-app/app/walk-in/selectRoom/page.tsx +++ b/booking-app/app/walk-in/selectRoom/page.tsx @@ -2,9 +2,12 @@ "use client"; +import { FormContextLevel } from "@/components/src/types"; import React from "react"; import SelectRoomPage from "@/components/src/client/routes/booking/formPages/SelectRoomPage"; -const SelectRoom: React.FC = () => ; +const SelectRoom: React.FC = () => ( + +); export default SelectRoom; diff --git a/booking-app/components/src/client/routes/admin/components/BookingActions.tsx b/booking-app/components/src/client/routes/admin/components/BookingActions.tsx index f10d1a61..3a6a8a7a 100644 --- a/booking-app/components/src/client/routes/admin/components/BookingActions.tsx +++ b/booking-app/components/src/client/routes/admin/components/BookingActions.tsx @@ -11,10 +11,12 @@ import { } from "@/components/src/server/db"; import AlertToast from "../../components/AlertToast"; +import { BookingContext } from "../../booking/bookingProvider"; import Check from "@mui/icons-material/Check"; import ConfirmDialog from "../../components/ConfirmDialog"; import { DatabaseContext } from "../../components/Provider"; import Loading from "../../components/Loading"; +import { Timestamp } from "@firebase/firestore"; import useExistingBooking from "../hooks/useExistingBooking"; import { useRouter } from "next/navigation"; @@ -23,6 +25,7 @@ interface Props { pageContext: PageContextLevel; setOptimisticStatus: (x: BookingStatusLabel) => void; status: BookingStatusLabel; + startDate: Timestamp; } enum Actions { @@ -34,6 +37,7 @@ enum Actions { FINAL_APPROVE = "Final Approve", DECLINE = "Decline", EDIT = "Edit", + MODIFICATION = "Modification", PLACEHOLDER = "", } @@ -49,13 +53,16 @@ export default function BookingActions({ calendarEventId, pageContext, setOptimisticStatus, + startDate, }: Props) { const [uiLoading, setUiLoading] = useState(false); const [selectedAction, setSelectedAction] = useState( Actions.PLACEHOLDER ); const { reloadBookings, reloadBookingStatuses } = useContext(DatabaseContext); + const { reloadExistingCalendarEvents } = useContext(BookingContext); const [showError, setShowError] = useState(false); + const [date, setDate] = useState(new Date()); const router = useRouter(); const loadExistingBookingData = useExistingBooking(); @@ -67,6 +74,10 @@ export default function BookingActions({ setShowError(true); }; + const updateActions = () => { + setDate(new Date()); + }; + const handleDialogChoice = (result: boolean) => { if (result) { const actionDetails = actions[selectedAction]; @@ -143,11 +154,21 @@ export default function BookingActions({ [Actions.EDIT]: { action: async () => { loadExistingBookingData(calendarEventId); + reloadExistingCalendarEvents(); router.push("/edit/" + calendarEventId); }, optimisticNextStatus: status, confirmation: false, }, + [Actions.MODIFICATION]: { + action: async () => { + loadExistingBookingData(calendarEventId); + reloadExistingCalendarEvents(); + router.push("/modification/" + calendarEventId); + }, + optimisticNextStatus: status, + confirmation: false, + }, // never used, just make typescript happy [Actions.PLACEHOLDER]: { action: async () => {}, @@ -162,7 +183,8 @@ export default function BookingActions({ } if ( status !== BookingStatusLabel.CHECKED_IN && - status !== BookingStatusLabel.NO_SHOW + status !== BookingStatusLabel.NO_SHOW && + startDate.toDate() > date ) { options.push(Actions.EDIT); } @@ -175,9 +197,11 @@ export default function BookingActions({ if (status === BookingStatusLabel.APPROVED) { options.push(Actions.CHECK_IN); options.push(Actions.NO_SHOW); + options.push(Actions.MODIFICATION); } else if (status === BookingStatusLabel.CHECKED_IN) { options.push(Actions.NO_SHOW); options.push(Actions.CHECK_OUT); + options.push(Actions.MODIFICATION); } else if (status === BookingStatusLabel.NO_SHOW) { options.push(Actions.CHECK_IN); } @@ -204,7 +228,7 @@ export default function BookingActions({ options.push(Actions.CANCEL); options.push(Actions.DECLINE); return options; - }, [status, paOptions]); + }, [status, paOptions, date]); const options = () => { switch (pageContext) { @@ -234,6 +258,7 @@ export default function BookingActions({ value={selectedAction} size="small" displayEmpty + onFocus={updateActions} onChange={(e) => setSelectedAction(e.target.value as Actions)} renderValue={(selected) => { if (selected === Actions.PLACEHOLDER) { diff --git a/booking-app/components/src/client/routes/admin/components/ExportDatabase.tsx b/booking-app/components/src/client/routes/admin/components/ExportDatabase.tsx new file mode 100644 index 00000000..9db181ab --- /dev/null +++ b/booking-app/components/src/client/routes/admin/components/ExportDatabase.tsx @@ -0,0 +1,56 @@ +import { Box, Button, Typography } from "@mui/material"; +import React, { useState } from "react"; + +import AlertToast from "../../components/AlertToast"; + +export default function ExportDatabase() { + const [loading, setLoading] = useState(false); + const [showError, setShowError] = useState(false); + + const onClick = async () => { + setLoading(true); + try { + const response = await fetch("/api/bookings/export"); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(new Blob([blob])); + + // Automatically trigger the download + const link = document.createElement("a"); + link.href = url; + link.download = "data.csv"; + link.click(); + + // Clean up + window.URL.revokeObjectURL(url); + } catch (ex) { + setShowError(true); + console.error("error exporting database", ex); + } finally { + setLoading(false); + } + }; + + return ( + + Export Database +

Export database booking contents as a downloadable CSV file

+ + + + setShowError(false)} + /> +
+ ); +} diff --git a/booking-app/components/src/client/routes/admin/components/Settings.tsx b/booking-app/components/src/client/routes/admin/components/Settings.tsx index fb6e176e..4add3488 100644 --- a/booking-app/components/src/client/routes/admin/components/Settings.tsx +++ b/booking-app/components/src/client/routes/admin/components/Settings.tsx @@ -5,6 +5,7 @@ import { AdminUsers } from "./AdminUsers"; import { BannedUsers } from "./Ban"; import BookingTypes from "./BookingTypes"; import { Departments } from "./Departments"; +import ExportDatabase from "./ExportDatabase"; import FinalApproverSetting from "./PolicySettings"; import Grid from "@mui/material/Unstable_Grid2"; import { Liaisons } from "./Liaisons"; @@ -20,6 +21,7 @@ const tabs = [ { label: "Ban", id: "ban" }, { label: "Booking Types", id: "bookingTypes" }, { label: "Policy Settings", id: "policy" }, + { label: "Export", id: "export" }, ]; export default function Settings() { @@ -49,6 +51,7 @@ export default function Settings() { {tab === "departments" && } {tab === "bookingTypes" && } {tab === "policy" && } + {tab === "export" && } ); diff --git a/booking-app/components/src/client/routes/booking/BookingForm.tsx b/booking-app/components/src/client/routes/booking/BookingForm.tsx index f3c2b39c..3b02978a 100644 --- a/booking-app/components/src/client/routes/booking/BookingForm.tsx +++ b/booking-app/components/src/client/routes/booking/BookingForm.tsx @@ -1,12 +1,19 @@ "use client"; + import React, { useEffect } from "react"; + +import { FormContextLevel } from "@/components/src/types"; import { Header } from "./components/Header"; type BookingFormProps = { children: React.ReactNode; + formContext: FormContextLevel; }; -export default function BookingForm({ children }: BookingFormProps) { +export default function BookingForm({ + children, + formContext, +}: BookingFormProps) { useEffect(() => { console.log( "DEPLOY MODE ENVIRONMENT:", @@ -44,7 +51,7 @@ export default function BookingForm({ children }: BookingFormProps) { return (
-
+
{children}
); diff --git a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx index 264d454c..58c70d88 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingFormMediaServices.tsx @@ -5,7 +5,7 @@ import { } from "../../../../policy"; import { Checkbox, FormControlLabel, Switch } from "@mui/material"; import { Control, Controller, UseFormTrigger } from "react-hook-form"; -import { Inputs, MediaServices } from "../../../../types"; +import { FormContextLevel, Inputs, MediaServices } from "../../../../types"; import React, { useContext, useMemo } from "react"; import { BookingContext } from "../bookingProvider"; @@ -24,7 +24,7 @@ interface Props { trigger: UseFormTrigger; showMediaServices: boolean; setShowMediaServices: any; - isWalkIn: boolean; + formContext: FormContextLevel; } export default function BookingFormMediaServices(props: Props) { @@ -34,11 +34,16 @@ export default function BookingFormMediaServices(props: Props) { trigger, showMediaServices, setShowMediaServices, - isWalkIn, + formContext, } = props; const { selectedRooms } = useContext(BookingContext); const roomIds = selectedRooms.map((room) => room.roomId); + const limitedContexts = [ + FormContextLevel.WALK_IN, + FormContextLevel.MODIFICATION, + ]; + const checkboxes = useMemo(() => { const options: MediaServices[] = []; const checkRoomMediaServices = (list: number[]) => @@ -74,7 +79,7 @@ export default function BookingFormMediaServices(props: Props) { if (!e.target.checked) { // de-select boxes if switch says "no media services" field.onChange(""); - } else if (isWalkIn) { + } else if (limitedContexts.includes(formContext)) { field.onChange(MediaServices.CHECKOUT_EQUIPMENT); } @@ -88,7 +93,7 @@ export default function BookingFormMediaServices(props: Props) { > ); - if (isWalkIn) { + if (limitedContexts.includes(formContext)) { return (
diff --git a/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx b/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx index e2a2916b..92e72998 100644 --- a/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx +++ b/booking-app/components/src/client/routes/booking/components/BookingStatusBar.tsx @@ -3,20 +3,22 @@ import { Check, ChevronLeft, ChevronRight } from "@mui/icons-material"; import React, { useContext } from "react"; import { BookingContext } from "../bookingProvider"; +import { FormContextLevel } from "@/components/src/types"; import Grid from "@mui/material/Unstable_Grid2"; import useCalculateOverlap from "../hooks/useCalculateOverlap"; import useCheckAutoApproval from "../hooks/useCheckAutoApproval"; import { usePathname } from "next/navigation"; interface Props { + formContext: FormContextLevel; goBack: () => void; goNext: () => void; + hideBackButton: boolean; hideNextButton: boolean; } -export default function BookingStatusBar(props: Props) { - const pathname = usePathname(); - const isWalkIn = pathname.includes("/walk-in"); +export default function BookingStatusBar({ formContext, ...props }: Props) { + const isWalkIn = formContext === FormContextLevel.WALK_IN; const { isAutoApproval, errorMessage } = useCheckAutoApproval(isWalkIn); const { bookingCalendarInfo, selectedRooms, isBanned, needsSafetyTraining } = useContext(BookingContext); @@ -29,14 +31,16 @@ export default function BookingStatusBar(props: Props) { // order of precedence matters // unfixable blockers > fixable blockers > non-blockers - const state: { - message: React.ReactNode; - severity: AlertColor; - icon?: React.ReactNode; - variant?: "filled" | "standard" | "outlined"; - btnDisabled: boolean; - btnDisabledMessage?: string; - } = (() => { + const state: + | { + message: React.ReactNode; + severity: AlertColor; + icon?: React.ReactNode; + variant?: "filled" | "standard" | "outlined"; + btnDisabled: boolean; + btnDisabledMessage?: string; + } + | undefined = (() => { if (isBanned) return { btnDisabled: true, @@ -79,7 +83,7 @@ export default function BookingStatusBar(props: Props) { severity: "error", }; } - if (isAutoApproval) + if (isAutoApproval && formContext !== FormContextLevel.MODIFICATION) return { btnDisabled: false, btnDisabledMessage: null, @@ -87,24 +91,27 @@ export default function BookingStatusBar(props: Props) { severity: "success", icon: , }; - - return { - btnDisabled: false, - btnDisabledMessage: null, - message: ( -

- This request will require approval.{" "} - - Why? - -

- ), - severity: "warning", - }; + else if (formContext !== FormContextLevel.MODIFICATION) + return { + btnDisabled: false, + btnDisabledMessage: null, + message: ( +

+ This request will require approval.{" "} + + Why? + +

+ ), + severity: "warning", + }; + else { + return undefined; + } })(); const [disabled, disabledMessage] = (() => { - if (state.btnDisabled) { + if (state?.btnDisabled ?? false) { return [true, state.btnDisabledMessage]; } if (bookingCalendarInfo == null) { @@ -122,16 +129,18 @@ export default function BookingStatusBar(props: Props) { paddingRight="18px" > - + {!props.hideBackButton && ( + + )} - {showAlert && ( + {showAlert && state && ( { +interface Props { + handleChange: (x: Date) => void; + formContext: FormContextLevel; +} + +export const CalendarDatePicker = ({ handleChange, formContext }: Props) => { const [date, setDate] = useState(dayjs(new Date())); const { bookingCalendarInfo } = useContext(BookingContext); @@ -21,14 +27,19 @@ export const CalendarDatePicker = ({ handleChange }) => { } }, []); + if (formContext === FormContextLevel.WALK_IN) { + return
; + } + return ( diff --git a/booking-app/components/src/client/routes/booking/components/CalendarEventBlock.tsx b/booking-app/components/src/client/routes/booking/components/CalendarEventBlock.tsx index f9786b11..92ca0fbf 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarEventBlock.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarEventBlock.tsx @@ -1,6 +1,5 @@ import { Box, IconButton } from "@mui/material"; -import { BookingStatusLabel } from "../../../../types"; import { Close } from "@mui/icons-material"; import { EventContentArg } from "@fullcalendar/core"; import React from "react"; @@ -11,28 +10,31 @@ export const UNKNOWN_BLOCK_TITLE = "Unavailable"; interface Props { bgcolor: string; - isFirst: boolean; - isLast: boolean; - isNew: boolean; + isnew: boolean; + numrooms: number; } -const Block = styled(Box)( - ({ theme, bgcolor, isFirst, isLast, isNew }) => ({ - backgroundColor: bgcolor || theme.palette.primary.main, - border: `2px solid ${bgcolor || theme.palette.primary.main}`, - borderRadius: "4px", - outline: "none", - height: "100%", - width: isNew && !isLast ? "105%" : "100%", - overflowX: "hidden", - cursor: bgcolor ? "unset" : "pointer", - padding: "2px 4px", - position: "relative", - zIndex: isNew ? 99 : 2, - borderTopLeftRadius: isNew && !isFirst ? 0 : 4, - borderBottomLeftRadius: isNew && !isFirst ? 0 : 4, - }) -); +const Block = styled(Box)(({ theme, bgcolor, isnew, numrooms }) => ({ + backgroundColor: bgcolor || theme.palette.primary.main, + border: `2px solid ${bgcolor || theme.palette.primary.main}`, + borderRadius: "4px", + outline: "none", + height: "100%", + width: isnew + ? `calc((100% + 2px) * ${numrooms} + ${numrooms - 1}px)` + : "100%", + overflowX: "hidden", + cursor: bgcolor ? "unset" : "pointer", + padding: "2px 4px", + position: "relative", + zIndex: isnew ? 99 : 2, +})); + +const Empty = styled(Box)` + width: "100%"; + height: "100%"; + position: relative; +`; const CloseButton = styled(IconButton)(({ theme }) => ({ position: "absolute", @@ -52,8 +54,9 @@ export default function CalendarEventBlock(eventInfo: EventContentArg) { ? eventInfo.event.url.split(":") : ["0", "0"]; const index = Number(params[0]); - const maxIndex = Number(params[1]); - const isLast = index === maxIndex; + const numRooms = Number(params[1]); + + const isLast = index === numRooms - 1; const isOneColumn = index === 0 && isLast; const backgroundColor = () => { @@ -63,17 +66,22 @@ export default function CalendarEventBlock(eventInfo: EventContentArg) { return "rgba(72, 196, 77, 1)"; }; - const hideTitle = isNew && index !== 0 && !isOneColumn; + if (isNew && index !== 0) { + return ( + + {isNew && isLast && ( + + + + )} + + ); + } return ( - - {!hideTitle && {title}} - {isNew && isLast && ( + + {title} + {isNew && isOneColumn && ( diff --git a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx index 9853a86a..691acba8 100644 --- a/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx +++ b/booking-app/components/src/client/routes/booking/components/CalendarVerticalResource.tsx @@ -1,10 +1,16 @@ import { Box, Typography, useMediaQuery, useTheme } from "@mui/material"; -import { CalendarApi, DateSelectArg, EventClickArg } from "@fullcalendar/core"; -import { CalendarEvent, RoomSetting } from "../../../../types"; +import { + CalendarApi, + DateSelectArg, + EventClickArg, + EventDropArg, +} from "@fullcalendar/core"; import CalendarEventBlock, { NEW_TITLE_TAG } from "./CalendarEventBlock"; +import { FormContextLevel, RoomSetting } from "../../../../types"; import React, { useContext, useEffect, useMemo, useRef } from "react"; import { BookingContext } from "../bookingProvider"; +import { EventResizeDoneArg } from "fullcalendar"; import FullCalendar from "@fullcalendar/react"; import googleCalendarPlugin from "@fullcalendar/google-calendar"; import interactionPlugin from "@fullcalendar/interaction"; // for selectable @@ -13,7 +19,7 @@ import { styled } from "@mui/system"; interface Props { calendarEventId?: string; - isEdit: boolean; + formContext: FormContextLevel; rooms: RoomSetting[]; dateView: Date; } @@ -72,7 +78,7 @@ const Empty = styled(Box)(({ theme }) => ({ export default function CalendarVerticalResource({ calendarEventId, - isEdit, + formContext, rooms, dateView, }: Props) { @@ -117,7 +123,10 @@ export default function CalendarVerticalResource({ resourceId: room.roomId + "", title: NEW_TITLE_TAG, overlap: true, - url: `${index}:${rooms.length - 1}`, // some hackiness to let us render multiple events visually as one big block + durationEditable: true, + startEditable: formContext !== FormContextLevel.MODIFICATION, + groupId: "new", + url: `${index}:${rooms.length}`, // some hackiness to let us render multiple events visually as one big block })); }, [bookingCalendarInfo, rooms]); @@ -164,28 +173,52 @@ export default function CalendarVerticalResource({ return el.overlap; }; + // clicking on created event should delete it + // only if not in MODIFICATION mode const handleEventClick = (info: EventClickArg) => { - if (info.event.title.includes(NEW_TITLE_TAG)) { + if ( + info.event.title.includes(NEW_TITLE_TAG) && + formContext !== FormContextLevel.MODIFICATION + ) { setBookingCalendarInfo(null); } }; + // if change event duration via dragging edges or drag event block to move + const handleEventEdit = (info: EventResizeDoneArg | EventDropArg) => { + setBookingCalendarInfo({ + startStr: info.event.startStr, + start: info.event.start, + endStr: info.event.endStr, + end: info.event.end, + allDay: false, + jsEvent: info.jsEvent, + view: info.view, + }); + }; + // for editing an existing reservation const existingCalEventsFiltered = useMemo(() => { - if (!isEdit || calendarEventId == null || calendarEventId.length === 0) + if ( + (formContext !== FormContextLevel.EDIT && + formContext !== FormContextLevel.MODIFICATION) || + calendarEventId == null || + calendarEventId.length === 0 + ) return existingCalendarEvents; // based on how we format the id in fetchCalendarEvents return existingCalendarEvents.filter( (event) => event.id.split(":")[0] !== calendarEventId ); - }, [existingCalendarEvents, isEdit]); + }, [existingCalendarEvents, formContext]); if (rooms.length === 0) { return ( - Select spaces to view their availability and choose a time slot + Select spaces to view their availability, then click and drag to + choose a time slot ); @@ -201,7 +234,7 @@ export default function CalendarVerticalResource({ googleCalendarPlugin, interactionPlugin, ]} - selectable={true} + selectable={formContext !== FormContextLevel.MODIFICATION} select={handleEventSelect} selectAllow={handleEventSelecting} selectOverlap={handleSelectOverlap} @@ -214,6 +247,8 @@ export default function CalendarVerticalResource({ info.jsEvent.preventDefault(); handleEventClick(info); }} + eventResize={handleEventEdit} + eventDrop={handleEventEdit} headerToolbar={false} slotMinTime="09:00:00" slotMaxTime="21:00:00" diff --git a/booking-app/components/src/client/routes/booking/components/FormInput.tsx b/booking-app/components/src/client/routes/booking/components/FormInput.tsx index c6a5eb2c..6e3c7415 100644 --- a/booking-app/components/src/client/routes/booking/components/FormInput.tsx +++ b/booking-app/components/src/client/routes/booking/components/FormInput.tsx @@ -1,4 +1,9 @@ -import { AttendeeAffiliation, Inputs, Role } from "../../../../types"; +import { + AttendeeAffiliation, + FormContextLevel, + Inputs, + Role, +} from "../../../../types"; import { BookingFormAgreementCheckbox, BookingFormDropdown, @@ -54,15 +59,10 @@ const Container = styled(Box)(({ theme }) => ({ interface Props { calendarEventId?: string; - isEdit: boolean; - isWalkIn: boolean; + formContext: FormContextLevel; } -export default function FormInput({ - calendarEventId, - isEdit, - isWalkIn, -}: Props) { +export default function FormInput({ calendarEventId, formContext }: Props) { const { userEmail, settings } = useContext(DatabaseContext); const { role, @@ -75,7 +75,7 @@ export default function FormInput({ setFormData, } = useContext(BookingContext); const router = useRouter(); - const registerEvent = useSubmitBooking(isEdit, isWalkIn); + const registerEvent = useSubmitBooking(formContext); const { isAutoApproval } = useCheckAutoApproval(); const { @@ -109,6 +109,8 @@ export default function FormInput({ mode: "onBlur", }); + const isWalkIn = formContext === FormContextLevel.WALK_IN; + // different from other switches b/c mediaServices doesn't have yes/no column in DB const [showMediaServices, setShowMediaServices] = useState(false); @@ -188,371 +190,420 @@ export default function FormInput({ router.push("/book/confirmation"); }; - return ( -
- - + const fullFormFields = ( + <> +
+ + + + -
-
- - - - + - + +
- -
+ {watch("role") === "Student" && ( +
+ - {watch("role") === "Student" && ( -
- + - + +
+ )} - -
- )} +
+ + + x.bookingType) + .sort((a, b) => a.localeCompare(b))} + {...{ control, errors, trigger }} + /> + + + Non-NYU guests will need to be sponsored through JRNY. For more + information about visitor, vendor, and affiliate access, + + click here + + . +

+ } + {...{ control, errors, trigger }} + /> +
-
- - - x.bookingType) - .sort((a, b) => a.localeCompare(b))} - {...{ control, errors, trigger }} - /> - - + {!isWalkIn && ( +
+ - Non-NYU guests will need to be sponsored through JRNY. For - more information about visitor, vendor, and affiliate access, + If your reservation is in 233 or 1201 and requires a specific + room setup that is different from the standard configuration, + it is the reservation holder’s responsibility to submit a - click here + work order with CBS - . + .
+ It is also the reservation holder's responsibility to ensure + the room is reset after use.

} {...{ control, errors, trigger }} /> -
- -
- {!isWalkIn && ( -
- - If your reservation is in 233 or 1201 and requires a - specific room setup that is different from the standard - configuration, it is the reservation holder’s - responsibility to submit a - - work order with CBS - - .
- It is also the reservation holder's responsibility to - ensure the room is reset after use. -

- } + {watch("roomSetup") === "yes" && ( + <> + - {watch("roomSetup") === "yes" && ( - <> - - - - )} -
- )} -
- - {watch("mediaServices") !== undefined && - watch("mediaServices").length > 0 && ( - - If you selected any of the Media Services above, please - describe your needs in detail. -
- If you need to check out equipment, you can check our - inventory and include your request below. (Ie. 2x Small - Mocap Suits) -
-{" "} - - Inventory for Black Box 220 and Ballrooms 221-224 - -
-{" "} - - Inventory for Garage 103 - -
-

- } - {...{ control, errors, trigger }} - /> - )} -
- {!isWalkIn && ( -
- - It is required for the reservation holder to pay and - arrange for CBS cleaning services if the event includes - catering -

- } - required={false} - {...{ control, errors, trigger }} - /> - {watch("catering") === "yes" && ( - <> - - - - )} -
- )} - {!isWalkIn && ( -
- - Only for large events with 75+ attendees, and bookings in - The Garage where the Willoughby entrance will be in use. - Once your booking is confirmed, it is your responsibility - to hire Campus Safety for your event. If appropriate, - please coordinate with your departmental Scheduling - Liaison to hire Campus Safety, as there is a fee. - - Click for Campus Safety Form - -

- } + - {watch("hireSecurity") === "yes" && ( - - )} -
+ )} -
- - {!isWalkIn && ( -
- + )} +
+ + {watch("mediaServices") !== undefined && + watch("mediaServices").length > 0 && ( + - {" "} - I confirm receipt of the + If you selected any of the Media Services above, please + describe your needs in detail. +
+ If you need to check out equipment, you can check our + inventory and include your request below. (Ie. 2x Small + Mocap Suits) +
-{" "} - 370J Pre-Event Checklist + Inventory for Black Box 220 and Ballrooms 221-224 - and acknowledge that it is my responsibility to setup - various event services as detailed within the checklist. - While the 370J Operations staff do setup cleaning services - through CBS, they do not facilitate hiring security, - catering, and arranging room setup services. -

- } - /> - - I agree to reset any and all requested rooms and common - spaces to their original state at the end of the event, - including cleaning and furniture return, and will notify - building staff of any problems, damage, or other concerns - affecting the condition and maintenance of the reserved - space. I understand that if I do not reset the room, I will - lose reservation privileges. -

- } - /> - - I have read the +
-{" "} - Booking Policy for 370 Jay Street Shared Spaces + Inventory for Garage 103 - and agree to follow all policies outlined. I understand that - I may lose access to spaces if I break this agreement. +

} + {...{ control, errors, trigger }} /> -
- )} - - + )} +
+ {!isWalkIn && ( +
+ + It is required for the reservation holder to pay and arrange + for CBS cleaning services if the event includes catering +

+ } + required={false} + {...{ control, errors, trigger }} + /> + {watch("catering") === "yes" && ( + <> + + + + )} +
+ )} + {!isWalkIn && ( +
+ + Only for large events with 75+ attendees, and bookings in The + Garage where the Willoughby entrance will be in use. Once your + booking is confirmed, it is your responsibility to hire Campus + Safety for your event. If appropriate, please coordinate with + your departmental Scheduling Liaison to hire Campus Safety, as + there is a fee. + + Click for Campus Safety Form + +

+ } + {...{ control, errors, trigger }} + /> + {watch("hireSecurity") === "yes" && ( + + )} +
+ )} + + + {!isWalkIn && ( +
+ + {" "} + I confirm receipt of the + + 370J Media Commons Event Service Rates/Additional Information + + document that contains information regarding event needs and + services. I acknowledge that it is my responsibility to set up + catering and Campus Media if needed for my reservation. I + understand that the 370J Media Commons Operations staff will + setup CBS cleaning services, facilitate hiring security, and + arrange room setup services if needed for my reservation. +

+ } + /> + + I agree to reset all rooms and common spaces I have used to + their original state at the end of my reservation, including + returning equipment, resetting furniture, and cleaning up after + myself. I will notify Media Commons staff of any problems, + damage, or other concerns affecting the condition and + maintenance of the reserved space. I understand that if I do not + reset the room, I may lose access to the Media Commons. +

+ } + /> + + I have read the + + Booking Policy for 370J Media Commons + + and agree to follow all policies outlined. I understand that I + may lose access to the Media Commons if I break this agreement. +

+ } + /> +
+ )} + + + ); + + const modificationFormFields = ( + <> +
+ +
+
+
+ + {watch("mediaServices") !== undefined && + watch("mediaServices").length > 0 && ( + + )} +
+
+ + + ); + + let formFields = <>; + switch (formContext) { + case FormContextLevel.MODIFICATION: + formFields = modificationFormFields; + break; + default: + formFields = fullFormFields; + } + + return ( +
+ + +
{formFields}
); diff --git a/booking-app/components/src/client/routes/booking/components/Header.tsx b/booking-app/components/src/client/routes/booking/components/Header.tsx index 05372a47..5d32a81b 100644 --- a/booking-app/components/src/client/routes/booking/components/Header.tsx +++ b/booking-app/components/src/client/routes/booking/components/Header.tsx @@ -1,9 +1,10 @@ import { Box, useScrollTrigger } from "@mui/material"; +import React, { useMemo } from "react"; import { usePathname, useRouter } from "next/navigation"; import BookingFormStepper from "./Stepper"; import BookingStatusBar from "./BookingStatusBar"; -import React from "react"; +import { FormContextLevel } from "@/components/src/types"; import { styled } from "@mui/system"; const StickyScroll = styled(Box)` @@ -16,7 +17,11 @@ const StickyScroll = styled(Box)` transition: box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; `; -export const Header = () => { +interface Props { + formContext: FormContextLevel; +} + +export const Header = ({ formContext }: Props) => { const router = useRouter(); const pathname = usePathname(); @@ -25,60 +30,41 @@ export const Header = () => { threshold: 100, }); - // /book, /walk-in, /edit/ - if (/^\/(book|walk-in|edit\/[^\/]+)$/.test(pathname)) { + // /book, /walk-in, /edit/, /modification/ + if (/^\/(book|walk-in|(?:edit|modification)\/[^\/]+)$/.test(pathname)) { return null; } const goBack = (() => { - const match = pathname.match( - /^(\/(book|walk-in|edit))\/(selectRoom|form)(\/[a-zA-Z0-9_-]+)?$/ - ); - - if (match) { - const [, basePath, , step, idSegment] = match; - const id = idSegment || ""; // If there's an ID, use it; otherwise, use an empty string. + const step = pathname.split("/")[2]; // Get the step + const idSegment = pathname.split("/")[3] || ""; // Get the id if it exists - switch (step) { - case "selectRoom": - return () => router.push(`${basePath}/role${id}`); - case "form": - return () => router.push(`${basePath}/selectRoom${id}`); - default: - return () => {}; - } - } else { - return () => {}; + switch (step) { + case "selectRoom": + if (formContext === FormContextLevel.MODIFICATION) return () => {}; + return () => router.push(`${formContext}/role/${idSegment}`); + case "form": + return () => router.push(`${formContext}/selectRoom/${idSegment}`); + default: + return () => {}; } })(); const goNext = (() => { - const match = pathname.match( - /^(\/(book|walk-in|edit))\/(selectRoom)(\/[a-zA-Z0-9_-]+)?$/ - ); + const step = pathname.split("/")[2]; // Get the step + const idSegment = pathname.split("/")[3] || ""; // Get the id segment if it exists - if (match) { - const [, basePath, , step, idSegment] = match; - const id = idSegment || ""; // If there's an ID, use it; otherwise, use an empty string. - - switch (step) { - case "selectRoom": - return () => router.push(`${basePath}/form${id}`); - default: - return () => {}; - } - } else { - return () => {}; + if (step === "selectRoom") { + return () => router.push(`${formContext}/form/${idSegment}`); } + return () => {}; })(); - // /book/form, /walk-in/form, /edit/form/ - const hideNextButton = - /^(\/(book|walk-in|edit))\/form(\/[a-zA-Z0-9_-]+)?$/.test(pathname); - - // /book/selectRoom, /book/form, /walk-in/selectRoom, /walk-in/form, /edit/selectRoom/, /edit/form/ - const showStatusBar = - /^(\/(book|walk-in|edit))\/(selectRoom|form)(\/[^\/]+)?$/.test(pathname); + const hideBackButton = + formContext === FormContextLevel.MODIFICATION && + pathname.includes("/selectRoom"); + const hideNextButton = pathname.includes("/form"); + const showStatusBar = pathname.match(/\/(selectRoom|form)/); return ( { } >
- + {showStatusBar && ( - + )}
diff --git a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx index d8f00ccb..1e21709a 100644 --- a/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx +++ b/booking-app/components/src/client/routes/booking/components/SelectRooms.tsx @@ -1,21 +1,21 @@ import { Checkbox, FormControlLabel, FormGroup } from "@mui/material"; +import { FormContextLevel, RoomSetting } from "../../../../types"; import { MOCAP_ROOMS, WALK_IN_CAN_BOOK_TWO } from "../../../../policy"; import React, { useContext, useMemo } from "react"; import { BookingContext } from "../bookingProvider"; import { ConfirmDialogControlled } from "../../components/ConfirmDialog"; -import { RoomSetting } from "../../../../types"; interface Props { allRooms: RoomSetting[]; - isWalkIn: boolean; + formContext: FormContextLevel; selected: RoomSetting[]; setSelected: any; } export const SelectRooms = ({ allRooms, - isWalkIn, + formContext, selected, setSelected, }: Props) => { @@ -34,7 +34,8 @@ export const SelectRooms = ({ // walk-ins can only book 1 room unless it's 2 ballroom bays (221-224) const isDisabled = (roomId: number) => { - if (!isWalkIn || selectedIds.length === 0) return false; + if (formContext !== FormContextLevel.WALK_IN || selectedIds.length === 0) + return false; if (selectedIds.includes(roomId)) return false; if (selectedIds.length >= 2) return true; if ( diff --git a/booking-app/components/src/client/routes/booking/components/Stepper.tsx b/booking-app/components/src/client/routes/booking/components/Stepper.tsx index 3e7b30a3..918c5f2f 100644 --- a/booking-app/components/src/client/routes/booking/components/Stepper.tsx +++ b/booking-app/components/src/client/routes/booking/components/Stepper.tsx @@ -1,37 +1,47 @@ import { Box, Step, StepLabel, Stepper } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; +import { FormContextLevel } from "@/components/src/types"; import { usePathname } from "next/navigation"; -const steps = ["Affiliation", "Select Time", "Details", "Confirmation"]; +interface Props { + formContext: FormContextLevel; +} + +const routeToStepNames = { + role: "Affiliation", + selectRoom: "Select Time", + form: "Details", + confirmation: "Confirmation", +}; -export default function BookingFormStepper() { +export default function BookingFormStepper({ formContext }: Props) { const pathname = usePathname(); const [activeStep, setActiveStep] = useState(0); - useEffect(() => { - switch (pathname) { - case "/walk-in/role": - case "/book/role": - setActiveStep(0); - break; - case "/walk-in/selectRoom": - case "/book/selectRoom": - setActiveStep(1); - break; - case "/walk-in/form": - case "/book/form": - setActiveStep(2); - break; - case "/walk-in/confirmation": - case "/book/confirmation": - setActiveStep(3); - break; - default: - setActiveStep(0); + const steps = useMemo(() => { + if (formContext === FormContextLevel.MODIFICATION) { + return [ + routeToStepNames.selectRoom, + routeToStepNames.form, + routeToStepNames.confirmation, + ]; + } else { + return [ + routeToStepNames.role, + routeToStepNames.selectRoom, + routeToStepNames.form, + routeToStepNames.confirmation, + ]; } }, [pathname]); + useEffect(() => { + const step = pathname.split("/")[2]; // role, selectRoom, form + const index = steps.indexOf(routeToStepNames[step]) ?? 0; + setActiveStep(index); + }, [pathname, formContext]); + return ( diff --git a/booking-app/components/src/client/routes/booking/formPages/BookingFormDetailsPage.tsx b/booking-app/components/src/client/routes/booking/formPages/BookingFormDetailsPage.tsx index 7709bb8b..4e7de2b6 100644 --- a/booking-app/components/src/client/routes/booking/formPages/BookingFormDetailsPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/BookingFormDetailsPage.tsx @@ -1,5 +1,6 @@ "use client"; +import { FormContextLevel } from "@/components/src/types"; import FormInput from "../components/FormInput"; import Grid from "@mui/material/Unstable_Grid2"; import React from "react"; @@ -7,21 +8,19 @@ import useCheckFormMissingData from "../hooks/useCheckFormMissingData"; interface Props { calendarEventId?: string; - isEdit?: boolean; - isWalkIn?: boolean; + formContext?: FormContextLevel; } export default function BookingFormDetailsPage({ calendarEventId, - isEdit = false, - isWalkIn = false, + formContext = FormContextLevel.FULL_FORM, }: Props) { useCheckFormMissingData(); return ( - + ); diff --git a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx index 8d416515..da37bf92 100644 --- a/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/SelectRoomPage.tsx @@ -7,6 +7,7 @@ import { BookingContext } from "../bookingProvider"; import { CalendarDatePicker } from "../components/CalendarDatePicker"; import CalendarVerticalResource from "../components/CalendarVerticalResource"; import { DatabaseContext } from "../../components/Provider"; +import { FormContextLevel } from "@/components/src/types"; import Grid from "@mui/material/Unstable_Grid2"; import { SelectRooms } from "../components/SelectRooms"; import { WALK_IN_ROOMS } from "@/components/src/policy"; @@ -14,14 +15,12 @@ import useCheckFormMissingData from "../hooks/useCheckFormMissingData"; interface Props { calendarEventId?: string; - isEdit?: boolean; - isWalkIn?: boolean; + formContext?: FormContextLevel; } export default function SelectRoomPage({ calendarEventId, - isEdit = false, - isWalkIn = false, + formContext = FormContextLevel.FULL_FORM, }: Props) { const { roomSettings } = useContext(DatabaseContext); const { selectedRooms, setSelectedRooms } = useContext(BookingContext); @@ -30,6 +29,8 @@ export default function SelectRoomPage({ const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + const isWalkIn = formContext === FormContextLevel.WALK_IN; + const roomsToShow = useMemo(() => { return !isWalkIn ? roomSettings @@ -44,12 +45,15 @@ export default function SelectRoomPage({ spacing={{ xs: 0, md: 2 }} alignItems={{ xs: "center", md: "unset" }} > - {!isWalkIn && } + Spaces @@ -60,7 +64,7 @@ export default function SelectRoomPage({
diff --git a/booking-app/components/src/client/routes/booking/formPages/UserRolePage.tsx b/booking-app/components/src/client/routes/booking/formPages/UserRolePage.tsx index 4397ae4d..62f48a9a 100644 --- a/booking-app/components/src/client/routes/booking/formPages/UserRolePage.tsx +++ b/booking-app/components/src/client/routes/booking/formPages/UserRolePage.tsx @@ -1,7 +1,7 @@ "use client"; import { Box, Button, Typography } from "@mui/material"; -import { Department, Role } from "../../../../types"; +import { Department, FormContextLevel, Role } from "../../../../types"; import React, { useContext, useEffect } from "react"; import { BookingContext } from "../bookingProvider"; @@ -28,14 +28,12 @@ const Container = styled(Box)(({ theme }) => ({ interface Props { calendarEventId?: string; - isEdit?: boolean; - isWalkIn?: boolean; + formContext?: FormContextLevel; } export default function UserRolePage({ calendarEventId, - isEdit = false, - isWalkIn = false, + formContext = FormContextLevel.FULL_FORM, }: Props) { const { role, department, setDepartment, setRole } = useContext(BookingContext); @@ -50,10 +48,14 @@ export default function UserRolePage({ }, []); const handleNextClick = () => { - if (isEdit && calendarEventId != null) { + if (formContext === FormContextLevel.EDIT && calendarEventId != null) { router.push("/edit/selectRoom/" + calendarEventId); } else { - router.push(isWalkIn ? "/walk-in/selectRoom" : "/book/selectRoom"); + router.push( + formContext === FormContextLevel.WALK_IN + ? "/walk-in/selectRoom" + : "/book/selectRoom" + ); } }; diff --git a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx index bc2ac25e..81fa6976 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCalculateOverlap.tsx @@ -11,8 +11,10 @@ export default function useCalculateOverlap() { const isOverlapping = useCallback(() => { if (bookingCalendarInfo == null) return false; - // check if /edit path and pull calendarEventId if true - const match = pathname.match(/^\/edit\/selectRoom\/([a-zA-Z0-9_-]+)$/); + // check if /edit or /modification path and pull calendarEventId if true + const match = pathname.match( + /^\/(?:edit|modification)\/selectRoom\/([a-zA-Z0-9_-]+)$/ + ); let calendarEventId: string; if (match) { calendarEventId = match[1]; diff --git a/booking-app/components/src/client/routes/booking/hooks/useCheckFormMissingData.tsx b/booking-app/components/src/client/routes/booking/hooks/useCheckFormMissingData.tsx index 9f4317c2..9fc25f7f 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useCheckFormMissingData.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useCheckFormMissingData.tsx @@ -34,7 +34,10 @@ export default function useCheckFormMissingData() { } if (isMissing) { - router.push("/" + pathname.split("/")[1]); + const base = pathname.split("/")[1]; + const id = pathname.split("/")[3] ?? ""; + console.log("MISSING ID", id, pathname); + router.push(`/${base}/${id}`); } }, []); } diff --git a/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx b/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx index 5a696bdc..6526c39d 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx @@ -1,14 +1,20 @@ +import { FormContextLevel, Inputs, PagePermission } from "../../../../types"; import { useCallback, useContext } from "react"; import { BookingContext } from "../bookingProvider"; import { DatabaseContext } from "../../components/Provider"; -import { Inputs } from "../../../../types"; import { useRouter } from "next/navigation"; -export default function useSubmitBooking(isEdit: boolean, isWalkIn: boolean) { +export default function useSubmitBooking(formContext: FormContextLevel) { const router = useRouter(); - const { liaisonUsers, userEmail, reloadBookings, reloadBookingStatuses } = - useContext(DatabaseContext); + const { + liaisonUsers, + userEmail, + reloadBookings, + reloadBookingStatuses, + pagePermission, + roomSettings, + } = useContext(DatabaseContext); const { bookingCalendarInfo, department, @@ -21,6 +27,10 @@ export default function useSubmitBooking(isEdit: boolean, isWalkIn: boolean) { setSubmitting, } = useContext(BookingContext); + const isEdit = formContext === FormContextLevel.EDIT; + const isWalkIn = formContext === FormContextLevel.WALK_IN; + const isModification = formContext === FormContextLevel.MODIFICATION; + const registerEvent = useCallback( async (data: Inputs, isAutoApproval: boolean, calendarEventId?: string) => { if ( @@ -43,17 +53,57 @@ export default function useSubmitBooking(isEdit: boolean, isWalkIn: boolean) { } } + if (isModification && pagePermission === PagePermission.BOOKING) { + // only a PA/admin can do a modification + setSubmitting("error"); + return; + } + let email: string; setSubmitting("submitting"); - if (isWalkIn && data.netId) { + if ((isWalkIn || isModification) && data.netId) { email = data.netId + "@nyu.edu"; } else { email = userEmail || data.missingEmail; } - const endpoint = isWalkIn ? "/api/walkIn" : "/api/bookings"; - fetch(`${process.env.NEXT_PUBLIC_BASE_URL}${endpoint}`, { - method: isWalkIn || !isEdit ? "POST" : "PUT", + const requestParams = ((): { + endpoint: string; + method: "POST" | "PUT"; + body?: Object; + } => { + switch (formContext) { + case FormContextLevel.EDIT: + return { + endpoint: "/api/bookings", + method: "PUT", + body: { calendarEventId, allRooms: roomSettings }, + }; + case FormContextLevel.MODIFICATION: + return { + endpoint: "/api/bookings", + method: "PUT", + body: { + calendarEventId, + isAutoApproval: true, + allRooms: roomSettings, + }, + }; + case FormContextLevel.WALK_IN: + return { + endpoint: "/api/walkIn", + method: "POST", + }; + default: + return { + endpoint: "/api/bookings", + method: "POST", + }; + } + })(); + + fetch(`${process.env.NEXT_PUBLIC_BASE_URL}${requestParams.endpoint}`, { + method: requestParams.method, headers: { "Content-Type": "application/json", }, @@ -64,7 +114,7 @@ export default function useSubmitBooking(isEdit: boolean, isWalkIn: boolean) { liaisonUsers, data, isAutoApproval, - ...(isEdit && calendarEventId && { calendarEventId }), + ...(requestParams.body ?? {}), }), }) .then((res) => { diff --git a/booking-app/components/src/client/routes/components/ListTable.tsx b/booking-app/components/src/client/routes/components/ListTable.tsx index 83bde3e9..40736738 100644 --- a/booking-app/components/src/client/routes/components/ListTable.tsx +++ b/booking-app/components/src/client/routes/components/ListTable.tsx @@ -1,4 +1,4 @@ -import { Box, TableCell } from "@mui/material"; +import { Box, TableCell, styled } from "@mui/material"; import React, { useMemo } from "react"; import ListTableRow from "./ListTableRow"; @@ -15,6 +15,12 @@ interface Props { topRow: React.ReactNode; } +const ListTableWrapper = styled(Table)` + th { + padding-right: 16px; + } +`; + export default function ListTable(props: Props) { const refresh = props.rowsRefresh; const topRow = props.topRow; @@ -24,7 +30,7 @@ export default function ListTable(props: Props) { if (props.rows.length === 0) { return []; } - return Object.keys(props.rows[0]) as string[]; + return Object.keys(props.rows[0]).filter((x) => x !== "id") as string[]; }, [props.rows]); const columns = useMemo( @@ -40,7 +46,7 @@ export default function ListTable(props: Props) { ); return ( - + {props?.rows.map((row, index: number) => ( ))} -
+ ); } diff --git a/booking-app/components/src/client/routes/components/Table.tsx b/booking-app/components/src/client/routes/components/Table.tsx index 1ba3f30f..a4ac8153 100644 --- a/booking-app/components/src/client/routes/components/Table.tsx +++ b/booking-app/components/src/client/routes/components/Table.tsx @@ -59,15 +59,22 @@ export const TableEmpty = styled(Box)` `; interface Props { + className?: string; columns: React.ReactNode[]; children: React.ReactNode[]; topRow: React.ReactNode; sx?: SxProps; } -export default function Table({ columns, children, topRow, sx }: Props) { +export default function Table({ + className, + columns, + children, + topRow, + sx, +}: Props) { return ( - <> + @@ -83,6 +90,6 @@ export default function Table({ columns, children, topRow, sx }: Props) { {children} - + ); } diff --git a/booking-app/components/src/client/routes/components/bookingTable/BookingTableRow.tsx b/booking-app/components/src/client/routes/components/bookingTable/BookingTableRow.tsx index 36a3a40e..b0183fd0 100644 --- a/booking-app/components/src/client/routes/components/bookingTable/BookingTableRow.tsx +++ b/booking-app/components/src/client/routes/components/bookingTable/BookingTableRow.tsx @@ -123,6 +123,7 @@ export default function BookingTableRow({ diff --git a/booking-app/components/src/client/routes/modification/ModificationLandingPage.tsx b/booking-app/components/src/client/routes/modification/ModificationLandingPage.tsx new file mode 100644 index 00000000..104286b8 --- /dev/null +++ b/booking-app/components/src/client/routes/modification/ModificationLandingPage.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { Box, List, ListItem, Typography } from "@mui/material"; + +import Button from "@mui/material/Button"; +import React from "react"; +import { styled } from "@mui/system"; +import { useRouter } from "next/navigation"; + +const Center = styled(Box)` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +`; + +const Modal = styled(Center)(({ theme }) => ({ + border: `1px solid ${theme.palette.custom.border}`, + borderRadius: 4, + alignItems: "flex-start", + marginTop: 20, + maxWidth: 800, +})); + +const Title = styled(Typography)` + font-weight: 700; + font-size: 20px; + line-height: 1.25; + margin-bottom: 12px; +`; + +const Bulleted = styled(List)` + list-style-type: disc; + padding: 0px 0px 0px 32px; + li { + display: list-item; + padding: 0; + } +`; + +interface Props { + calendarEventId: string; +} + +export default function ModificationLandingPage({ calendarEventId }: Props) { + const router = useRouter(); + + return ( +
+ 370🅙 Media Commons Reservation Form + + + Policy reminders for modifying a reservation + + + Reservation Modification Policy + +

You may modify

+ + Reservation end time + Selected rooms + Equipment checkout + Number of expected attendees + + +
+
+ ); +} diff --git a/booking-app/components/src/client/utils/date.tsx b/booking-app/components/src/client/utils/date.tsx index f14f8f12..fa4ccef8 100644 --- a/booking-app/components/src/client/utils/date.tsx +++ b/booking-app/components/src/client/utils/date.tsx @@ -58,3 +58,23 @@ export const formatTimeAmPm = (d: Date) => { hour12: true, }); }; + +export function roundTimeUp() { + const now = new Date(); + const minutes = now.getMinutes(); + + // Round up to next half-hour or hour + const roundedMinutes = minutes > 30 ? 60 : 30; + + if (roundedMinutes === 60) { + now.setHours(now.getHours() + 1); + now.setMinutes(0); + } else { + now.setMinutes(30); + } + + now.setSeconds(0); + now.setMilliseconds(0); + + return now; +} diff --git a/booking-app/components/src/server/admin.ts b/booking-app/components/src/server/admin.ts index 2b942375..ef068696 100644 --- a/booking-app/components/src/server/admin.ts +++ b/booking-app/components/src/server/admin.ts @@ -5,11 +5,6 @@ import { PolicySettings, RoomSetting, } from "../types"; - -import { getApprovalCcEmail, TableNames } from "../policy"; -import { approvalUrl, declineUrl, getBookingToolDeployUrl } from "./ui"; - -import { Timestamp } from "firebase-admin/firestore"; import { Constraint, serverDeleteData, @@ -18,6 +13,10 @@ import { serverGetFinalApproverEmail, serverUpdateInFirestore, } from "@/lib/firebase/server/adminDb"; +import { TableNames, getApprovalCcEmail } from "../policy"; +import { approvalUrl, declineUrl, getBookingToolDeployUrl } from "./ui"; + +import { Timestamp } from "firebase-admin/firestore"; export const serverBookingContents = (id: string) => { return serverGetDataByCalendarEventId(TableNames.BOOKING, id) @@ -121,7 +120,9 @@ export const serverApproveBooking = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.PENDING, + newValues: { + statusPrefix: BookingStatusLabel.PENDING, + }, }), } ); @@ -241,7 +242,7 @@ export const serverApproveEvent = async (id: string) => { const formDataForCalendarEvents = { calendarEventId: id, - newPrefix: BookingStatusLabel.APPROVED, + newValues: { statusPrefix: BookingStatusLabel.APPROVED }, }; await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/calendarEvents`, { method: "PUT", diff --git a/booking-app/components/src/server/calendars.ts b/booking-app/components/src/server/calendars.ts index 8b92b9d2..9e6bcd9a 100644 --- a/booking-app/components/src/server/calendars.ts +++ b/booking-app/components/src/server/calendars.ts @@ -1,12 +1,11 @@ import { BookingFormDetails, BookingStatusLabel, RoomSetting } from "../types"; -import { Timestamp, endAt, sum, where } from "@firebase/firestore"; import { TableNames } from "../policy"; import { clientFetchAllDataFromCollection } from "@/lib/firebase/firebase"; import { getCalendarClient } from "@/lib/googleClient"; import { serverGetRoomCalendarIds } from "./admin"; -const patchCalendarEvent = async ( +export const patchCalendarEvent = async ( event: any, calendarId: string, eventId: string, @@ -70,17 +69,19 @@ const bookingContentsToDescription = (bookingContents: BookingFormDetails) => { listItem("Expected Attendance", bookingContents.expectedAttendance), bookingContents.roomSetup === "yes" && "**" + listItem("Room Setup", bookingContents.setupDetails) + "**", - listItem("Title", bookingContents.title), - bookingContents.mediaServices && - bookingContents.mediaServices.length > 0 && - listItem("Media Services", bookingContents.mediaServices), - bookingContents.mediaServicesDetails.length > 0 && - listItem("Media Services Details", bookingContents.mediaServicesDetails), - (bookingContents.catering === "yes" || - bookingContents.cateringService.length > 0) && - listItem("Catering", bookingContents.cateringService), - bookingContents.hireSecurity === "yes" && - listItem("Hire Security", bookingContents.hireSecurity), + bookingContents.mediaServices && bookingContents.mediaServices.length > 0 + ? listItem("Media Services", bookingContents.mediaServices) + : "", + bookingContents.mediaServicesDetails.length > 0 + ? listItem("Media Services Details", bookingContents.mediaServicesDetails) + : "", + bookingContents.catering === "yes" || + bookingContents.cateringService.length > 0 + ? listItem("Catering", bookingContents.cateringService) + : "", + bookingContents.hireSecurity === "yes" + ? listItem("Hire Security", bookingContents.hireSecurity) + : "", "

Cancellation Policy

", ]; //@ts-ignore @@ -123,9 +124,14 @@ export const insertEvent = async ({ return event.data; }; -export const updateEventPrefix = async ( +export const updateCalendarEvent = async ( calendarEventId: string, - newPrefix: BookingStatusLabel, + newValues: { + end: { + dateTime: string; + }; + statusPrefix?: BookingStatusLabel; + }, bookingContents: BookingFormDetails ) => { const roomCalendarIds = await serverGetRoomCalendarIds( @@ -144,27 +150,44 @@ export const updateEventPrefix = async ( eventId: calendarEventId, }); - if (event) { + if (!event) { + throw new Error("event not found with specified id"); + } + + const updatedValues = {}; + + if (newValues.statusPrefix) { const eventData = event.data; const eventTitle = eventData.summary ?? ""; const prefixRegex = /\[.*?\]/g; - const newTitle = eventTitle.replace(prefixRegex, `[${newPrefix}]`); + const newTitle = eventTitle.replace( + prefixRegex, + `[${newValues.statusPrefix}]` + ); + updatedValues["summary"] = newTitle; + } - let description = bookingContents - ? bookingContentsToDescription(bookingContents) - : ""; - description += - 'To cancel reservations please return to the Booking Tool, visit My Bookings, and click "cancel" on the booking at least 24 hours before the date of the event. Failure to cancel an unused booking is considered a no-show and may result in restricted use of the space.'; + if (newValues.end) { + updatedValues["end"] = newValues.end; + } - await patchCalendarEvent(event, roomCalendarId, calendarEventId, { - summary: newTitle, - description: description, - }); + let description = bookingContents + ? bookingContentsToDescription(bookingContents) + : ""; + description += + 'To cancel reservations please return to the Booking Tool, visit My Bookings, and click "cancel" on the booking at least 24 hours before the date of the event. Failure to cancel an unused booking is considered a no-show and may result in restricted use of the space.'; + updatedValues["description"] = description; - console.log( - `Updated event ${calendarEventId} in calendar ${roomCalendarId} with new prefix ${newPrefix}` - ); - } + await patchCalendarEvent( + event, + roomCalendarId, + calendarEventId, + updatedValues + ); + + console.log( + `Updated event ${calendarEventId} in calendar ${roomCalendarId} with new values: ${JSON.stringify(newValues)}` + ); } catch (error) { console.error( `Error updating event ${calendarEventId} in calendar ${roomCalendarId}:`, @@ -190,3 +213,34 @@ export const deleteEvent = async ( console.log("calendar event doesn't exist for room " + roomId); } }; + +export const updateByCalendarEventId = async ( + calendarEventId: string, + newValues: any +) => { + // const allRooms: RoomSetting[] = await clientFetchAllDataFromCollection( + // TableNames.RESOURCES + // ); + // const roomCalendarIds = allRooms.map((room) => room.calendarId); + // // const calendarIdsToEvent = {}; + // const calendar = await getCalendarClient(); + // for (const roomCalendarId of roomCalendarIds) { + // const event = await calendar.events.get({ + // calendarId: roomCalendarId, + // eventId: calendarEventId, + // }); + // await patchCalendarEvent(event, roomCalendarId, calendarEventId, newValues); + // } +}; + +// update endTime for all calendar events +// searchCalendarsForEventId(id); +// const roomCalendarIdsToEvents = await searchCalendarsForEventId(id); +// for (let [calendarId, event] of Object.entries(roomCalendarIdsToEvents)) { +// await patchCalendarEvent(event, calendarId, id, { +// end: { +// dateTime: checkoutDate.toISOString(), +// }, +// }); +// console.log(`Updated end time on ${calendarId} event: ${id}`); +// } diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index 6e238244..ac299854 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -1,22 +1,23 @@ -import { Timestamp, where } from "@firebase/firestore"; - import { + BookingFormDetails, + BookingStatusLabel, + PolicySettings, +} from "../types"; +import { + TableNames, clientGetFinalApproverEmail, getCancelCcEmail, - TableNames, } from "../policy"; +import { Timestamp, where } from "@firebase/firestore"; +import { approvalUrl, declineUrl, getBookingToolDeployUrl } from "./ui"; import { clientFetchAllDataFromCollection, clientGetDataByCalendarEventId, clientUpdateDataInFirestore, } from "@/lib/firebase/firebase"; + import { clientUpdateDataByCalendarEventId } from "@/lib/firebase/client/clientDb"; -import { - BookingFormDetails, - BookingStatusLabel, - PolicySettings, -} from "../types"; -import { approvalUrl, declineUrl, getBookingToolDeployUrl } from "./ui"; +import { roundTimeUp } from "../client/utils/date"; export const fetchAllFutureBooking = async ( collectionName: TableNames @@ -96,7 +97,7 @@ export const decline = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.DECLINED, + newValues: { statusPrefix: BookingStatusLabel.DECLINED }, }), } ); @@ -134,7 +135,7 @@ export const cancel = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.CANCELED, + newValues: { statusPrefix: BookingStatusLabel.CANCELED }, }), } ); @@ -183,16 +184,20 @@ export const checkin = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.CHECKED_IN, + newValues: { statusPrefix: BookingStatusLabel.CHECKED_IN }, }), } ); }; export const checkOut = async (id: string) => { + const checkoutDate = roundTimeUp(); clientUpdateDataByCalendarEventId(TableNames.BOOKING_STATUS, id, { checkedOutAt: Timestamp.now(), }); + clientUpdateDataByCalendarEventId(TableNames.BOOKING, id, { + endDate: Timestamp.fromDate(checkoutDate), + }); const doc = await clientGetDataByCalendarEventId( TableNames.BOOKING_STATUS, id @@ -208,6 +213,7 @@ export const checkOut = async (id: string) => { headerMessage, BookingStatusLabel.CHECKED_OUT ); + const response = await fetch( `${process.env.NEXT_PUBLIC_BASE_URL}/api/calendarEvents`, { @@ -217,7 +223,12 @@ export const checkOut = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.CHECKED_OUT, + newValues: { + statusPrefix: BookingStatusLabel.CHECKED_OUT, + end: { + dateTime: roundTimeUp().toISOString(), + }, + }, }), } ); @@ -256,7 +267,7 @@ export const noShow = async (id: string) => { }, body: JSON.stringify({ calendarEventId: id, - newPrefix: BookingStatusLabel.NO_SHOW, + newValues: { statusPrefix: BookingStatusLabel.NO_SHOW }, }), } ); diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index fcbc1ca6..ed195f43 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -95,6 +95,14 @@ export enum Department { } export type DevBranch = "development" | "staging" | "production" | ""; +// what context are we entering the form in? +export enum FormContextLevel { + EDIT = "/edit", + FULL_FORM = "/book", + MODIFICATION = "/modification", + WALK_IN = "/walk-in", +} + export type Inputs = { firstName: string; lastName: string; diff --git a/booking-app/package-lock.json b/booking-app/package-lock.json index 752525e8..eaf44726 100644 --- a/booking-app/package-lock.json +++ b/booking-app/package-lock.json @@ -31,6 +31,7 @@ "fullcalendar": "^6.1.14", "googleapis": "^140.0.1", "handlebars": "^4.7.8", + "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", "next": "14.2.4", "prop-types": "^15.8.1", @@ -3772,6 +3773,11 @@ "integrity": "sha512-qC/xYId4NMebE6w/V33Fh9gWxLgURiNYgVNObbJl2LZv0GUUItCcCqC5axQSwRaAgaxl2mELq1rMzlswaQ0Zxg==", "dev": true }, + "node_modules/@streamparser/json": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@streamparser/json/-/json-0.0.6.tgz", + "integrity": "sha512-vL9EVn/v+OhZ+Wcs6O4iKE9EUpwHUqHmCtNUMWjqp+6dr85+XPOSGTEsqYNq1Vn04uk9SWlOVmx9J48ggJVT2Q==" + }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -8211,6 +8217,31 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json2csv": { + "version": "6.0.0-alpha.2", + "resolved": "https://registry.npmjs.org/json2csv/-/json2csv-6.0.0-alpha.2.tgz", + "integrity": "sha512-nJ3oP6QxN8z69IT1HmrJdfVxhU1kLTBVgMfRnNZc37YEY+jZ4nU27rBGxT4vaqM/KUCavLRhntmTuBFqZLBUcA==", + "dependencies": { + "@streamparser/json": "^0.0.6", + "commander": "^6.2.0", + "lodash.get": "^4.4.2" + }, + "bin": { + "json2csv": "bin/json2csv.js" + }, + "engines": { + "node": ">= 12", + "npm": ">= 6.13.0" + } + }, + "node_modules/json2csv/node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "engines": { + "node": ">= 6" + } + }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -8401,6 +8432,11 @@ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/booking-app/package.json b/booking-app/package.json index ba14c6ac..b600f1f7 100644 --- a/booking-app/package.json +++ b/booking-app/package.json @@ -34,6 +34,7 @@ "fullcalendar": "^6.1.14", "googleapis": "^140.0.1", "handlebars": "^4.7.8", + "json2csv": "^6.0.0-alpha.2", "mysql2": "^3.10.1", "next": "14.2.4", "prop-types": "^15.8.1",