diff --git a/app/components/booking/create-booking-dialog.tsx b/app/components/booking/create-booking-dialog.tsx new file mode 100644 index 000000000..1bf9f97d2 --- /dev/null +++ b/app/components/booking/create-booking-dialog.tsx @@ -0,0 +1,80 @@ +import { cloneElement, useState } from "react"; +import type { TeamMember } from "@prisma/client"; +import { useLoaderData } from "@remix-run/react"; +import { CalendarRangeIcon } from "lucide-react"; +import { useSearchParams } from "~/hooks/search-params"; +import { getBookingDefaultStartEndTimes } from "~/utils/date-fns"; +import { tw } from "~/utils/tw"; +import { BookingForm } from "./form"; +import { Dialog, DialogPortal } from "../layout/dialog"; + +type CreateBookingDialogProps = { + className?: string; + trigger: React.ReactElement<{ onClick: () => void }>; +}; + +export default function CreateBookingDialog({ + className, + trigger, +}: CreateBookingDialogProps) { + const { teamMembers, isSelfServiceOrBase } = useLoaderData<{ + teamMembers: TeamMember[]; + isSelfServiceOrBase: boolean; + }>(); + + // The loader already takes care of returning only the current user so we just get the first and only element in the array + const custodianRef = isSelfServiceOrBase ? teamMembers[0]?.id : undefined; + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [searchParams] = useSearchParams(); + + const assetIds = searchParams.getAll("assetId"); + + const { startDate, endDate } = getBookingDefaultStartEndTimes(); + + function openDialog() { + setIsDialogOpen(true); + } + + function closeDialog() { + setIsDialogOpen(false); + } + + return ( + <> + {cloneElement(trigger, { onClick: openDialog })} + + + + + + } + > + + + Create new booking + + Choose a name for your booking, select a start and end time and + choose the custodian. Based on the selected information, asset + availability will be determined. + + + + + + + + > + ); +} diff --git a/app/components/booking/form.tsx b/app/components/booking/form.tsx index 92530a48e..ec7b8a313 100644 --- a/app/components/booking/form.tsx +++ b/app/components/booking/form.tsx @@ -124,6 +124,12 @@ type BookingFormData = { bookingFlags?: BookingFlags; assetIds?: string[] | null; description?: string | null; + + /** + * In case if the form is rendered outside of /edit or /new booking, + * then we can pass `action` to submit form + */ + action?: string; }; export function BookingForm({ @@ -136,6 +142,7 @@ export function BookingForm({ bookingFlags, assetIds, description, + action, }: BookingFormData) { const navigation = useNavigation(); const { teamMembers } = useLoaderData(); @@ -191,7 +198,7 @@ export function BookingForm({ return ( - + {/* Hidden input for expired state. Helps is know what status we should set on the server, when the booking is getting checked out */} {isExpired && } diff --git a/app/components/calendar/title-container.tsx b/app/components/calendar/title-container.tsx new file mode 100644 index 000000000..35b1e548d --- /dev/null +++ b/app/components/calendar/title-container.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useLoaderData } from "@remix-run/react"; +import { useViewportHeight } from "~/hooks/use-viewport-height"; + +type TitleContainerProps = { + className?: string; + calendarTitle?: string; + calendarSubtitle?: string; + calendarView: string; +}; + +export default function TitleContainer({ + className, + calendarTitle, + calendarSubtitle, + calendarView, +}: TitleContainerProps) { + const { title } = useLoaderData<{ title: string }>(); + const { isMd } = useViewportHeight(); + + const titleToRender = useMemo(() => { + if (calendarTitle) { + return calendarTitle; + } + + return title; + }, [calendarTitle, title]); + + return ( + + + {titleToRender} + + {!isMd || calendarView == "timeGridWeek" ? ( + {calendarSubtitle} + ) : null} + + ); +} diff --git a/app/routes/_layout+/bookings.tsx b/app/routes/_layout+/bookings.tsx index db92be798..85df63bd9 100644 --- a/app/routes/_layout+/bookings.tsx +++ b/app/routes/_layout+/bookings.tsx @@ -7,6 +7,7 @@ import { Link, Outlet, useMatches, useNavigate } from "@remix-run/react"; import { ChevronRight } from "lucide-react"; import { AvailabilityBadge } from "~/components/booking/availability-label"; import BulkActionsDropdown from "~/components/booking/bulk-actions-dropdown"; +import CreateBookingDialog from "~/components/booking/create-booking-dialog"; import { StatusFilter } from "~/components/booking/status-filter"; import DynamicDropdown from "~/components/dynamic-dropdown/dynamic-dropdown"; import { ErrorContent } from "~/components/errors"; @@ -185,6 +186,7 @@ export async function loader({ context, request }: LoaderFunctionArgs) { perPage, modelName, ...teamMembersData, + isSelfServiceOrBase, }), { headers: [ @@ -272,15 +274,17 @@ export default function BookingsIndexPage({ > {!isChildBookingsPage ? ( - - New booking - + + New booking + + } + /> ) : null} diff --git a/app/routes/_layout+/calendar.tsx b/app/routes/_layout+/calendar.tsx index 462b2f86a..a7851afb5 100644 --- a/app/routes/_layout+/calendar.tsx +++ b/app/routes/_layout+/calendar.tsx @@ -12,8 +12,10 @@ import { HoverCardPortal } from "@radix-ui/react-hover-card"; import { ChevronLeftIcon, ChevronRightIcon } from "@radix-ui/react-icons"; import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; import { json } from "@remix-run/node"; -import { Link, useLoaderData } from "@remix-run/react"; +import { Link } from "@remix-run/react"; import { ClientOnly } from "remix-utils/client-only"; +import CreateBookingDialog from "~/components/booking/create-booking-dialog"; +import TitleContainer from "~/components/calendar/title-container"; import FallbackLoading from "~/components/dashboard/fallback-loading"; import { ErrorContent } from "~/components/errors"; import { ArrowRightIcon } from "~/components/icons/library"; @@ -30,7 +32,9 @@ import { import { Spinner } from "~/components/shared/spinner"; import { UserBadge } from "~/components/shared/user-badge"; import When from "~/components/when/when"; +import { hasGetAllValue } from "~/hooks/use-model-filters"; import { useViewportHeight } from "~/hooks/use-viewport-height"; +import { getTeamMemberForCustodianFilter } from "~/modules/team-member/service.server"; import calendarStyles from "~/styles/layout/calendar.css?url"; import { appendToMetaTitle } from "~/utils/append-to-meta-title"; import { bookingStatusColorMap } from "~/utils/bookings"; @@ -41,7 +45,8 @@ import { } from "~/utils/calendar"; import { getWeekStartingAndEndingDates } from "~/utils/date-fns"; import { makeShelfError, ShelfError } from "~/utils/error"; -import { data, error } from "~/utils/http.server"; +import { data, error, getCurrentSearchParams } from "~/utils/http.server"; +import { getParamsValues } from "~/utils/list"; import { isPersonalOrg } from "~/utils/organization"; import { PermissionAction, @@ -77,12 +82,13 @@ export const loader = async ({ request, context }: LoaderFunctionArgs) => { const { userId } = authSession; try { - const { currentOrganization } = await requirePermission({ - userId, - request, - entity: PermissionEntity.booking, - action: PermissionAction.read, - }); + const { isSelfServiceOrBase, currentOrganization, organizationId } = + await requirePermission({ + userId, + request, + entity: PermissionEntity.booking, + action: PermissionAction.read, + }); if (isPersonalOrg(currentOrganization)) { throw new ShelfError({ @@ -107,7 +113,20 @@ export const loader = async ({ request, context }: LoaderFunctionArgs) => { const title = `${currentMonth} ${currentYear}`; - return json(data({ header, title })); + const searchParams = getCurrentSearchParams(request); + const { teamMemberIds } = getParamsValues(searchParams); + + const teamMembersData = await getTeamMemberForCustodianFilter({ + organizationId, + selectedTeamMembers: teamMemberIds, + getAll: + searchParams.has("getAll") && + hasGetAllValue(searchParams, "teamMember"), + isSelfService: isSelfServiceOrBase, // we can assume this is false because this view is not allowed for + userId, + }); + + return json(data({ header, title, ...teamMembersData })); } catch (cause) { const reason = makeShelfError(cause); throw json(error(reason), { status: reason.status }); @@ -125,11 +144,10 @@ export const DATE_FORMAT_OPTIONS = { // Calendar Component export default function Calendar() { - const { title } = useLoaderData(); const { isMd } = useViewportHeight(); const [startingDay, endingDay] = getWeekStartingAndEndingDates(new Date()); const [_error, setError] = useState(null); - const [calendarTitle, setCalendarTitle] = useState(title); + const [calendarTitle, setCalendarTitle] = useState(); const [calendarSubtitle, setCalendarSubtitle] = useState( isMd ? undefined : `${startingDay} - ${endingDay}` ); @@ -252,17 +270,19 @@ export default function Calendar() { return ( <> - + + New booking} + /> + + - - - {calendarTitle} - - {!isMd || calendarView == "timeGridWeek" ? ( - {calendarSubtitle} - ) : null} - +
+ Choose a name for your booking, select a start and end time and + choose the custodian. Based on the selected information, asset + availability will be determined. +