Skip to content

Commit

Permalink
Merge pull request #892 from Shelf-nu/booking-add-to-calendar
Browse files Browse the repository at this point in the history
testing feature that allows users to add a specific booking to their …
  • Loading branch information
DonKoko authored Apr 5, 2024
2 parents afc3170 + 69c25c1 commit 714430b
Show file tree
Hide file tree
Showing 5 changed files with 174 additions and 19 deletions.
36 changes: 36 additions & 0 deletions app/components/booking/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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
*/
Expand Down Expand Up @@ -300,6 +307,9 @@ export function BookingForm({
assets during the duration of the booking period.
</p>
</Card>
{!routeIsNewBooking && (
<AddToCalendar disabled={disabled || isDraft || isCancelled} />
)}
</div>
</div>
</div>
Expand All @@ -312,3 +322,29 @@ export function BookingForm({
</div>
);
}

const AddToCalendar = ({ disabled }: { disabled: boolean }) => (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<Button
to={`cal.ics`}
download={true}
reloadDocument={true}
disabled={disabled}
variant="secondary"
icon="calendar"
>
Add to calendar
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">
{disabled
? "Not possible to add to calendar due to booking status"
: "Download this booking as a calendar event"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
5 changes: 4 additions & 1 deletion app/components/shared/icons-map.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CalendarIcon } from "@radix-ui/react-icons";
import { Spinner } from "./spinner";

import {
Expand Down Expand Up @@ -57,7 +58,8 @@ export type Icon =
| "help"
| "profile"
| "send"
| "user";
| "user"
| "calendar";

type IconsMap = {
[key in Icon]: JSX.Element;
Expand Down Expand Up @@ -91,6 +93,7 @@ export const iconsMap: IconsMap = {
logout: <LogoutIcon />,
send: <SendIcon />,
user: <UserIcon />,
calendar: <CalendarIcon className="size-5" />,
};

export default iconsMap;
85 changes: 85 additions & 0 deletions app/routes/_layout+/bookings.$bookingId.cal[.ics].ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
40 changes: 22 additions & 18 deletions app/routes/_layout+/bookings.$bookingId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -533,11 +535,13 @@ export default function BookingEditPage() {
<Header
title={hasName ? name : booking.name}
subHeading={
<Badge color={bookingStatusColorMap[booking.status]}>
<span className="block lowercase first-letter:uppercase">
{booking.status}
</span>
</Badge>
<div className="flex items-center gap-2">
<Badge color={bookingStatusColorMap[booking.status]}>
<span className="block lowercase first-letter:uppercase">
{booking.status}
</span>
</Badge>
</div>
}
/>

Expand Down
27 changes: 27 additions & 0 deletions app/utils/date-fns.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import type { ClientHint } from "~/modules/booking/types";
import { getDateTimeFormatFromHints } from "./client-hints";

export function getDifferenceInSeconds(
dateLeft: Date,
dateRight: Date
Expand Down Expand Up @@ -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();

Expand Down

0 comments on commit 714430b

Please sign in to comment.