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 }) => (
+
+
+
+
+ Add to calendar
+
+
+
+
+ {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() {