WIP bookings page ui changes, created api endpoint

This commit is contained in:
Alan 2022-03-23 11:55:06 -07:00
parent 77266535e5
commit 8b92475097
8 changed files with 422 additions and 41 deletions

View File

@ -4,12 +4,13 @@ import dayjs from "dayjs";
import { useState } from "react";
import { useMutation } from "react-query";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/Dialog";
import { TextArea } from "@calcom/ui/form/fields";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import * as fetch from "@lib/core/http/fetch-wrapper";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { useMeQuery } from "@components/Shell";
@ -17,6 +18,69 @@ import TableActions, { ActionType } from "@components/ui/TableActions";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
interface IRescheduleDialog {
isOpenDialog: boolean;
setIsOpenDialog: () => void;
bookingUId: string;
}
const RescheduleDialog = (props: IRescheduleDialog) => {
const { t } = useLocale();
const { isOpenDialog, setIsOpenDialog, bookingUId: bookingId } = props;
const [rescheduleReason, setRescheduleReason] = useState("");
const rescheduleApi = async () => {
await fetch.post(`${process.env.BASE_URL}/api/reschedule`, { bookingId, rescheduleReason });
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent>
<DialogClose asChild>
<div className="fixed top-1 right-1 flex h-8 w-8 justify-center rounded-full hover:bg-gray-200">
<XIcon className="w-4" />
</div>
</DialogClose>
<div style={{ display: "flex", flexDirection: "row" }}>
<div className="flex h-10 w-10 flex-shrink-0 justify-center rounded-full bg-[#FAFAFA]">
<ClockIcon className="m-auto h-6 w-6"></ClockIcon>
</div>
<div className="px-4 pt-1">
<DialogHeader title={"Send reschedule request"} />
<p className="-mt-8 text-sm text-gray-500">
This will cancel the scheduled meeting, notify the scheduler and ask them to pick a new time.
</p>
<p className="mt-6 mb-2 text-sm font-bold text-black">
Reason for reschedule request
<span className="font-normal text-gray-500"> (Optional)</span>
</p>
<TextArea
name={t("rejection_reason")}
value={rescheduleReason}
onChange={(e) => setRescheduleReason(e.target.value)}
className="mb-5 sm:mb-6"
/>
<DialogFooter>
<DialogClose>
<Button color="secondary">{t("cancel")}</Button>
</DialogClose>
<Button
// disabled={mutation.isLoading}
onClick={() => {
// mutation.mutate(false);
}}>
Send reschedule request
</Button>
</DialogFooter>
</div>
</div>
</DialogContent>
</Dialog>
);
};
function BookingListItem(booking: BookingItem) {
// Get user so we can determine 12/24 hour format preferences
const query = useMeQuery();
@ -80,15 +144,29 @@ function BookingListItem(booking: BookingItem) {
{
id: "reschedule",
label: t("reschedule"),
href: `/reschedule/${booking.uid}`,
icon: ClockIcon,
actions: [
{
id: "edit",
// @TODO: add translate
label: "Edit booking",
href: "",
},
{
id: "reschedule_request",
// @TODO: add translate
label: "Reschedule booking",
onClick: () => setIsOpenRescheduleDialog(true),
},
],
},
];
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
return (
<>
<RescheduleDialog isOpenDialog={isOpenRescheduleDialog} setIsOpenDialog={setIsOpenRescheduleDialog} />
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent>
<DialogHeader title={t("rejection_reason_title")} />

View File

@ -1,7 +1,6 @@
import { DotsHorizontalIcon } from "@heroicons/react/solid";
import React, { FC } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import Button from "@calcom/ui/Button";
import Dropdown, { DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@calcom/ui/Dropdown";
@ -9,56 +8,72 @@ import { SVGComponent } from "@lib/types/SVGComponent";
export type ActionType = {
id: string;
icon: SVGComponent;
icon?: SVGComponent;
label: string;
disabled?: boolean;
color?: "primary" | "secondary";
} & ({ href?: never; onClick: () => any } | { href: string; onClick?: never });
} & ({ href?: never; onClick: () => any } | { href?: string; onClick?: never }) & { actions?: ActionType[] };
interface Props {
actions: ActionType[];
}
const DropdownActions = ({ actions, actionTrigger }: { actions: ActionType[]; actionTrigger?: any }) => {
return (
<Dropdown>
{!actionTrigger ? (
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
) : (
<DropdownMenuTrigger asChild>{actionTrigger}</DropdownMenuTrigger>
)}
<DropdownMenuContent portalled>
{actions.map((action) => (
<DropdownMenuItem key={action.id} className="focus-visible:outline-none">
<Button
type="button"
size="lg"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
);
};
const TableActions: FC<Props> = ({ actions }) => {
const { t } = useLocale();
return (
<>
<div className="hidden space-x-2 rtl:space-x-reverse lg:block">
{actions.map((action) => (
<Button
key={action.id}
data-testid={action.id}
href={action.href}
onClick={action.onClick}
StartIcon={action.icon}
disabled={action.disabled}
color={action.color || "secondary"}>
{action.label}
</Button>
))}
{actions.map((action) => {
const button = (
<Button
key={action.id}
data-testid={action.id}
href={action.href}
onClick={action.onClick}
StartIcon={action.icon}
disabled={action.disabled}
color={action.color || "secondary"}>
{action.label}
</Button>
);
if (!action.actions) {
return button;
}
return <DropdownActions key={action.id} actions={action.actions} actionTrigger={button} />;
})}
</div>
<div className="inline-block text-left lg:hidden">
<Dropdown>
<DropdownMenuTrigger className="h-[38px] w-[38px] cursor-pointer rounded-sm border border-transparent text-neutral-500 hover:border-gray-300 hover:text-neutral-900">
<DotsHorizontalIcon className="h-5 w-5 group-hover:text-gray-800" />
</DropdownMenuTrigger>
<DropdownMenuContent portalled>
{actions.map((action) => (
<DropdownMenuItem key={action.id}>
<Button
type="button"
size="lg"
color="minimal"
className="w-full rounded-none font-normal"
href={action.href}
StartIcon={action.icon}
onClick={action.onClick}>
{action.label}
</Button>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
<DropdownActions actions={actions} />
</div>
</>
);

View File

@ -14,6 +14,8 @@ import OrganizerScheduledEmail from "@lib/emails/templates/organizer-scheduled-e
import TeamInviteEmail, { TeamInvite } from "@lib/emails/templates/team-invite-email";
import { CalendarEvent, Person } from "@lib/integrations/calendar/interfaces/Calendar";
import OrganizerRequestRescheduledEmail from "./templates/organizer-request-reschedule-email";
export const sendScheduledEmails = async (calEvent: CalendarEvent) => {
const emailsToSend: Promise<unknown>[] = [];
@ -207,3 +209,14 @@ export const sendTeamInviteEmail = async (teamInviteEvent: TeamInvite) => {
}
});
};
export const sendRescheduleEmail = async (calEvent: CalendarEvent) => {
await new Promise((resolve, reject) => {
try {
const rescheduleEmail = new OrganizerRequestRescheduledEmail(calEvent);
resolve(rescheduleEmail.sendEmail());
} catch (e) {
reject(console.error("RescheduleEmail.sendEmail failed", e));
}
});
};

View File

@ -0,0 +1,160 @@
import dayjs from "dayjs";
import localizedFormat from "dayjs/plugin/localizedFormat";
import timezone from "dayjs/plugin/timezone";
import toArray from "dayjs/plugin/toArray";
import utc from "dayjs/plugin/utc";
import { getCancelLink } from "@lib/CalEventParser";
import {
emailHead,
emailSchedulingBodyHeader,
emailBodyLogo,
emailScheduledBodyHeaderContent,
emailSchedulingBodyDivider,
} from "./common";
import OrganizerScheduledEmail from "./organizer-scheduled-email";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(localizedFormat);
dayjs.extend(toArray);
export default class OrganizerRequestRescheduledEmail extends OrganizerScheduledEmail {
protected getNodeMailerPayload(): Record<string, unknown> {
const toAddresses = [this.calEvent.organizer.email];
if (this.calEvent.team) {
this.calEvent.team.members.forEach((member) => {
const memberAttendee = this.calEvent.attendees.find((attendee) => attendee.name === member);
if (memberAttendee) {
toAddresses.push(memberAttendee.email);
}
});
}
return {
icalEvent: {
filename: "event.ics",
content: this.getiCalEventAsString(),
},
from: `Cal.com <${this.getMailerOptions().from}>`,
to: toAddresses.join(","),
subject: `${this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
})}`,
html: this.getHtmlBody(),
text: this.getTextBody(),
};
}
protected getTextBody(): string {
return `
${this.calEvent.organizer.language.translate("event_has_been_rescheduled")}
${this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")}
${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.calEvent.organizer.language.translate("need_to_reschedule_or_cancel")}
${getCancelLink(this.calEvent)}
`.replace(/(<([^>]+)>)/gi, "");
}
protected getHtmlBody(): string {
const headerContent = this.calEvent.organizer.language.translate("rescheduled_event_type_subject", {
eventType: this.calEvent.type,
name: this.calEvent.attendees[0].name,
date: `${this.getOrganizerStart().format("h:mma")} - ${this.getOrganizerEnd().format(
"h:mma"
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("dddd").toLowerCase()
)}, ${this.calEvent.organizer.language.translate(
this.getOrganizerStart().format("MMMM").toLowerCase()
)} ${this.getOrganizerStart().format("D")}, ${this.getOrganizerStart().format("YYYY")}`,
});
return `
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
${emailHead(headerContent)}
<body style="word-spacing:normal;background-color:#F5F5F5;">
<div style="background-color:#F5F5F5;">
${emailSchedulingBodyHeader("calendarCircle")}
${emailScheduledBodyHeaderContent(
this.calEvent.organizer.language.translate("event_has_been_rescheduled"),
this.calEvent.organizer.language.translate("emailed_you_and_any_other_attendees")
)}
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:1;text-align:left;color:#3E3E3E;">
${this.getWhat()}
${this.getWhen()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailSchedulingBodyDivider()}
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" style="width:600px;" width="600" bgcolor="#FFFFFF" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="background:#FFFFFF;background-color:#FFFFFF;margin:0px auto;max-width:600px;">
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#FFFFFF;background-color:#FFFFFF;width:100%;">
<tbody>
<tr>
<td style="border-bottom:1px solid #E1E1E1;border-left:1px solid #E1E1E1;border-right:1px solid #E1E1E1;direction:ltr;font-size:0px;padding:0px;text-align:center;">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:598px;" ><![endif]-->
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
<tbody>
<tr>
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
<div style="font-family:Roboto, Helvetica, sans-serif;font-size:16px;font-weight:500;line-height:0px;text-align:left;color:#3E3E3E;">
${this.getManageLink()}
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
${emailBodyLogo()}
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>
`;
}
}

View File

@ -0,0 +1,68 @@
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { z, ZodError } from "zod";
import prisma from "@lib/prisma";
const rescheduleSchema = z.object({
bookingId: z.string(),
rescheduleReason: z.string().optional(),
});
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const { bookingId, rescheduleReason: cancellationReason } = req.body;
try {
const bookingToReschedule = await prisma.booking.findFirst({
where: {
uid: bookingId,
NOT: {
// status: BookingStatus.CANCELLED,
},
},
});
if (bookingToReschedule) {
await prisma.booking.update({
where: {
id: bookingToReschedule.id,
},
data: {
cancellationReason,
updatedAt: dayjs().toISOString(),
status: BookingStatus.CANCELLED,
rescheduled: true,
},
});
}
console.log({ bookingToReschedule });
return res.status(200).json(bookingToReschedule);
} catch (error) {
console.log(error);
// throw new Error(error?.message);
}
// Change it to cancelled and update reschedule sent field
// Send email about rescheduling
return res.status(204);
};
function validate(handler: NextApiHandler) {
return async (req, res) => {
if (req.method === "POST") {
try {
rescheduleSchema.parse(req.body);
} catch (error) {
if (error instanceof ZodError && error?.name === "ZodError") {
return res.status(400).json(error?.issues);
}
return res.status(402);
}
} else {
return res.status(405);
}
await handler(req, res);
};
}
export default validate(handler);

View File

@ -2,11 +2,11 @@ import { CalendarIcon } from "@heroicons/react/outline";
import { useRouter } from "next/router";
import { Fragment } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryInput, trpc } from "@lib/trpc";
import BookingsShell from "@components/BookingsShell";

View File

@ -0,0 +1,45 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "fromReschedule" TEXT,
ADD COLUMN "rescheduled" BOOLEAN;
-- RenameIndex
ALTER INDEX "Booking.uid_unique" RENAME TO "Booking_uid_key";
-- RenameIndex
ALTER INDEX "DailyEventReference_bookingId_unique" RENAME TO "DailyEventReference_bookingId_key";
-- RenameIndex
ALTER INDEX "DestinationCalendar.bookingId_unique" RENAME TO "DestinationCalendar_bookingId_key";
-- RenameIndex
ALTER INDEX "DestinationCalendar.eventTypeId_unique" RENAME TO "DestinationCalendar_eventTypeId_key";
-- RenameIndex
ALTER INDEX "DestinationCalendar.userId_unique" RENAME TO "DestinationCalendar_userId_key";
-- RenameIndex
ALTER INDEX "EventType.userId_slug_unique" RENAME TO "EventType_userId_slug_key";
-- RenameIndex
ALTER INDEX "Payment.externalId_unique" RENAME TO "Payment_externalId_key";
-- RenameIndex
ALTER INDEX "Payment.uid_unique" RENAME TO "Payment_uid_key";
-- RenameIndex
ALTER INDEX "Team.slug_unique" RENAME TO "Team_slug_key";
-- RenameIndex
ALTER INDEX "VerificationRequest.identifier_token_unique" RENAME TO "VerificationRequest_identifier_token_key";
-- RenameIndex
ALTER INDEX "VerificationRequest.token_unique" RENAME TO "VerificationRequest_token_key";
-- RenameIndex
ALTER INDEX "Webhook.id_unique" RENAME TO "Webhook_id_key";
-- RenameIndex
ALTER INDEX "users.email_unique" RENAME TO "users_email_key";
-- RenameIndex
ALTER INDEX "users.username_unique" RENAME TO "users_username_key";

View File

@ -253,6 +253,8 @@ model Booking {
destinationCalendar DestinationCalendar?
cancellationReason String?
rejectionReason String?
rescheduled Boolean?
fromReschedule String?
}
model Schedule {