diff --git a/app/components/booking/form.tsx b/app/components/booking/form.tsx index 6f8f64d8b..fa3be95bd 100644 --- a/app/components/booking/form.tsx +++ b/app/components/booking/form.tsx @@ -8,6 +8,12 @@ import { useAtom } from "jotai"; import { useZorm } from "react-zorm"; import { z } from "zod"; import { updateDynamicTitleAtom } from "~/atoms/dynamic-title-atom"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/shared/tooltip"; import { useBookingStatus } from "~/hooks/use-booking-status"; import { useUserIsSelfService } from "~/hooks/user-user-is-self-service"; import type { BookingWithCustodians } from "~/routes/_layout+/bookings"; @@ -21,6 +27,7 @@ import { AbsolutePositionedHeaderActions } from "../layout/header/absolute-posit import { Button } from "../shared"; import { Card } from "../shared/card"; import { ControlledActionButton } from "../shared/controlled-action-button"; + /** * Important note is that the fields are only valudated when they are not disabled */ @@ -300,6 +307,9 @@ export function BookingForm({ assets during the duration of the booking period.

+ {!routeIsNewBooking && ( + + )} @@ -312,3 +322,29 @@ export function BookingForm({ ); } + +const AddToCalendar = ({ disabled }: { disabled: boolean }) => ( + + + + + + +

+ {disabled + ? "Not possible to add to calendar due to booking status" + : "Download this booking as a calendar event"} +

+
+
+
+); diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index ba45aded5..d28619a9b 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -1,3 +1,4 @@ +import { CalendarIcon } from "@radix-ui/react-icons"; import { Spinner } from "./spinner"; import { @@ -57,7 +58,8 @@ export type Icon = | "help" | "profile" | "send" - | "user"; + | "user" + | "calendar"; type IconsMap = { [key in Icon]: JSX.Element; @@ -91,6 +93,7 @@ export const iconsMap: IconsMap = { logout: , send: , user: , + calendar: , }; export default iconsMap; diff --git a/app/routes/_layout+/bookings.$bookingId.cal[.ics].ts b/app/routes/_layout+/bookings.$bookingId.cal[.ics].ts new file mode 100644 index 000000000..0b7b0f2cc --- /dev/null +++ b/app/routes/_layout+/bookings.$bookingId.cal[.ics].ts @@ -0,0 +1,85 @@ +import { OrganizationRoles } from "@prisma/client"; +import { json, type LoaderFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { getBooking } from "~/modules/booking/service.server"; +import { getClientHint } from "~/utils/client-hints"; +import { formatDatesForICal } from "~/utils/date-fns"; +import { SERVER_URL } from "~/utils/env"; +import { makeShelfError, ShelfError } from "~/utils/error"; +import { error, getParams } from "~/utils/http.server"; +import { PermissionAction, PermissionEntity } from "~/utils/permissions/types"; +import { requirePermission } from "~/utils/roles.server"; + +export async function loader({ request, context, params }: LoaderFunctionArgs) { + const authSession = context.getSession(); + const { userId } = authSession; + const { bookingId } = getParams(params, z.object({ bookingId: z.string() }), { + additionalData: { userId }, + }); + + try { + /** Check if the current user is allowed to read booking */ + const { organizationId, role } = await requirePermission({ + userId: authSession.userId, + request, + entity: PermissionEntity.booking, + action: PermissionAction.read, + }); + const booking = await getBooking({ + id: bookingId, + organizationId: organizationId, + }); + + /** Check if the user is self service */ + const isSelfService = role === OrganizationRoles.SELF_SERVICE; + + /** For self service users, we only allow them to read their own bookings */ + if (isSelfService && booking.custodianUserId !== authSession.userId) { + throw new ShelfError({ + cause: null, + message: + "You are not authorized to download the calendar for this booking", + status: 403, + label: "Booking", + shouldBeCaptured: false, + }); + } + const hints = getClientHint(request); + + const formattedFromDate = formatDatesForICal(booking.from as Date, hints); + const formattedToDate = formatDatesForICal(booking.to as Date, hints); + const formattedDTSTAMP = formatDatesForICal(new Date(Date.now()), hints); + + const ics = ` +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//ZContent.net//Zap Calendar 1.0//EN +CALSCALE:GREGORIAN +METHOD:PUBLISH +BEGIN:VEVENT +SUMMARY:${booking.name} +UID:${booking.id} +SEQUENCE:${Date.now()} +STATUS:CONFIRMED +TRANSP:TRANSPARENT +DTSTART:${formattedFromDate} +DTEND:${formattedToDate} +DTSTAMP:${formattedDTSTAMP} +CATEGORIES:Shelf.nu booking +LOCATION:shelf.nu +DESCRIPTION:Shelf.nu booking (Asset / Equipment checkout) +URL:${SERVER_URL}/bookings/${bookingId} +END:VEVENT +END:VCALENDAR`.trim(); + + return new Response(ics, { + headers: { + "Content-Type": "text/calendar", + "Content-Disposition": `attachment; filename="${booking.name} - shelf.nu.ics"`, + }, + }); + } catch (cause) { + const reason = makeShelfError(cause, { userId }); + return json(error(reason), { status: reason.status }); + } +} diff --git a/app/routes/_layout+/bookings.$bookingId.tsx b/app/routes/_layout+/bookings.$bookingId.tsx index e6b6bed18..91647f7b7 100644 --- a/app/routes/_layout+/bookings.$bookingId.tsx +++ b/app/routes/_layout+/bookings.$bookingId.tsx @@ -15,9 +15,10 @@ import { NewBookingFormSchema } from "~/components/booking/form"; import ContextualModal from "~/components/layout/contextual-modal"; import Header from "~/components/layout/header"; import type { HeaderData } from "~/components/layout/header/types"; -import { Badge } from "~/components/shared"; -import { db } from "~/database"; -import { createNotes } from "~/modules/asset"; +import { Badge } from "~/components/shared/badge"; + +import { db } from "~/database/db.server"; +import { createNotes } from "~/modules/asset/service.server"; import { deleteBooking, getBooking, @@ -84,6 +85,17 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { organizationId: organizationId, }); + /** For self service users, we only allow them to read their own bookings */ + if (isSelfService && booking.custodianUserId !== authSession.userId) { + throw new ShelfError({ + cause: null, + message: "You are not authorized to view this booking", + status: 403, + label: "Booking", + shouldBeCaptured: false, + }); + } + const [teamMembers, org, assets] = await Promise.all([ /** * We need to fetch the team members to be able to display them in the custodian dropdown. @@ -169,16 +181,6 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) { * This is useful for more consistent data in the front-end */ booking.assets = assets; - /** For self service users, we only allow them to read their own bookings */ - if (isSelfService && booking.custodianUserId !== authSession.userId) { - throw new ShelfError({ - cause: null, - message: "You are not authorized to view this booking", - status: 403, - label: "Booking", - }); - } - const { page, perPageParam } = getParamsValues(searchParams); const cookie = await updateCookieWithPerPage(request, perPageParam); const { perPage } = cookie; @@ -533,11 +535,13 @@ export default function BookingEditPage() {
- - {booking.status} - - +
+ + + {booking.status} + + +
} /> diff --git a/app/utils/date-fns.ts b/app/utils/date-fns.ts index c764fd25f..6efe9b1bf 100644 --- a/app/utils/date-fns.ts +++ b/app/utils/date-fns.ts @@ -1,3 +1,6 @@ +import type { ClientHint } from "~/modules/booking/types"; +import { getDateTimeFormatFromHints } from "./client-hints"; + export function getDifferenceInSeconds( dateLeft: Date, dateRight: Date @@ -52,6 +55,30 @@ export function getTimeRemainingMessage(date1: Date, date2: Date): string { } } +export function formatDatesForICal(date: Date, hints: ClientHint) { + const dateTimeFormat = getDateTimeFormatFromHints(hints, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); + const formatLocalDate = (date: Date, dateTimeFormat: Intl.DateTimeFormat) => { + const parts = dateTimeFormat.formatToParts(date); + const year = parts.find((part) => part.type === "year")!.value; + const month = parts.find((part) => part.type === "month")!.value; + const day = parts.find((part) => part.type === "day")!.value; + const hour = parts.find((part) => part.type === "hour")!.value; + const minute = parts.find((part) => part.type === "minute")!.value; + const second = parts.find((part) => part.type === "second")!.value; + return `${year}${month}${day}T${hour}${minute}${second}`; + }; + + return formatLocalDate(date, dateTimeFormat); +} + export function getBookingDefaultStartEndTimes() { const now = new Date();