Add reason for rejection (optional) (#1719)

* init --WIP

* added rejection option modal

* migration added

* lint fix

* rejection reason in email added

* moved getRejectedReason function

* lint fix

* --wip

* Prevent undefineds and nulls on email messages

* Cleanup

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
This commit is contained in:
Syed Ali Shahbaz 2022-02-09 23:55:58 +05:30 committed by GitHub
parent b2fb60af31
commit a41dd30467
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 128 additions and 75 deletions

View file

@ -1,15 +0,0 @@
declare module "@wojtekmaj/react-daterange-picker/dist/entry.nostyle" {
import { CalendarProps } from "react-calendar";
export type DateRangePickerCalendarProps = Omit<
CalendarProps,
"calendarClassName" | "onChange" | "value"
> & {
calendarClassName?: string;
onChange: (value: [Date, Date]) => void;
value: [Date, Date];
clearIcon: JSX.Element | null;
calendarIcon: JSX.Element | null;
rangeDivider: JSX.Element | null;
};
export default function DateRangePicker(props: DateRangePickerCalendarProps): JSX.Element;
}

View file

@ -1,25 +1,35 @@
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
import { BookingStatus } from "@prisma/client";
import dayjs from "dayjs";
import { useState } from "react";
import { useMutation } from "react-query";
import { HttpError } from "@lib/core/http/error";
import { useLocale } from "@lib/hooks/useLocale";
import { inferQueryOutput, trpc } from "@lib/trpc";
import { TextArea } from "@components/form/fields";
import TableActions, { ActionType } from "@components/ui/TableActions";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@components/Dialog";
import Button from "@components/ui/Button";
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
function BookingListItem(booking: BookingItem) {
const { t, i18n } = useLocale();
const utils = trpc.useContext();
const [rejectionReason, setRejectionReason] = useState<string>("");
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const mutation = useMutation(
async (confirm: boolean) => {
const res = await fetch("/api/book/confirm", {
method: "PATCH",
body: JSON.stringify({ id: booking.id, confirmed: confirm, language: i18n.language }),
body: JSON.stringify({
id: booking.id,
confirmed: confirm,
language: i18n.language,
reason: rejectionReason,
}),
headers: {
"Content-Type": "application/json",
},
@ -41,7 +51,7 @@ function BookingListItem(booking: BookingItem) {
{
id: "reject",
label: t("reject"),
onClick: () => mutation.mutate(false),
onClick: () => setRejectionDialogIsOpen(true),
icon: BanIcon,
disabled: mutation.isLoading,
},
@ -73,64 +83,98 @@ function BookingListItem(booking: BookingItem) {
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
return (
<tr className="flex">
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div>
</td>
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden">
{!booking.confirmed && !booking.rejected && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
)}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
)}
<div className="text-sm font-medium text-gray-900">
{startTime}:{" "}
<small className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</small>
</div>
</div>
<div
title={booking.title}
className="max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
)}
{!booking.confirmed && !booking.rejected && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div>
{booking.description && (
<div className="max-w-52 truncate text-sm text-gray-500 md:max-w-96" title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<div className="text-sm text-gray-900 hover:text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div>
)}
</td>
<>
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent>
<DialogHeader title={t("rejection_reason_title")} />
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>
<p className="-mt-4 text-sm text-gray-500">{t("rejection_reason_description")}</p>
<p className="mt-6 mb-2 text-sm font-bold text-black">
{t("rejection_reason")}
<span className="font-normal text-gray-500"> (Optional)</span>
</p>
<TextArea
name={t("rejection_reason")}
value={rejectionReason}
onChange={(e) => setRejectionReason(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);
}}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<tr className="flex">
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
<div className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</div>
</td>
<td className={"flex-1 py-4 ltr:pl-4 rtl:pr-4" + (booking.rejected ? " line-through" : "")}>
<div className="sm:hidden">
{!booking.confirmed && !booking.rejected && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">{t("unconfirmed")}</Tag>
)}
</>
) : null}
</td>
</tr>
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
)}
<div className="text-sm font-medium text-gray-900">
{startTime}:{" "}
<small className="text-sm text-gray-500">
{dayjs(booking.startTime).format("HH:mm")} - {dayjs(booking.endTime).format("HH:mm")}
</small>
</div>
</div>
<div
title={booking.title}
className="max-w-56 truncate text-sm font-medium leading-6 text-neutral-900 md:max-w-max">
{booking.eventType?.team && <strong>{booking.eventType.team.name}: </strong>}
{booking.title}
{!!booking?.eventType?.price && !booking.paid && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">Pending payment</Tag>
)}
{!booking.confirmed && !booking.rejected && (
<Tag className="hidden ltr:ml-2 rtl:mr-2 sm:inline-flex">{t("unconfirmed")}</Tag>
)}
</div>
{booking.description && (
<div className="max-w-52 truncate text-sm text-gray-500 md:max-w-96" title={booking.description}>
&quot;{booking.description}&quot;
</div>
)}
{booking.attendees.length !== 0 && (
<div className="text-sm text-gray-900 hover:text-blue-500">
<a href={"mailto:" + booking.attendees[0].email}>{booking.attendees[0].email}</a>
</div>
)}
</td>
<td className="whitespace-nowrap py-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4">
{isUpcoming && !isCancelled ? (
<>
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
{!booking.confirmed && booking.rejected && (
<div className="text-sm text-gray-500">{t("rejected")}</div>
)}
</>
) : null}
</td>
</tr>
</>
);
}

View file

@ -48,6 +48,7 @@ ${this.getWhat()}
${this.getWhen()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.getRejectionReason()}
`.replace(/(<([^>]+)>)/gi, "");
}
@ -95,6 +96,7 @@ ${this.getAdditionalNotes()}
${this.getWho()}
${this.getLocation()}
${this.getAdditionalNotes()}
${this.getRejectionReason()}
</div>
</td>
</tr>

View file

@ -285,6 +285,7 @@ ${getRichDescription(this.calEvent)}
}
protected getAdditionalNotes(): string {
if (!this.calEvent.description) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
@ -296,6 +297,16 @@ ${getRichDescription(this.calEvent)}
`;
}
protected getRejectionReason(): string {
if (!this.calEvent.rejectionReason) return "";
return `
<p style="height: 6px"></p>
<div style="line-height: 6px;">
<p style="color: #494949;">${this.calEvent.attendees[0].language.translate("rejection_reason")}</p>
<p style="color: #494949; font-weight: 400; line-height: 24px;">${this.calEvent.rejectionReason}</p>
</div>`;
}
protected getLocation(): string {
let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : "";

View file

@ -53,6 +53,7 @@ export interface CalendarEvent {
paymentInfo?: PaymentInfo | null;
destinationCalendar?: DestinationCalendar | null;
cancellationReason?: string | null;
rejectionReason?: string | null;
}
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {

View file

@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { refund } from "@ee/lib/stripe/server";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getSession } from "@lib/auth";
import { sendDeclinedEmails } from "@lib/emails/email-manager";
import { sendScheduledEmails } from "@lib/emails/email-manager";
@ -185,7 +186,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(204).end();
} else {
await refund(booking, evt);
const rejectionReason = asStringOrNull(req.body.reason) || "";
await prisma.booking.update({
where: {
id: bookingId,
@ -193,6 +194,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
rejected: true,
status: BookingStatus.REJECTED,
rejectionReason: rejectionReason,
},
});

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Booking" ADD COLUMN "rejectionReason" TEXT;

View file

@ -245,6 +245,7 @@ model Booking {
payment Payment[]
destinationCalendar DestinationCalendar?
cancellationReason String?
rejectionReason String?
}
model Schedule {

View file

@ -36,6 +36,7 @@ export const _BookingModel = z.object({
status: z.nativeEnum(BookingStatus),
paid: z.boolean(),
cancellationReason: z.string().nullish(),
rejectionReason: z.string().nullish(),
});
export interface CompleteBooking extends z.infer<typeof _BookingModel> {

View file

@ -15,6 +15,10 @@
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
"cancellation_reason": "Reason for cancellation",
"cancellation_reason_placeholder": "Why are you cancelling? (optional)",
"rejection_reason": "Reason for rejecting",
"rejection_reason_title": "Reject the booking request?",
"rejection_reason_description": "Are you sure you want to reject the booking? We'll let the person who tried to book know. You can provide a reason below.",
"rejection_confirmation": "Reject the booking",
"manage_this_event": "Manage this event",
"your_event_has_been_scheduled": "Your event has been scheduled",
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",