diff --git a/booking-app/app/api/approve/route.ts b/booking-app/app/api/approve/route.ts index 73204525..67684bbd 100644 --- a/booking-app/app/api/approve/route.ts +++ b/booking-app/app/api/approve/route.ts @@ -4,7 +4,6 @@ import { serverApproveBooking } from "@/components/src/server/admin"; export async function POST(req: NextRequest) { const { id, email } = await req.json(); - try { await serverApproveBooking(id, email); return NextResponse.json( @@ -14,8 +13,8 @@ export async function POST(req: NextRequest) { } catch (error) { console.error(`booking_id: ${id} Error approving:`, error); return NextResponse.json( - { error: `Failed to approve booking. id: ${id}` }, - { status: 500 }, + { error: error.message }, + { status: error.status }, ); } } diff --git a/booking-app/app/equipment/page.tsx b/booking-app/app/equipment/page.tsx new file mode 100644 index 00000000..97004678 --- /dev/null +++ b/booking-app/app/equipment/page.tsx @@ -0,0 +1,16 @@ +// app/liaison/page.tsx + +"use client"; + +import Equipment from "@/components/src/client/routes/equipment/Equipment"; +import { Suspense } from "react"; + +const EquipmentPage: React.FC = () => { + return ( + Loading...}> + + + ); +}; + +export default EquipmentPage; diff --git a/booking-app/clientInfo b/booking-app/clientInfo new file mode 100644 index 00000000..368be10f --- /dev/null +++ b/booking-app/clientInfo @@ -0,0 +1 @@ +o4Qd0YDOKtCR95EUyQ2Rbp5UbNUa:XkfbKenefg7ntnRR1D3r3nTo_lQa diff --git a/booking-app/components/src/client/routes/admin/components/Approvers.tsx b/booking-app/components/src/client/routes/admin/components/Approvers.tsx new file mode 100644 index 00000000..4162ea8e --- /dev/null +++ b/booking-app/components/src/client/routes/admin/components/Approvers.tsx @@ -0,0 +1,16 @@ +import { Typography } from "@mui/material"; +import { EquipmentUsers } from "./EquipmentUsers"; +import { Liaisons } from "./Liaisons"; + +export const Approvers = () => ( +
+ + Liaison Users + + + + Equipment Users + + +
+); 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 5e8ac63f..e2efca82 100644 --- a/booking-app/components/src/client/routes/admin/components/BookingActions.tsx +++ b/booking-app/components/src/client/routes/admin/components/BookingActions.tsx @@ -36,7 +36,7 @@ export default function BookingActions(props: Props) { const [selectedAction, setSelectedAction] = useState( Actions.PLACEHOLDER ); - const { reloadBookings } = useContext(DatabaseContext); + const { reloadFutureBookings } = useContext(DatabaseContext); const [showError, setShowError] = useState(false); const [reason, setReason] = useState(); @@ -54,7 +54,7 @@ export default function BookingActions(props: Props) { }; const reload = async () => { - reloadBookings(); + reloadFutureBookings(); }; const onError = () => { diff --git a/booking-app/components/src/client/routes/admin/components/EquipmentUsers.tsx b/booking-app/components/src/client/routes/admin/components/EquipmentUsers.tsx new file mode 100644 index 00000000..1d0fa3fc --- /dev/null +++ b/booking-app/components/src/client/routes/admin/components/EquipmentUsers.tsx @@ -0,0 +1,60 @@ +import { useContext, useMemo } from "react"; + +import { ApproverLevel, TableNames } from "../../../../policy"; +import { formatDate } from "../../../utils/date"; +import AddRow from "../../components/AddRow"; +import ListTable from "../../components/ListTable"; +import { DatabaseContext } from "../../components/Provider"; + +const AddEquipmentForm = ({ equipmentEmails, reloadEquipmentEmails }) => { + return ( + + ); +}; + +export const EquipmentUsers = () => { + const { equipmentUsers, reloadApproverUsers } = useContext(DatabaseContext); + const equipmentEmails = useMemo( + () => equipmentUsers.map((user) => user.email), + [equipmentUsers] + ); + + const rows = useMemo(() => { + const filtered = equipmentUsers.map((liaison) => { + const { level, ...other } = liaison; + return other; + }); + const sorted = filtered.sort((a, b) => + a.department.localeCompare(b.department) + ); + return sorted as unknown as { [key: string]: string }[]; + }, [equipmentUsers]); + + return ( + + } + columnFormatters={{ createdAt: formatDate }} + /> + ); +}; 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 4f96ca9e..30525ed0 100644 --- a/booking-app/components/src/client/routes/admin/components/Settings.tsx +++ b/booking-app/components/src/client/routes/admin/components/Settings.tsx @@ -1,23 +1,23 @@ import { Divider, ListItemButton, ListItemText, Stack } from "@mui/material"; +import Grid from "@mui/material/Unstable_Grid2"; +import { useState } from "react"; import { AdminUsers } from "./AdminUsers"; +import { Approvers } from "./Approvers"; import { BannedUsers } from "./Ban"; import BookingTypes from "./BookingTypes"; import { Departments } from "./Departments"; import ExportDatabase from "./ExportDatabase"; -import Grid from "@mui/material/Unstable_Grid2"; -import { Liaisons } from "./Liaisons"; import { PAUsers } from "./PAUsers"; import PolicySettings from "./PolicySettings"; import SafetyTrainedUsers from "./SafetyTraining"; import SyncCalendars from "./SyncCalendars"; -import { useState } from "react"; const tabs = [ { label: "Safety Training", id: "safetyTraining" }, { label: "PA Users", id: "pa" }, { label: "Admin Users", id: "admin" }, - { label: "Liaisons", id: "liaisons" }, + { label: "Approvers", id: "approvers" }, { label: "Departments", id: "departments" }, { label: "Ban", id: "ban" }, { label: "Booking Types", id: "bookingTypes" }, @@ -51,7 +51,7 @@ export default function Settings() { {tab === "safetyTraining" && } {tab === "pa" && } {tab === "admin" && } - {tab === "liaisons" && } + {tab === "approvers" && } {tab === "ban" && } {tab === "departments" && } {tab === "bookingTypes" && } diff --git a/booking-app/components/src/client/routes/admin/hooks/useBookingActions.tsx b/booking-app/components/src/client/routes/admin/hooks/useBookingActions.tsx index daf7a0c4..59aa07d5 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useBookingActions.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useBookingActions.tsx @@ -1,4 +1,3 @@ -import { BookingStatusLabel, PageContextLevel } from "@/components/src/types"; import { cancel, checkOut, @@ -7,13 +6,14 @@ import { decline, noShow, } from "@/components/src/server/db"; +import { BookingStatusLabel, PageContextLevel } from "@/components/src/types"; import { useContext, useMemo, useState } from "react"; +import { Timestamp } from "@firebase/firestore"; +import { useRouter } from "next/navigation"; import { BookingContext } from "../../booking/bookingProvider"; import { DatabaseContext } from "../../components/Provider"; -import { Timestamp } from "@firebase/firestore"; import useExistingBooking from "./useExistingBooking"; -import { useRouter } from "next/navigation"; export enum Actions { CANCEL = "Cancel", @@ -146,6 +146,7 @@ export default function useBookingActions({ status !== BookingStatusLabel.CHECKED_IN && status !== BookingStatusLabel.CHECKED_OUT && status !== BookingStatusLabel.NO_SHOW && + status !== BookingStatusLabel.APPROVED && startDate.toDate() > date ) { options.push(Actions.EDIT); @@ -184,6 +185,7 @@ export default function useBookingActions({ }, [status]); const liaisonOptions = [Actions.FIRST_APPROVE, Actions.DECLINE]; + const equipmentOptions = [Actions.MODIFICATION, Actions.DECLINE]; const adminOptions = useMemo(() => { if ( @@ -215,6 +217,8 @@ export default function useBookingActions({ return paOptions; case PageContextLevel.LIAISON: return liaisonOptions; + case PageContextLevel.EQUIPMENT: + return equipmentOptions; default: return adminOptions; } diff --git a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx index eead2d63..da022390 100644 --- a/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx +++ b/booking-app/components/src/client/routes/admin/hooks/useExistingBooking.tsx @@ -12,10 +12,10 @@ export default function useExistingBooking() { setBookingCalendarInfo, setFormData, } = useContext(BookingContext); - const { bookings, roomSettings } = useContext(DatabaseContext); + const { futureBookings, roomSettings } = useContext(DatabaseContext); const findBooking = (calendarEventId: string) => - bookings.filter( + futureBookings.filter( (booking) => booking.calendarEventId === calendarEventId )[0]; 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 d29cd9aa..7b028ece 100644 --- a/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx +++ b/booking-app/components/src/client/routes/booking/hooks/useSubmitBooking.tsx @@ -10,7 +10,7 @@ export default function useSubmitBooking(formContext: FormContextLevel) { const { liaisonUsers, userEmail, - reloadBookings, + reloadFutureBookings, pagePermission, roomSettings, } = useContext(DatabaseContext); @@ -122,7 +122,7 @@ export default function useSubmitBooking(formContext: FormContextLevel) { setFormData(undefined); setHasShownMocapModal(false); - reloadBookings(); + reloadFutureBookings(); setSubmitting("success"); }) .catch((error) => { @@ -136,7 +136,7 @@ export default function useSubmitBooking(formContext: FormContextLevel) { liaisonUsers, userEmail, router, - reloadBookings, + reloadFutureBookings, department, role, ] diff --git a/booking-app/components/src/client/routes/components/AddRow.tsx b/booking-app/components/src/client/routes/components/AddRow.tsx index 697779e0..7fb22f2b 100644 --- a/booking-app/components/src/client/routes/components/AddRow.tsx +++ b/booking-app/components/src/client/routes/components/AddRow.tsx @@ -1,12 +1,12 @@ -import { Box, IconButton, TextField } from "@mui/material"; +import { IconButton, TextField } from "@mui/material"; import React, { useMemo, useState } from "react"; +import { Timestamp } from "@firebase/firestore"; import { AddCircleOutline } from "@mui/icons-material"; import Grid from "@mui/material/Unstable_Grid2/Grid2"; -import Loading from "./Loading"; -import { TableNames } from "../../../policy"; -import { Timestamp } from "@firebase/firestore"; import { clientSaveDataToFirestore } from "../../../../../lib/firebase/firebase"; +import { TableNames } from "../../../policy"; +import Loading from "./Loading"; interface Props { addDuplicateErrorMessage?: string; @@ -18,9 +18,9 @@ interface Props { rowsRefresh: () => Promise; title: string; extra?: { - components: React.ReactNode[]; + components?: React.ReactNode[]; values: { [key: string]: any }; - updates: ((x: string) => void)[]; + updates?: ((x: string) => void)[]; }; } diff --git a/booking-app/components/src/client/routes/components/Provider.tsx b/booking-app/components/src/client/routes/components/Provider.tsx index e27d7e43..d348345c 100644 --- a/booking-app/components/src/client/routes/components/Provider.tsx +++ b/booking-app/components/src/client/routes/components/Provider.tsx @@ -1,3 +1,5 @@ +import { ApproverLevel, TableNames } from "@/components/src/policy"; +import React, { createContext, useEffect, useMemo, useState } from "react"; import { AdminUser, Approver, @@ -14,19 +16,23 @@ import { Settings, UserApiData, } from "../../../types"; -import { ApproverLevel, TableNames } from "@/components/src/policy"; -import React, { createContext, useEffect, useMemo, useState } from "react"; -import { clientFetchAllDataFromCollection } from "@/lib/firebase/firebase"; -import { fetchAllFutureBooking } from "@/components/src/server/db"; import { useAuth } from "@/components/src/client/routes/components/AuthProvider"; +import { + fetchAllBookings, + fetchAllFutureBooking, +} from "@/components/src/server/db"; +import { clientFetchAllDataFromCollection } from "@/lib/firebase/firebase"; +import { Timestamp } from "firebase-admin/firestore"; export interface DatabaseContextType { adminUsers: AdminUser[]; bannedUsers: Ban[]; - bookings: Booking[]; + futureBookings: Booking[]; + allBookings: Booking[]; bookingsLoading: boolean; liaisonUsers: Approver[]; + equipmentUsers: Approver[]; departmentNames: DepartmentType[]; operationHours: OperationHours[]; pagePermission: PagePermission; @@ -41,21 +47,24 @@ export interface DatabaseContextType { reloadAdminUsers: () => Promise; reloadApproverUsers: () => Promise; reloadBannedUsers: () => Promise; - reloadBookings: () => Promise; + reloadFutureBookings: () => Promise; reloadDepartmentNames: () => Promise; reloadOperationHours: () => Promise; reloadPaUsers: () => Promise; reloadBookingTypes: () => Promise; reloadSafetyTrainedUsers: () => Promise; setUserEmail: (x: string) => void; + fetchAllBookings: () => Promise; } export const DatabaseContext = createContext({ adminUsers: [], bannedUsers: [], - bookings: [], + futureBookings: [], + allBookings: [], bookingsLoading: true, liaisonUsers: [], + equipmentUsers: [], departmentNames: [], operationHours: [], pagePermission: PagePermission.BOOKING, @@ -70,13 +79,14 @@ export const DatabaseContext = createContext({ reloadAdminUsers: async () => {}, reloadApproverUsers: async () => {}, reloadBannedUsers: async () => {}, - reloadBookings: async () => {}, + reloadFutureBookings: async () => {}, reloadDepartmentNames: async () => {}, reloadOperationHours: async () => {}, reloadPaUsers: async () => {}, reloadBookingTypes: async () => {}, reloadSafetyTrainedUsers: async () => {}, setUserEmail: (x: string) => {}, + fetchAllBookings: async () => {}, }); export const DatabaseProvider = ({ @@ -85,10 +95,12 @@ export const DatabaseProvider = ({ children: React.ReactNode; }) => { const [bannedUsers, setBannedUsers] = useState([]); - const [bookings, setBookings] = useState([]); + const [futureBookings, setFutureBookings] = useState([]); const [bookingsLoading, setBookingsLoading] = useState(true); + const [allBookings, setAllBookings] = useState([]); const [adminUsers, setAdminUsers] = useState([]); const [liaisonUsers, setLiaisonUsers] = useState([]); + const [equipmentUsers, setEquipmentUsers] = useState([]); const [departmentNames, setDepartmentName] = useState([]); const [operationHours, setOperationHours] = useState([]); const [paUsers, setPaUsers] = useState([]); @@ -104,9 +116,12 @@ export const DatabaseProvider = ({ const [userApiData, setUserApiData] = useState( undefined ); + const [lastItem, setLastItem] = useState(null); + const LIMIT = 3; const { user } = useAuth(); const netId = useMemo(() => userEmail?.split("@")[0], [userEmail]); + useEffect(() => { const fetchUserApiData = async () => { if (!netId) return; @@ -125,21 +140,30 @@ export const DatabaseProvider = ({ // page permission updates with respect to user email, admin list, PA list const pagePermission = useMemo(() => { + // Early return if no email if (!userEmail) return PagePermission.BOOKING; - if (adminUsers.map((admin) => admin.email).includes(userEmail)) - return PagePermission.ADMIN; - console.log("liaisonUsers", liaisonUsers); - console.log("userEmail", userEmail); - console.log( - "liaisonUsers.map((liaison) => liaison.email).includes(userEmail)", - liaisonUsers.map((liaison) => liaison.email).includes(userEmail) - ); - if (liaisonUsers.map((liaison) => liaison.email).includes(userEmail)) { - return PagePermission.LIAISON; - } else if (paUsers.map((pa) => pa.email).includes(userEmail)) - return PagePermission.PA; - else return PagePermission.BOOKING; - }, [userEmail, adminUsers, paUsers, liaisonUsers]); + + // Pre-compute email lists once + const adminEmails = adminUsers.map((admin) => admin.email); + const liaisonEmails = liaisonUsers.map((liaison) => liaison.email); + const paEmails = paUsers.map((pa) => pa.email); + const equipmentEmails = equipmentUsers.map((e) => e.email); + + // Check permissions + if (adminEmails.includes(userEmail)) return PagePermission.ADMIN; + if (equipmentEmails.includes(userEmail)) return PagePermission.EQUIPMENT; + if (liaisonEmails.includes(userEmail)) return PagePermission.LIAISON; + if (paEmails.includes(userEmail)) return PagePermission.PA; + + return PagePermission.BOOKING; + }, [ + userEmail, + // Make sure we're using the actual arrays in dependencies + JSON.stringify(adminUsers), + JSON.stringify(liaisonUsers), + JSON.stringify(paUsers), + JSON.stringify(equipmentUsers), + ]); useEffect(() => { if (!bookingsLoading) { @@ -149,7 +173,7 @@ export const DatabaseProvider = ({ fetchDepartmentNames(); fetchSettings(); } else { - fetchBookings(); + fetchFutureBookings(); } }, [bookingsLoading, user]); @@ -160,80 +184,42 @@ export const DatabaseProvider = ({ fetchRoomSettings(); }, [user]); + useEffect(() => { + if ( + pagePermission === PagePermission.ADMIN || + pagePermission === PagePermission.LIAISON || + pagePermission === PagePermission.PA + ) + fetchBookings(); + }, [pagePermission]); + const fetchActiveUserEmail = () => { if (!user) return; setUserEmail(user.email); }; - const fetchBookings = async () => { - fetchAllFutureBooking(TableNames.BOOKING) + const fetchFutureBookings = async () => { + fetchAllFutureBooking() .then((fetchedData) => { - const bookings = fetchedData.map((item: any) => ({ - id: item.id, - requestNumber: item.requestNumber, - calendarEventId: item.calendarEventId, - email: item.email, - startDate: item.startDate, - endDate: item.endDate, - roomId: String(item.roomId), - user: item.user, - room: item.room, - startTime: item.startTime, - endTime: item.endTime, - status: item.status, - firstName: item.firstName, - lastName: item.lastName, - secondaryName: item.secondaryName, - nNumber: item.nNumber, - netId: item.netId, - phoneNumber: item.phoneNumber, - department: item.department, - otherDepartment: item.otherDepartment, - role: item.role, - sponsorFirstName: item.sponsorFirstName, - sponsorLastName: item.sponsorLastName, - sponsorEmail: item.sponsorEmail, - title: item.title, - description: item.description, - bookingType: item.bookingType, - attendeeAffiliation: item.attendeeAffiliation, - roomSetup: item.roomSetup, - setupDetails: item.setupDetails, - mediaServices: item.mediaServices, - mediaServicesDetails: item.mediaServicesDetails, - equipmentCheckedOut: item.equipmentCheckedOut, - catering: item.catering, - hireSecurity: item.hireSecurity, - expectedAttendance: item.expectedAttendance, - cateringService: item.cateringService, - missingEmail: item?.missingEmail, - chartFieldForCatering: item.chartFieldForCatering, - chartFieldForSecurity: item.chartFieldForSecurity, - chartFieldForRoomSetup: item.chartFieldForRoomSetup, - requestedAt: item.requestedAt, - firstApprovedAt: item.firstApprovedAt, - firstApprovedBy: item.firstApprovedBy, - finalApprovedAt: item.finalApprovedAt, - finalApprovedBy: item.finalApprovedBy, - declinedAt: item.declinedAt, - declinedBy: item.declinedBy, - declineReason: item.declineReason, - canceledAt: item.canceledAt, - canceledBy: item.canceledBy, - checkedInAt: item.checkedInAt, - checkedInBy: item.checkedInBy, - checkedOutAt: item.checkedOutAt, - checkedOutBy: item.checkedOutBy, - noShowedAt: item.noShowedAt, - noShowedBy: item.noShowedBy, - walkedInAt: item.walkedInAt, - })); - setBookings(bookings); + setFutureBookings(fetchedData as Booking[]); setBookingsLoading(false); }) .catch((error) => console.error("Error fetching data:", error)); }; + const fetchBookings = async () => { + try { + const bookingsResponse: Booking[] = await fetchAllBookings( + LIMIT, + lastItem + ); + setLastItem(bookingsResponse[bookingsResponse.length - 1].requestedAt); + setAllBookings((oldBookings) => [...oldBookings, ...bookingsResponse]); + } catch (error) { + console.error("Error fetching data:", error); + } + }; + const fetchAdminUsers = async () => { clientFetchAllDataFromCollection(TableNames.ADMINS) .then((fetchedData) => { @@ -341,14 +327,20 @@ export const DatabaseProvider = ({ level: Number(item.level), })); const liaisons = all.filter((x) => x.level === ApproverLevel.FIRST); + const equipmentUsers = all.filter( + (x) => x.level === ApproverLevel.EQUIPMENT + ); + const finalApprover = all.filter( (x) => x.level === ApproverLevel.FINAL )[0]; setLiaisonUsers(liaisons); + setEquipmentUsers(equipmentUsers); setPolicySettings({ finalApproverEmail: finalApprover.email }); }) .catch((error) => console.error("Error fetching data:", error)); }; + const fetchDepartmentNames = async () => { clientFetchAllDataFromCollection(TableNames.DEPARTMENTS) .then((fetchedData) => { @@ -426,8 +418,10 @@ export const DatabaseProvider = ({ value={{ adminUsers, bannedUsers, - bookings, + futureBookings, + allBookings, liaisonUsers, + equipmentUsers, departmentNames, operationHours, paUsers, @@ -443,13 +437,14 @@ export const DatabaseProvider = ({ reloadAdminUsers: fetchAdminUsers, reloadApproverUsers: fetchApproverUsers, reloadBannedUsers: fetchBannedUsers, - reloadBookings: fetchBookings, + reloadFutureBookings: fetchFutureBookings, reloadDepartmentNames: fetchDepartmentNames, reloadOperationHours: fetchOperationHours, reloadPaUsers: fetchPaUsers, reloadBookingTypes: fetchBookingTypes, reloadSafetyTrainedUsers: fetchSafetyTrainedUsers, setUserEmail, + fetchAllBookings: fetchBookings, }} > {children} diff --git a/booking-app/components/src/client/routes/components/bookingTable/Bookings.tsx b/booking-app/components/src/client/routes/components/bookingTable/Bookings.tsx index c3968286..d9145fcf 100644 --- a/booking-app/components/src/client/routes/components/bookingTable/Bookings.tsx +++ b/booking-app/components/src/client/routes/components/bookingTable/Bookings.tsx @@ -1,5 +1,5 @@ import { Booking, BookingRow, PageContextLevel } from "../../../../types"; -import { Box, TableCell } from "@mui/material"; +import { Box, TableCell, Typography } from "@mui/material"; import React, { useCallback, useContext, @@ -20,6 +20,7 @@ import MoreInfoModal from "./MoreInfoModal"; import SortableTableCell from "./SortableTableCell"; import useAllowedStatuses from "./hooks/useAllowedStatuses"; import { useBookingFilters } from "./hooks/useBookingFilters"; +import { Button } from "@mui/material"; interface BookingsProps { pageContext: PageContextLevel; @@ -30,7 +31,7 @@ export const Bookings: React.FC = ({ pageContext, calendarEventId, }) => { - const { bookings, bookingsLoading, reloadBookings } = + const { futureBookings, bookingsLoading, reloadFutureBookings, fetchAllBookings, allBookings } = useContext(DatabaseContext); const allowedStatuses = useAllowedStatuses(pageContext); @@ -45,7 +46,7 @@ export const Bookings: React.FC = ({ const isUserView = pageContext === PageContextLevel.USER; useEffect(() => { - reloadBookings(); + reloadFutureBookings(); }, []); const filteredRows = useBookingFilters({ @@ -102,7 +103,7 @@ export const Bookings: React.FC = ({ }, [pageContext, statusFilters, allowedStatuses, selectedDateRange]); const bottomSection = useMemo(() => { - if (bookingsLoading && bookings.length === 0) { + if (bookingsLoading && futureBookings.length === 0) { return ( @@ -196,6 +197,7 @@ export const Bookings: React.FC = ({ /> ))} + {isUserView && } {bottomSection} {modalData != null && ( @@ -204,6 +206,35 @@ export const Bookings: React.FC = ({ closeModal={() => setModalData(null)} /> )} + {!isUserView && ( + + + Previous Bookings + + + {allBookings.map((row: BookingRow) => ( + + ))} +
+ + + +
+ )} ); }; diff --git a/booking-app/components/src/client/routes/components/bookingTable/EquipmentCheckoutToggle.tsx b/booking-app/components/src/client/routes/components/bookingTable/EquipmentCheckoutToggle.tsx index 23170f88..3dd4c6b4 100644 --- a/booking-app/components/src/client/routes/components/bookingTable/EquipmentCheckoutToggle.tsx +++ b/booking-app/components/src/client/routes/components/bookingTable/EquipmentCheckoutToggle.tsx @@ -15,7 +15,7 @@ export default function EquipmentCheckoutToggle({ booking, status }: Props) { const [loading, setLoading] = useState(false); const [optimisticStatus, setOptimisticStatus] = useState(status); const originalStatus = useRef(status); - const { reloadBookings } = useContext(DatabaseContext); + const { reloadFutureBookings } = useContext(DatabaseContext); const handleEquipToggleChange = async ( event: React.ChangeEvent @@ -32,7 +32,7 @@ export default function EquipmentCheckoutToggle({ booking, status }: Props) { equipmentCheckedOut: newStatus, } ); - await reloadBookings(); + await reloadFutureBookings(); } catch (ex) { console.error(ex); // Revert to the original status if there's an error diff --git a/booking-app/components/src/client/routes/components/bookingTable/hooks/useBookingFilters.ts b/booking-app/components/src/client/routes/components/bookingTable/hooks/useBookingFilters.ts index 712b6878..9bdc772f 100644 --- a/booking-app/components/src/client/routes/components/bookingTable/hooks/useBookingFilters.ts +++ b/booking-app/components/src/client/routes/components/bookingTable/hooks/useBookingFilters.ts @@ -29,7 +29,7 @@ export function useBookingFilters(props: Props): BookingRow[] { selectedDateRange, selectedStatusFilters, } = props; - const { bookings, liaisonUsers, userEmail } = useContext(DatabaseContext); + const { futureBookings, liaisonUsers, userEmail } = useContext(DatabaseContext); const allowedStatuses = useAllowedStatuses(pageContext); const [currentTime, setCurrentTime] = useState(new Date()); @@ -43,11 +43,11 @@ export function useBookingFilters(props: Props): BookingRow[] { const rows: BookingRow[] = useMemo( () => - bookings.map((booking) => ({ + futureBookings.map((booking) => ({ ...booking, status: getBookingStatus(booking), })), - [bookings] + [futureBookings] ); const filteredRows = useMemo(() => { diff --git a/booking-app/components/src/client/routes/components/navBar.tsx b/booking-app/components/src/client/routes/components/navBar.tsx index f74108dc..43cf45ad 100644 --- a/booking-app/components/src/client/routes/components/navBar.tsx +++ b/booking-app/components/src/client/routes/components/navBar.tsx @@ -77,6 +77,9 @@ export default function NavBar() { case PagePermission.LIAISON: router.push("/liaison"); break; + case PagePermission.EQUIPMENT: + router.push("/equipment"); + break; } }; @@ -103,6 +106,8 @@ export default function NavBar() { setSelectedView(PagePermission.ADMIN); } else if (pathname.includes("/liaison")) { setSelectedView(PagePermission.LIAISON); + } else if (pathname.includes("/equipment")) { + setSelectedView(PagePermission.EQUIPMENT); } }, [pathname]); @@ -133,6 +138,8 @@ export default function NavBar() { pagePermission === PagePermission.LIAISON || pagePermission === PagePermission.ADMIN; const showAdmin = pagePermission === PagePermission.ADMIN; + const showEquipment = + pagePermission === PagePermission.ADMIN || PagePermission.EQUIPMENT; return ( ); }, [pagePermission, selectedView]); diff --git a/booking-app/components/src/client/routes/equipment/Equipment.tsx b/booking-app/components/src/client/routes/equipment/Equipment.tsx new file mode 100644 index 00000000..81ce9a75 --- /dev/null +++ b/booking-app/components/src/client/routes/equipment/Equipment.tsx @@ -0,0 +1,40 @@ +import { Box, Tab, Tabs } from "@mui/material"; +import { useContext, useState } from "react"; +import { PageContextLevel, PagePermission } from "../../../types"; + +import { Bookings } from "../components/bookingTable/Bookings"; +import { DatabaseContext } from "../components/Provider"; + +const Equipment = () => { + const { pagePermission } = useContext(DatabaseContext); + + const [tab, setTab] = useState("bookings"); + + const userHasPermission = + pagePermission === PagePermission.ADMIN || + pagePermission === PagePermission.EQUIPMENT; + + return ( + + {!userHasPermission ? ( +
You do not have permission to view this page.
+ ) : ( +
+ setTab(newVal)} + textColor="primary" + indicatorColor="primary" + > + + + {tab === "bookings" && ( + + )} +
+ )} +
+ ); +}; + +export default Equipment; diff --git a/booking-app/components/src/policy.ts b/booking-app/components/src/policy.ts index 9fc7c8ac..d82c2b30 100644 --- a/booking-app/components/src/policy.ts +++ b/booking-app/components/src/policy.ts @@ -3,8 +3,8 @@ import { MEDIA_COMMONS_OPERATION_EMAIL, } from "./mediaCommonsPolicy"; -import { BookingStatusLabel } from "./types"; import { clientGetFinalApproverEmailFromDatabase } from "@/lib/firebase/firebase"; +import { BookingStatusLabel } from "./types"; export enum TableNames { ADMINS = "usersAdmin", @@ -35,6 +35,7 @@ export const BOOKING_TABLE_HIDE_STATUS_TIME_ELAPSED = [ export enum ApproverLevel { FIRST = 1, FINAL = 2, + EQUIPMENT = 3, } /********** CONTACTS ************/ diff --git a/booking-app/components/src/server/admin.ts b/booking-app/components/src/server/admin.ts index c410bd3f..ee9e6fdb 100644 --- a/booking-app/components/src/server/admin.ts +++ b/booking-app/components/src/server/admin.ts @@ -7,8 +7,10 @@ import { serverGetFinalApproverEmail, serverUpdateInFirestore, } from "@/lib/firebase/server/adminDb"; -import { TableNames, getApprovalCcEmail } from "../policy"; +import { ApproverLevel, TableNames, getApprovalCcEmail } from "../policy"; import { + AdminUser, + Approver, ApproverType, BookingFormDetails, BookingStatus, @@ -110,68 +112,85 @@ export const serverApproveInstantBooking = (id: string) => { // both first approve and second approve flows hit here export const serverApproveBooking = async (id: string, email: string) => { - const bookingStatus = await serverGetDataByCalendarEventId( - TableNames.BOOKING, - id - ); - const firstApproveDateRange = - bookingStatus && bookingStatus.firstApprovedAt - ? bookingStatus.firstApprovedAt.toDate() - : null; + try { + const bookingStatus = await serverGetDataByCalendarEventId( + TableNames.BOOKING, + id + ); + const isFinalApproval = bookingStatus?.firstApprovedAt?.toDate() ?? null; - console.log("first approve date", firstApproveDateRange); + if (isFinalApproval) { + await finalApprove(id, email); + } else { + await firstApprove(id, email); + } + } catch (error) { + throw error.status ? error : { status: 500, message: error.message }; + } +}; - // if already first approved, then this is a second approve - if (firstApproveDateRange !== null) { - serverFinalApprove(id, email); - await serverApproveEvent(id); - } else { - console.log("email", email); - serverFirstApprove(id, email); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_BASE_URL}/api/calendarEvents`, - { - method: "PUT", - headers: { - "Content-Type": "application/json", +const firstApprove = async (id, email) => { + serverFirstApprove(id, email); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_BASE_URL}/api/calendarEvents`, + { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + calendarEventId: id, + newValues: { + statusPrefix: BookingStatusLabel.PENDING, }, - body: JSON.stringify({ - calendarEventId: id, - newValues: { - statusPrefix: BookingStatusLabel.PENDING, - }, - }), - } - ); - const contents = await serverBookingContents(id); + }), + } + ); + const contents = await serverBookingContents(id); - const emailContents = { - ...contents, - headerMessage: "This is a request email for final approval.", - }; - const recipient = await serverGetFinalApproverEmail(); - const formData = { - templateName: "booking_detail", - contents: emailContents, - targetEmail: recipient, - status: BookingStatusLabel.PENDING, - eventTitle: contents.title || "", - requestNumber: contents.requestNumber, - bodyMessage: "", - approverType: ApproverType.FINAL_APPROVER, + const emailContents = { + ...contents, + headerMessage: "This is a request email for final approval.", + }; + const recipient = await serverGetFinalApproverEmail(); + const formData = { + templateName: "booking_detail", + contents: emailContents, + targetEmail: recipient, + status: BookingStatusLabel.PENDING, + eventTitle: contents.title || "", + requestNumber: contents.requestNumber, + bodyMessage: "", + approverType: ApproverType.FINAL_APPROVER, + }; + const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); +}; +const finalApprove = async (id, email) => { + const finalApprovers = (await approvers()).filter( + (a) => a.level === ApproverLevel.FINAL + ); + const finalApproverEmails = [...(await admins()), ...finalApprovers].map( + (a) => a.email + ); + + const canPerformSecondApproval = finalApproverEmails.includes(email); + if (!canPerformSecondApproval) { + throw { + success: false, + message: + "Unauthorized: Only final approvers or admin users can perform second approval", + status: 403, }; - const res = await fetch( - `${process.env.NEXT_PUBLIC_BASE_URL}/api/sendEmail`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(formData), - } - ); } + serverFinalApprove(id, email); + await serverApproveEvent(id); }; export const serverSendConfirmationEmail = async ( @@ -287,7 +306,17 @@ export const serverApproveEvent = async (id: string) => { ); }; -export const approvers = async () => { +export const admins = async (): Promise => { + const fetchedData = await serverFetchAllDataFromCollection(TableNames.ADMINS); + const filtered = fetchedData.map((item: any) => ({ + id: item.id, + email: item.email, + createdAt: item.createdAt, + })); + return filtered; +}; + +export const approvers = async (): Promise => { const fetchedData = await serverFetchAllDataFromCollection( TableNames.APPROVERS ); @@ -295,6 +324,7 @@ export const approvers = async () => { id: item.id, email: item.email, department: item.department, + level: item.level, createdAt: item.createdAt, })); return filtered; diff --git a/booking-app/components/src/server/db.ts b/booking-app/components/src/server/db.ts index 343ae12b..fd97dabf 100644 --- a/booking-app/components/src/server/db.ts +++ b/booking-app/components/src/server/db.ts @@ -5,35 +5,48 @@ import { Days, OperationHours, } from "../types"; -import { - ApproverLevel, - TableNames, - clientGetFinalApproverEmail, - getCancelCcEmail, -} from "../policy"; -import { Timestamp, where } from "@firebase/firestore"; + import { clientFetchAllDataFromCollection, clientGetDataByCalendarEventId, clientSaveDataToFirestore, clientUpdateDataInFirestore, + getPaginatedData, } from "@/lib/firebase/firebase"; +import { Timestamp, where } from "@firebase/firestore"; +import { + ApproverLevel, + TableNames, + clientGetFinalApproverEmail, + getApprovalCcEmail, + getCancelCcEmail, +} from "../policy"; import { clientUpdateDataByCalendarEventId } from "@/lib/firebase/client/clientDb"; -import { getBookingToolDeployUrl } from "./ui"; import { roundTimeUp } from "../client/utils/date"; +import { getBookingToolDeployUrl } from "./ui"; -export const fetchAllFutureBooking = async ( - collectionName: TableNames -): Promise => { +export const fetchAllFutureBooking = async (): Promise => { const now = Timestamp.now(); const futureQueryConstraints = [where("endDate", ">", now)]; - return clientFetchAllDataFromCollection( - collectionName, + return clientFetchAllDataFromCollection( + TableNames.BOOKING, futureQueryConstraints ); }; +export const fetchAllBookings = async ( + limit: number, + last: any +): Promise => { + return getPaginatedData( + TableNames.BOOKING, + limit, + "requestedAt", + last + ); +}; + export const getOldSafetyTrainingEmails = () => { //TODO: implement this return []; @@ -120,6 +133,12 @@ export const cancel = async (id: string, email: string) => { headerMessage, BookingStatusLabel.CANCELED ); + clientSendBookingDetailEmail( + id, + getApprovalCcEmail(process.env.NEXT_PUBLIC_BRANCH_NAME), + headerMessage, + BookingStatusLabel.NO_SHOW + ); clientSendBookingDetailEmail( id, getCancelCcEmail(), @@ -287,6 +306,12 @@ export const noShow = async (id: string, email: string) => { headerMessage, BookingStatusLabel.NO_SHOW ); + clientSendBookingDetailEmail( + id, + getApprovalCcEmail(process.env.NEXT_PUBLIC_BRANCH_NAME), + headerMessage, + BookingStatusLabel.NO_SHOW + ); clientSendConfirmationEmail( id, BookingStatusLabel.NO_SHOW, diff --git a/booking-app/components/src/types.ts b/booking-app/components/src/types.ts index 7901a284..258ca767 100644 --- a/booking-app/components/src/types.ts +++ b/booking-app/components/src/types.ts @@ -194,6 +194,7 @@ export enum PagePermission { BOOKING = 0, PA, LIAISON, + EQUIPMENT, ADMIN, } @@ -201,6 +202,7 @@ export enum PageContextLevel { USER = 0, LIAISON, PA, + EQUIPMENT, ADMIN, } diff --git a/booking-app/lib/firebase/firebase.ts b/booking-app/lib/firebase/firebase.ts index 34741c9f..d777eb13 100644 --- a/booking-app/lib/firebase/firebase.ts +++ b/booking-app/lib/firebase/firebase.ts @@ -10,8 +10,10 @@ import { getDoc, getDocs, limit, + orderBy, query, setDoc, + startAfter, updateDoc, where, } from "@firebase/firestore"; @@ -65,6 +67,68 @@ export const clientFetchAllDataFromCollection = async ( return data; }; +export const clientFetchAllDataFromCollectionWithLimitAndOffset = async ( + collectionName: TableNames, + limitNumber: number, + offset: number +): Promise => { + const db = getDb(); + const colRef = collection(db, collectionName); + const q = query(colRef, limit(limitNumber), where("offset", ">=", offset)); + const snapshot = await getDocs(q); + const data = snapshot.docs.map((document) => ({ + id: document.id, + ...(document.data() as unknown as T), + })); + return data; +} + +export const getPaginatedData = async ( + collectionName, + itemsPerPage = 10, + orderByField = 'requestedAt', + lastVisible = null + ) : Promise => { + try { + const db = getDb(); + + // Create reference to collection + const colRef = collection(db, collectionName); + + // Build query + let q = query( + colRef, + orderBy(orderByField, 'desc'), + limit(itemsPerPage) + ); + + // If we have a last visible item, start after it + if (lastVisible) { + console.log(lastVisible); + q = query( + colRef, + orderBy(orderByField, 'desc'), + startAfter(lastVisible), + limit(itemsPerPage) + ); + } + + // Execute query + const snapshot = await getDocs(q); + + // Convert snapshot to data array + const items = snapshot.docs.map(doc => ({ + id: doc.id, + ...doc.data() as unknown as T + })); + console.log(items) + return items; + } catch (error) { + console.error('Error getting paginated data:', error); + throw error; + } +}; + export const clientGetFinalApproverEmailFromDatabase = async (): Promise< string | null > => { diff --git a/booking-app/playwright-report/index.html b/booking-app/playwright-report/index.html new file mode 100644 index 00000000..8c9e3b4e --- /dev/null +++ b/booking-app/playwright-report/index.html @@ -0,0 +1,71 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/booking-app/test-results/.last-run.json b/booking-app/test-results/.last-run.json new file mode 100644 index 00000000..cbcc1fba --- /dev/null +++ b/booking-app/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file