diff --git a/app/components/assets/form.tsx b/app/components/assets/form.tsx index 9f163e66f..d3e2ab182 100644 --- a/app/components/assets/form.tsx +++ b/app/components/assets/form.tsx @@ -236,6 +236,7 @@ export const AssetForm = ({ countKey="totalCategories" closeOnSelect selectionMode="set" + allowClear extraContent={ + {" "} + Please switch to your team workspace to use this feature ) : null} . diff --git a/app/components/shared/icons-map.tsx b/app/components/shared/icons-map.tsx index 63ec00a9d..ef100b60a 100644 --- a/app/components/shared/icons-map.tsx +++ b/app/components/shared/icons-map.tsx @@ -33,6 +33,7 @@ import { GraphIcon, ScanQRIcon, SwitchIcon, + BookingsIcon, } from "../icons/library"; /** The possible options for icons to be rendered in the button */ @@ -69,7 +70,8 @@ export type IconType = | "calendar" | "graph" | "scanQR" - | "switch"; + | "switch" + | "bookings"; type IconsMap = { [key in IconType]: JSX.Element; @@ -106,6 +108,7 @@ export const iconsMap: IconsMap = { send: , user: , calendar: , + bookings: , graph: , scanQR: , switch: , diff --git a/app/database/schema.prisma b/app/database/schema.prisma index 2d04a2500..751433fad 100644 --- a/app/database/schema.prisma +++ b/app/database/schema.prisma @@ -187,7 +187,6 @@ enum NoteType { } model Qr { - // @TODO we might need to change this to shorten the length of the id id String @id @default(cuid()) // Version of the QR code based on spec from Denso wave diff --git a/app/hooks/use-main-menu-items.tsx b/app/hooks/use-main-menu-items.tsx index 80a8a2ef2..641e89881 100644 --- a/app/hooks/use-main-menu-items.tsx +++ b/app/hooks/use-main-menu-items.tsx @@ -31,6 +31,11 @@ export function useMainMenuItems() { }, { icon: , + to: "calendar", + label: "Calendar", + }, + { + icon: , to: "bookings", label: "Bookings (beta)", }, diff --git a/app/modules/booking/service.server.ts b/app/modules/booking/service.server.ts index 3ed9be769..770137431 100644 --- a/app/modules/booking/service.server.ts +++ b/app/modules/booking/service.server.ts @@ -8,9 +8,12 @@ import { } from "@prisma/client"; import { db } from "~/database/db.server"; import { bookingUpdatesTemplateString } from "~/emails/bookings-updates-template"; +import { getStatusClasses, isOneDayEvent } from "~/utils/calendar"; import { calcTimeDifference } from "~/utils/date-fns"; +import { sendNotification } from "~/utils/emitter/send-notification.server"; import type { ErrorLabel } from "~/utils/error"; import { ShelfError } from "~/utils/error"; +import { getCurrentSearchParams } from "~/utils/http.server"; import { Logger } from "~/utils/logger"; import { sendEmail } from "~/utils/mail.server"; import { scheduler } from "~/utils/scheduler.server"; @@ -22,7 +25,7 @@ import { deletedBookingEmailContent, sendCheckinReminder, } from "./email-helpers"; -import type { ClientHint, SchedulerData } from "./types"; +import type { BookingUpdateIntent, ClientHint, SchedulerData } from "./types"; import { createNotes } from "../asset/service.server"; import { getOrganizationAdminsEmails } from "../organization/service.server"; @@ -451,6 +454,7 @@ export async function getBookings(params: { bookingFrom?: Booking["from"] | null; bookingTo?: Booking["to"] | null; userId: Booking["creatorId"]; + extraInclude?: Prisma.BookingInclude; }) { const { organizationId, @@ -465,6 +469,7 @@ export async function getBookings(params: { excludeBookingIds, bookingFrom, userId, + extraInclude, } = params; try { @@ -570,6 +575,7 @@ export async function getBookings(params: { profilePicture: true, }, }, + ...(extraInclude || undefined), }, orderBy: { from: "asc" }, }), @@ -766,3 +772,150 @@ export async function getBooking( }); } } + +export async function getBookingsForCalendar(params: { + request: Request; + organizationId: Organization["id"]; + userId: string; + isSelfService: boolean; +}) { + const { request, organizationId, userId, isSelfService = false } = params; + const searchParams = getCurrentSearchParams(request); + + const start = searchParams.get("start") as string; + const end = searchParams.get("end") as string; + + try { + const { bookings } = await getBookings({ + organizationId, + page: 1, + perPage: 1000, + userId, + bookingFrom: new Date(start), + bookingTo: new Date(end), + ...(isSelfService && { + // If the user is self service, we only show bookings that belong to that user) + custodianUserId: userId, + }), + extraInclude: { + custodianTeamMember: true, + custodianUser: true, + }, + }); + + const events = bookings + .filter((booking) => booking.from && booking.to) + .map((booking) => { + const custodianName = booking?.custodianUser + ? `${booking.custodianUser.firstName} ${booking.custodianUser.lastName}` + : booking.custodianTeamMember?.name; + + return { + title: `${booking.name} | ${custodianName}`, + start: (booking.from as Date).toISOString(), + end: (booking.to as Date).toISOString(), + url: `/bookings/${booking.id}`, + classNames: [ + `bookingId-${booking.id}`, + ...getStatusClasses( + booking.status, + isOneDayEvent(booking.from as Date, booking.to as Date) + ), + ], + extendedProps: { + status: booking.status, + id: booking.id, + }, + }; + }); + + return events; + } catch (cause) { + throw new ShelfError({ + cause, + message: + "Something went wrong while fetching the bookings for the calendar. Please try again or contact support.", + additionalData: { ...params }, + label, + }); + } +} +export async function createNotesForBookingUpdate( + intent: BookingUpdateIntent, + booking: Booking & { assets: Pick[] }, + user: { firstName: string; lastName: string; id: string } +) { + switch (intent) { + case "checkOut": + await createNotes({ + content: `**${user?.firstName?.trim()} ${user?.lastName?.trim()}** checked out asset with **[${ + booking.name + }](/bookings/${booking.id})**.`, + type: "UPDATE", + userId: user.id, + assetIds: booking.assets.map((a) => a.id), + }); + break; + case "checkIn": + /** Create check-in notes for all assets */ + await createNotes({ + content: `**${user?.firstName?.trim()} ${user?.lastName?.trim()}** checked in asset with **[${ + booking.name + }](/bookings/${booking.id})**.`, + type: "UPDATE", + userId: user.id, + assetIds: booking.assets.map((a) => a.id), + }); + break; + default: + break; + } +} + +export function sendBookingUpdateNotification( + intent: BookingUpdateIntent, + senderId: string +) { + /** The cases that are not covered here is because the action already reutns within the switch and takes care of the notification */ + switch (intent) { + case "save": + sendNotification({ + title: "Booking saved", + message: "Your booking has been saved successfully", + icon: { name: "success", variant: "success" }, + senderId, + }); + break; + case "reserve": + /** Send reserved notification */ + sendNotification({ + title: "Booking reserved", + message: "Your booking has been reserved successfully", + icon: { name: "success", variant: "success" }, + senderId, + }); + + break; + + case "checkOut": + sendNotification({ + title: "Booking checked-out", + message: "Your booking has been checked-out successfully", + icon: { name: "success", variant: "success" }, + senderId, + }); + + break; + case "checkIn": + sendNotification({ + title: "Booking checked-in", + message: "Your booking has been checked-in successfully", + icon: { name: "success", variant: "success" }, + senderId, + }); + break; + + default: + break; + } +} diff --git a/app/modules/booking/types.ts b/app/modules/booking/types.ts index 061843d5c..29b30fdab 100644 --- a/app/modules/booking/types.ts +++ b/app/modules/booking/types.ts @@ -24,3 +24,13 @@ export interface SchedulerData { hints: ClientHint; eventType: bookingSchedulerEventsEnum; } + +export type BookingUpdateIntent = + | "save" + | "reserve" + | "delete" + | "removeAsset" + | "checkOut" + | "checkIn" + | "archive" + | "cancel"; diff --git a/app/modules/custom-field/service.server.ts b/app/modules/custom-field/service.server.ts index 3f436e046..14e642e14 100644 --- a/app/modules/custom-field/service.server.ts +++ b/app/modules/custom-field/service.server.ts @@ -181,7 +181,6 @@ export async function upsertCustomField( }); if (!existingCustomField) { - // @TODO not sure how to handle this case const newCustomField = await createCustomField(def); customFields[def.name] = newCustomField; } else { diff --git a/app/modules/user/service.server.ts b/app/modules/user/service.server.ts index 04ce6cd2b..19388b0e1 100644 --- a/app/modules/user/service.server.ts +++ b/app/modules/user/service.server.ts @@ -215,8 +215,10 @@ export async function createUser( organizationIds.push(organizationId); } - // @TODO this is weird and organizationIds is not used - // We haev to figure out why its being called 2 times and whats going on here + /** Create user organization association + * 1. For the personal org + * 2. For the org that the user is being attached to + */ await Promise.all([ createUserOrgAssociation(tx, { userId: user.id, diff --git a/app/root.tsx b/app/root.tsx index 9fa08b97e..f62341f5e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -81,7 +81,7 @@ function Document({ children, title }: PropsWithChildren<{ title?: string }>) { - +