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:
parent
b2fb60af31
commit
a41dd30467
10 changed files with 128 additions and 75 deletions
15
@types/@wojtekmaj:react-daterange-picker.d.ts
vendored
15
@types/@wojtekmaj:react-daterange-picker.d.ts
vendored
|
@ -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;
|
|
||||||
}
|
|
|
@ -1,25 +1,35 @@
|
||||||
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
import { BanIcon, CheckIcon, ClockIcon, XIcon } from "@heroicons/react/outline";
|
||||||
import { BookingStatus } from "@prisma/client";
|
import { BookingStatus } from "@prisma/client";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import { useState } from "react";
|
||||||
import { useMutation } from "react-query";
|
import { useMutation } from "react-query";
|
||||||
|
|
||||||
import { HttpError } from "@lib/core/http/error";
|
import { HttpError } from "@lib/core/http/error";
|
||||||
import { useLocale } from "@lib/hooks/useLocale";
|
import { useLocale } from "@lib/hooks/useLocale";
|
||||||
import { inferQueryOutput, trpc } from "@lib/trpc";
|
import { inferQueryOutput, trpc } from "@lib/trpc";
|
||||||
|
|
||||||
|
import { TextArea } from "@components/form/fields";
|
||||||
import TableActions, { ActionType } from "@components/ui/TableActions";
|
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];
|
type BookingItem = inferQueryOutput<"viewer.bookings">["bookings"][number];
|
||||||
|
|
||||||
function BookingListItem(booking: BookingItem) {
|
function BookingListItem(booking: BookingItem) {
|
||||||
const { t, i18n } = useLocale();
|
const { t, i18n } = useLocale();
|
||||||
const utils = trpc.useContext();
|
const utils = trpc.useContext();
|
||||||
|
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||||
|
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
async (confirm: boolean) => {
|
async (confirm: boolean) => {
|
||||||
const res = await fetch("/api/book/confirm", {
|
const res = await fetch("/api/book/confirm", {
|
||||||
method: "PATCH",
|
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: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
|
@ -41,7 +51,7 @@ function BookingListItem(booking: BookingItem) {
|
||||||
{
|
{
|
||||||
id: "reject",
|
id: "reject",
|
||||||
label: t("reject"),
|
label: t("reject"),
|
||||||
onClick: () => mutation.mutate(false),
|
onClick: () => setRejectionDialogIsOpen(true),
|
||||||
icon: BanIcon,
|
icon: BanIcon,
|
||||||
disabled: mutation.isLoading,
|
disabled: mutation.isLoading,
|
||||||
},
|
},
|
||||||
|
@ -73,64 +83,98 @@ function BookingListItem(booking: BookingItem) {
|
||||||
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
const startTime = dayjs(booking.startTime).format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="flex">
|
<>
|
||||||
<td className="hidden whitespace-nowrap py-4 align-top ltr:pl-6 rtl:pr-6 sm:table-cell">
|
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||||
<div className="text-sm leading-6 text-gray-900">{startTime}</div>
|
<DialogContent>
|
||||||
<div className="text-sm text-gray-500">
|
<DialogHeader title={t("rejection_reason_title")} />
|
||||||
{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}>
|
|
||||||
"{booking.description}"
|
|
||||||
</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">
|
<p className="-mt-4 text-sm text-gray-500">{t("rejection_reason_description")}</p>
|
||||||
{isUpcoming && !isCancelled ? (
|
<p className="mt-6 mb-2 text-sm font-bold text-black">
|
||||||
<>
|
{t("rejection_reason")}
|
||||||
{!booking.confirmed && !booking.rejected && <TableActions actions={pendingActions} />}
|
<span className="font-normal text-gray-500"> (Optional)</span>
|
||||||
{booking.confirmed && !booking.rejected && <TableActions actions={bookedActions} />}
|
</p>
|
||||||
{!booking.confirmed && booking.rejected && (
|
<TextArea
|
||||||
<div className="text-sm text-gray-500">{t("rejected")}</div>
|
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>
|
||||||
)}
|
)}
|
||||||
</>
|
{!!booking?.eventType?.price && !booking.paid && (
|
||||||
) : null}
|
<Tag className="mb-2 ltr:mr-2 rtl:ml-2">Pending payment</Tag>
|
||||||
</td>
|
)}
|
||||||
</tr>
|
<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}>
|
||||||
|
"{booking.description}"
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ ${this.getWhat()}
|
||||||
${this.getWhen()}
|
${this.getWhen()}
|
||||||
${this.getLocation()}
|
${this.getLocation()}
|
||||||
${this.getAdditionalNotes()}
|
${this.getAdditionalNotes()}
|
||||||
|
${this.getRejectionReason()}
|
||||||
`.replace(/(<([^>]+)>)/gi, "");
|
`.replace(/(<([^>]+)>)/gi, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,6 +96,7 @@ ${this.getAdditionalNotes()}
|
||||||
${this.getWho()}
|
${this.getWho()}
|
||||||
${this.getLocation()}
|
${this.getLocation()}
|
||||||
${this.getAdditionalNotes()}
|
${this.getAdditionalNotes()}
|
||||||
|
${this.getRejectionReason()}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
@ -285,6 +285,7 @@ ${getRichDescription(this.calEvent)}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getAdditionalNotes(): string {
|
protected getAdditionalNotes(): string {
|
||||||
|
if (!this.calEvent.description) return "";
|
||||||
return `
|
return `
|
||||||
<p style="height: 6px"></p>
|
<p style="height: 6px"></p>
|
||||||
<div style="line-height: 6px;">
|
<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 {
|
protected getLocation(): string {
|
||||||
let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : "";
|
let providerName = this.calEvent.location ? getIntegrationName(this.calEvent.location) : "";
|
||||||
|
|
||||||
|
|
|
@ -53,6 +53,7 @@ export interface CalendarEvent {
|
||||||
paymentInfo?: PaymentInfo | null;
|
paymentInfo?: PaymentInfo | null;
|
||||||
destinationCalendar?: DestinationCalendar | null;
|
destinationCalendar?: DestinationCalendar | null;
|
||||||
cancellationReason?: string | null;
|
cancellationReason?: string | null;
|
||||||
|
rejectionReason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
|
export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "externalId"> {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { refund } from "@ee/lib/stripe/server";
|
import { refund } from "@ee/lib/stripe/server";
|
||||||
|
|
||||||
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { getSession } from "@lib/auth";
|
import { getSession } from "@lib/auth";
|
||||||
import { sendDeclinedEmails } from "@lib/emails/email-manager";
|
import { sendDeclinedEmails } from "@lib/emails/email-manager";
|
||||||
import { sendScheduledEmails } 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();
|
res.status(204).end();
|
||||||
} else {
|
} else {
|
||||||
await refund(booking, evt);
|
await refund(booking, evt);
|
||||||
|
const rejectionReason = asStringOrNull(req.body.reason) || "";
|
||||||
await prisma.booking.update({
|
await prisma.booking.update({
|
||||||
where: {
|
where: {
|
||||||
id: bookingId,
|
id: bookingId,
|
||||||
|
@ -193,6 +194,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||||
data: {
|
data: {
|
||||||
rejected: true,
|
rejected: true,
|
||||||
status: BookingStatus.REJECTED,
|
status: BookingStatus.REJECTED,
|
||||||
|
rejectionReason: rejectionReason,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Booking" ADD COLUMN "rejectionReason" TEXT;
|
|
@ -245,6 +245,7 @@ model Booking {
|
||||||
payment Payment[]
|
payment Payment[]
|
||||||
destinationCalendar DestinationCalendar?
|
destinationCalendar DestinationCalendar?
|
||||||
cancellationReason String?
|
cancellationReason String?
|
||||||
|
rejectionReason String?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Schedule {
|
model Schedule {
|
||||||
|
|
|
@ -36,6 +36,7 @@ export const _BookingModel = z.object({
|
||||||
status: z.nativeEnum(BookingStatus),
|
status: z.nativeEnum(BookingStatus),
|
||||||
paid: z.boolean(),
|
paid: z.boolean(),
|
||||||
cancellationReason: z.string().nullish(),
|
cancellationReason: z.string().nullish(),
|
||||||
|
rejectionReason: z.string().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface CompleteBooking extends z.infer<typeof _BookingModel> {
|
export interface CompleteBooking extends z.infer<typeof _BookingModel> {
|
||||||
|
|
|
@ -15,6 +15,10 @@
|
||||||
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
"need_to_reschedule_or_cancel": "Need to reschedule or cancel?",
|
||||||
"cancellation_reason": "Reason for cancellation",
|
"cancellation_reason": "Reason for cancellation",
|
||||||
"cancellation_reason_placeholder": "Why are you cancelling? (optional)",
|
"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",
|
"manage_this_event": "Manage this event",
|
||||||
"your_event_has_been_scheduled": "Your event has been scheduled",
|
"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}}'.",
|
"accept_our_license": "Accept our license by changing the .env variable <1>NEXT_PUBLIC_LICENSE_CONSENT</1> to '{{agree}}'.",
|
||||||
|
|
Loading…
Reference in a new issue