refactor: extract booking actions dropdown to separate component (#24872)
* refactor: extract booking actions dropdown to separate component - Created BookingActionsDropdown component with all dropdown actions and dialogs - Component receives booking as prop and handles all action logic internally - Removed duplicate dialog states and mutations from BookingListItem - Fixed img tag to use Next.js Image component - Maintains same functionality and UI while improving code reusability Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: restore cardCharged variable in BookingListItem The cardCharged variable was accidentally removed during the refactoring but is still needed for the actionContext in BookingListItem.tsx Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: move BookingItemProps type to separate types.ts file - Created types.ts to hold shared booking types - Updated BookingListItem.tsx to import from types.ts - Updated BookingActionsDropdown.tsx to import from types.ts - Updated bookingActions.ts to import from types.ts - Removed unused RouterInputs and RouterOutputs imports from BookingListItem.tsx - Removes unwanted dependency chain where BookingActionsDropdown imported from BookingListItem Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * remove unnecessary description * extract states as a zustand store * fix dialog issue * fix type errors --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
ab37847ea1
commit
3a1e04bb42
6 files changed
+777
-384
No files matched your search
@@ -0,0 +1,593 @@
|
||||
import { useState } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { TextAreaField } from "@calcom/ui/components/form";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowEditActions,
|
||||
type BookingActionContext,
|
||||
} from "./bookingActions";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
interface BookingActionsDropdownProps {
|
||||
booking: BookingItemProps;
|
||||
}
|
||||
|
||||
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Use store for all dialog states
|
||||
const rejectionReason = useBookingActionsStoreContext((state) => state.rejectionReason);
|
||||
const setRejectionReason = useBookingActionsStoreContext((state) => state.setRejectionReason);
|
||||
const rejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.rejectionDialogIsOpen);
|
||||
const setRejectionDialogIsOpen = useBookingActionsStoreContext((state) => state.setRejectionDialogIsOpen);
|
||||
const chargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.chargeCardDialogIsOpen);
|
||||
const setChargeCardDialogIsOpen = useBookingActionsStoreContext((state) => state.setChargeCardDialogIsOpen);
|
||||
const viewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.viewRecordingsDialogIsOpen
|
||||
);
|
||||
const setViewRecordingsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setViewRecordingsDialogIsOpen
|
||||
);
|
||||
const meetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.meetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const setMeetingSessionDetailsDialogIsOpen = useBookingActionsStoreContext(
|
||||
(state) => state.setMeetingSessionDetailsDialogIsOpen
|
||||
);
|
||||
const isNoShowDialogOpen = useBookingActionsStoreContext((state) => state.isNoShowDialogOpen);
|
||||
const setIsNoShowDialogOpen = useBookingActionsStoreContext((state) => state.setIsNoShowDialogOpen);
|
||||
const isOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.isOpenRescheduleDialog);
|
||||
const setIsOpenRescheduleDialog = useBookingActionsStoreContext((state) => state.setIsOpenRescheduleDialog);
|
||||
const isOpenReassignDialog = useBookingActionsStoreContext((state) => state.isOpenReassignDialog);
|
||||
const setIsOpenReassignDialog = useBookingActionsStoreContext((state) => state.setIsOpenReassignDialog);
|
||||
const isOpenSetLocationDialog = useBookingActionsStoreContext((state) => state.isOpenSetLocationDialog);
|
||||
const setIsOpenLocationDialog = useBookingActionsStoreContext((state) => state.setIsOpenLocationDialog);
|
||||
const isOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.isOpenAddGuestsDialog);
|
||||
const setIsOpenAddGuestsDialog = useBookingActionsStoreContext((state) => state.setIsOpenAddGuestsDialog);
|
||||
const isOpenReportDialog = useBookingActionsStoreContext((state) => state.isOpenReportDialog);
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
const rerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.rerouteDialogIsOpen);
|
||||
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
|
||||
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
id: attendee.id,
|
||||
noShow: attendee.noShow || false,
|
||||
phoneNumber: attendee.phoneNumber,
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === "REJECTED") {
|
||||
setRejectionDialogIsOpen(false);
|
||||
showToast(t("booking_rejection_success"), "success");
|
||||
} else {
|
||||
showToast(t("booking_confirmation_success"), "success");
|
||||
}
|
||||
utils.viewer.bookings.invalidate();
|
||||
utils.viewer.me.bookingUnconfirmedCount.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("booking_confirmation_failed"), "error");
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const isUpcoming = new Date(booking.endTime) >= new Date();
|
||||
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
|
||||
const isBookingInPast = new Date(booking.endTime) < new Date();
|
||||
const isCancelled = booking.status === "CANCELLED";
|
||||
const isConfirmed = booking.status === "ACCEPTED";
|
||||
const isRejected = booking.status === "REJECTED";
|
||||
const isPending = booking.status === "PENDING";
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
|
||||
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
|
||||
|
||||
const userEmail = booking.loggedInUser.userEmail;
|
||||
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
|
||||
const isAttendee = !!userSeat;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
|
||||
const getSeatReferenceUid = () => {
|
||||
return userSeat?.referenceUid;
|
||||
};
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
bookingId: booking.id,
|
||||
confirmed: confirm,
|
||||
reason: rejectionReason,
|
||||
};
|
||||
|
||||
if ((isTabRecurring || isTabUnconfirmed) && isRecurring) {
|
||||
body = Object.assign({}, body, { recurringEventId: booking.recurringEventId });
|
||||
}
|
||||
mutation.mutate(body);
|
||||
};
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
const actionContext: BookingActionContext = {
|
||||
booking,
|
||||
isUpcoming,
|
||||
isOngoing,
|
||||
isBookingInPast,
|
||||
isCancelled,
|
||||
isConfirmed,
|
||||
isRejected,
|
||||
isPending,
|
||||
isRescheduled,
|
||||
isRecurring,
|
||||
isTabRecurring,
|
||||
isTabUnconfirmed,
|
||||
isBookingFromRoutingForm,
|
||||
isDisabledCancelling,
|
||||
isDisabledRescheduling,
|
||||
isCalVideoLocation,
|
||||
showPendingPayment: false, // This will be calculated below
|
||||
isAttendee,
|
||||
cardCharged,
|
||||
attendeeList,
|
||||
getSeatReferenceUid,
|
||||
t,
|
||||
} as BookingActionContext;
|
||||
|
||||
const cancelEventAction = getCancelEventAction(actionContext);
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
...reportAction,
|
||||
onClick: () => setIsOpenReportDialog(true),
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
bookingUid,
|
||||
attendees,
|
||||
setIsOpen,
|
||||
isOpen,
|
||||
}: {
|
||||
bookingUid: string;
|
||||
attendees: Array<{
|
||||
name: string;
|
||||
email: string;
|
||||
id: number;
|
||||
noShow: boolean;
|
||||
phoneNumber: string | null;
|
||||
}>;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
const [noShowAttendees, setNoShowAttendees] = useState<
|
||||
Array<{
|
||||
email: string;
|
||||
noShow: boolean;
|
||||
}>
|
||||
>(attendees.map((attendee) => ({ email: attendee.email, noShow: attendee.noShow })));
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
setIsOpen(false);
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent title={t("mark_as_no_show")}>
|
||||
<div className="space-y-2">
|
||||
{attendees.map((attendee, index) => (
|
||||
<label key={attendee.email} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={noShowAttendees[index]?.noShow || false}
|
||||
onChange={(e) => {
|
||||
const newNoShowAttendees = [...noShowAttendees];
|
||||
newNoShowAttendees[index] = {
|
||||
email: attendee.email,
|
||||
noShow: e.target.checked,
|
||||
};
|
||||
setNoShowAttendees(newNoShowAttendees);
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{attendee.name} ({attendee.email})
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
onClick={() => {
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: noShowAttendees,
|
||||
});
|
||||
}}>
|
||||
{t("confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const parsedBooking = {
|
||||
...booking,
|
||||
eventType: booking.eventType.id ? booking.eventType : null,
|
||||
};
|
||||
|
||||
// Render dialogs that might be triggered from BookingListItem even if dropdown is not shown
|
||||
const dialogs = (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={booking.loggedInUser.userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
<TextAreaField
|
||||
name="rejectionReason"
|
||||
label={
|
||||
<>
|
||||
{t("rejection_reason")}
|
||||
<span className="text-subtle font-normal"> (Optional)</span>
|
||||
</>
|
||||
}
|
||||
value={rejectionReason}
|
||||
onChange={(e) => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose />
|
||||
<Button
|
||||
disabled={mutation.isPending}
|
||||
data-testid="rejection-confirm"
|
||||
onClick={() => {
|
||||
bookingConfirm(false);
|
||||
}}>
|
||||
{t("rejection_confirmation")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isBookingFromRoutingForm &&
|
||||
parsedBooking.eventType &&
|
||||
parsedBooking.eventType.id !== undefined &&
|
||||
parsedBooking.eventType.slug !== undefined &&
|
||||
parsedBooking.eventType.title !== undefined &&
|
||||
parsedBooking.routedFromRoutingFormReponse && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{
|
||||
...parsedBooking,
|
||||
metadata: parsedBooking.metadata as z.infer<typeof bookingMetadataSchema>,
|
||||
routedFromRoutingFormReponse: parsedBooking.routedFromRoutingFormReponse,
|
||||
eventType: {
|
||||
length: parsedBooking.eventType.length ?? 0,
|
||||
schedulingType: parsedBooking.eventType.schedulingType ?? null,
|
||||
title: parsedBooking.eventType.title,
|
||||
id: parsedBooking.eventType.id,
|
||||
slug: parsedBooking.eventType.slug,
|
||||
team: parsedBooking.eventType.team ?? null,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Don't render dropdown if edit actions shouldn't be shown
|
||||
if (!shouldShowEditActions(actionContext)) {
|
||||
return dialogs;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{dialogs}
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useRef, type ReactNode } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import type { StoreApi } from "zustand";
|
||||
|
||||
import { createBookingActionsStore, type BookingActionsStore } from "./store";
|
||||
|
||||
export const BookingActionsStoreContext = createContext<StoreApi<BookingActionsStore> | null>(null);
|
||||
|
||||
export interface BookingActionsStoreProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const BookingActionsStoreProvider = ({ children }: BookingActionsStoreProviderProps) => {
|
||||
const storeRef = useRef<StoreApi<BookingActionsStore>>();
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createBookingActionsStore();
|
||||
}
|
||||
|
||||
return (
|
||||
<BookingActionsStoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</BookingActionsStoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useBookingActionsStoreContext = <T,>(
|
||||
selector: (store: BookingActionsStore) => T,
|
||||
equalityFn?: (a: T, b: T) => boolean
|
||||
): T => {
|
||||
const bookingActionsStoreContext = useContext(BookingActionsStoreContext);
|
||||
|
||||
if (!bookingActionsStoreContext) {
|
||||
throw new Error("useBookingActionsStoreContext must be used within BookingActionsStoreProvider");
|
||||
}
|
||||
|
||||
return useStore(bookingActionsStoreContext, selector, equalityFn);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
@@ -9,8 +10,6 @@ import dayjs from "@calcom/dayjs";
|
||||
// TODO: Use browser locale, implement Intl in Dayjs maybe?
|
||||
import "@calcom/dayjs/locales";
|
||||
import { Dialog } from "@calcom/features/components/controlled-dialog";
|
||||
import { MeetingSessionDetailsDialog } from "@calcom/features/ee/video/MeetingSessionDetailsDialog";
|
||||
import ViewRecordingsDialog from "@calcom/features/ee/video/ViewRecordingsDialog";
|
||||
import { formatTime } from "@calcom/lib/dayjs";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -20,7 +19,6 @@ import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
|
||||
import type { AssignmentReason } from "@calcom/prisma/client";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
@@ -48,42 +46,18 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
|
||||
import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
|
||||
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import { ReassignDialog } from "@components/dialog/ReassignDialog";
|
||||
import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
|
||||
import { RerouteDialog } from "@components/dialog/RerouteDialog";
|
||||
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
|
||||
|
||||
import { BookingActionsDropdown } from "./BookingActionsDropdown";
|
||||
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
|
||||
import {
|
||||
getPendingActions,
|
||||
getCancelEventAction,
|
||||
getEditEventActions,
|
||||
getAfterEventActions,
|
||||
getReportAction,
|
||||
shouldShowPendingActions,
|
||||
shouldShowEditActions,
|
||||
shouldShowRecurringCancelAction,
|
||||
shouldShowIndividualReportButton,
|
||||
type BookingActionContext,
|
||||
getReportAction,
|
||||
} from "./bookingActions";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
export type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
};
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
|
||||
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
|
||||
@@ -131,11 +105,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const [rejectionReason, setRejectionReason] = useState<string>("");
|
||||
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
|
||||
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
|
||||
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
|
||||
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
|
||||
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const attendeeList = booking.attendees.map((attendee) => {
|
||||
return {
|
||||
@@ -147,17 +116,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
});
|
||||
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
showToast(data.message, "success");
|
||||
// Invalidate and refetch the bookings query to update the UI
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = trpc.viewer.bookings.confirm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data?.status === BookingStatus.REJECTED) {
|
||||
@@ -185,12 +143,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const isRescheduled = booking.fromReschedule !== null;
|
||||
const isRecurring = booking.recurringEventId !== null;
|
||||
|
||||
const getBookingStatus = (): "upcoming" | "past" | "cancelled" | "rejected" => {
|
||||
if (isCancelled) return "cancelled";
|
||||
if (isRejected) return "rejected";
|
||||
if (isBookingInPast) return "past";
|
||||
return "upcoming";
|
||||
};
|
||||
const isTabRecurring = booking.listingStatus === "recurring";
|
||||
const isTabUnconfirmed = booking.listingStatus === "unconfirmed";
|
||||
const isBookingFromRoutingForm = isBookingReroutable(parsedBooking);
|
||||
@@ -219,6 +171,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const isDisabledCancelling = booking.eventType.disableCancelling;
|
||||
const isDisabledRescheduling = booking.eventType.disableRescheduling;
|
||||
const cardCharged = booking?.payment[0]?.success;
|
||||
|
||||
const bookingConfirm = async (confirm: boolean) => {
|
||||
let body = {
|
||||
@@ -298,49 +251,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
.tz(userTimeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? (isDifferentYear ? "ddd, D MMM YYYY" : "ddd, D MMM") : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
|
||||
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
|
||||
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
|
||||
const [isOpenReportDialog, setIsOpenReportDialog] = useState(false);
|
||||
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
|
||||
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
|
||||
onSuccess: () => {
|
||||
showToast(t("location_updated"), "success");
|
||||
setIsOpenLocationDialog(false);
|
||||
utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (e) => {
|
||||
const errorMessages: Record<string, string> = {
|
||||
UNAUTHORIZED: t("you_are_unauthorized_to_make_this_change_to_the_booking"),
|
||||
BAD_REQUEST: e.message,
|
||||
};
|
||||
|
||||
const message = errorMessages[e.data?.code as string] || t("location_update_failed");
|
||||
showToast(message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
const saveLocation = async ({
|
||||
newLocation,
|
||||
credentialId,
|
||||
}: {
|
||||
newLocation: string;
|
||||
/**
|
||||
* It could be set for conferencing locations that support team level installations.
|
||||
*/
|
||||
credentialId: number | null;
|
||||
}) => {
|
||||
try {
|
||||
await setLocationMutation.mutateAsync({
|
||||
bookingId: booking.id,
|
||||
newLocation,
|
||||
credentialId,
|
||||
});
|
||||
} catch {
|
||||
// Errors are shown through the mutation onError handler
|
||||
}
|
||||
};
|
||||
|
||||
// Getting accepted recurring dates to show
|
||||
const recurringDates = booking.recurringInfo?.bookings[BookingStatus.ACCEPTED]
|
||||
@@ -360,58 +270,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
const title = booking.title;
|
||||
|
||||
const isCalVideoLocation =
|
||||
!booking.location ||
|
||||
booking.location === "integrations:daily" ||
|
||||
(typeof booking.location === "string" && booking.location.trim() === "");
|
||||
|
||||
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
|
||||
|
||||
const baseEditEventActions = getEditEventActions(actionContext);
|
||||
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "reschedule_request"
|
||||
? () => setIsOpenRescheduleDialog(true)
|
||||
: action.id === "reroute"
|
||||
? () => setRerouteDialogIsOpen(true)
|
||||
: action.id === "change_location"
|
||||
? () => setIsOpenLocationDialog(true)
|
||||
: action.id === "add_members"
|
||||
? () => setIsOpenAddGuestsDialog(true)
|
||||
: action.id === "reassign"
|
||||
? () => setIsOpenReassignDialog(true)
|
||||
: undefined,
|
||||
})) as ActionType[];
|
||||
|
||||
const baseAfterEventActions = getAfterEventActions(actionContext);
|
||||
const afterEventActions: ActionType[] = baseAfterEventActions.map((action) => ({
|
||||
...action,
|
||||
onClick:
|
||||
action.id === "view_recordings"
|
||||
? () => setViewRecordingsDialogIsOpen(true)
|
||||
: action.id === "meeting_session_details"
|
||||
? () => setMeetingSessionDetailsDialogIsOpen(true)
|
||||
: action.id === "charge_card"
|
||||
? () => setChargeCardDialogIsOpen(true)
|
||||
: action.id === "no_show"
|
||||
? () => {
|
||||
if (attendeeList.length === 1) {
|
||||
const attendee = attendeeList[0];
|
||||
noShowMutation.mutate({
|
||||
bookingUid: booking.uid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
setIsNoShowDialogOpen(true);
|
||||
}
|
||||
: undefined,
|
||||
disabled:
|
||||
action.disabled ||
|
||||
(action.id === "no_show" && !(isBookingInPast || isOngoing)) ||
|
||||
(action.id === "view_recordings" && !booking.isRecorded),
|
||||
})) as ActionType[];
|
||||
const setIsOpenReportDialog = useBookingActionsStoreContext((state) => state.setIsOpenReportDialog);
|
||||
|
||||
const reportAction = getReportAction(actionContext);
|
||||
const reportActionWithHandler = {
|
||||
@@ -421,72 +282,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RescheduleDialog
|
||||
isOpenDialog={isOpenRescheduleDialog}
|
||||
setIsOpenDialog={setIsOpenRescheduleDialog}
|
||||
bookingUId={booking.uid}
|
||||
/>
|
||||
{isOpenReassignDialog && (
|
||||
<ReassignDialog
|
||||
isOpenDialog={isOpenReassignDialog}
|
||||
setIsOpenDialog={setIsOpenReassignDialog}
|
||||
bookingId={booking.id}
|
||||
teamId={booking.eventType?.team?.id || 0}
|
||||
bookingFromRoutingForm={isBookingFromRoutingForm}
|
||||
/>
|
||||
)}
|
||||
<EditLocationDialog
|
||||
booking={booking}
|
||||
saveLocation={saveLocation}
|
||||
isOpenDialog={isOpenSetLocationDialog}
|
||||
setShowLocationModal={setIsOpenLocationDialog}
|
||||
teamId={booking.eventType?.team?.id}
|
||||
/>
|
||||
<AddGuestsDialog
|
||||
isOpenDialog={isOpenAddGuestsDialog}
|
||||
setIsOpenDialog={setIsOpenAddGuestsDialog}
|
||||
bookingId={booking.id}
|
||||
/>
|
||||
<ReportBookingDialog
|
||||
isOpenDialog={isOpenReportDialog}
|
||||
setIsOpenDialog={setIsOpenReportDialog}
|
||||
bookingUid={booking.uid}
|
||||
isRecurring={isRecurring}
|
||||
status={getBookingStatus()}
|
||||
/>
|
||||
{booking.paid && booking.payment[0] && (
|
||||
<ChargeCardDialog
|
||||
isOpenDialog={chargeCardDialogIsOpen}
|
||||
setIsOpenDialog={setChargeCardDialogIsOpen}
|
||||
bookingId={booking.id}
|
||||
paymentAmount={booking.payment[0].amount}
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
setIsOpenDialog={setViewRecordingsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isCalVideoLocation && meetingSessionDetailsDialogIsOpen && (
|
||||
<MeetingSessionDetailsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={meetingSessionDetailsDialogIsOpen}
|
||||
setIsOpenDialog={setMeetingSessionDetailsDialogIsOpen}
|
||||
timeFormat={userTimeFormat ?? null}
|
||||
/>
|
||||
)}
|
||||
{isNoShowDialogOpen && (
|
||||
<NoShowAttendeesDialog
|
||||
bookingUid={booking.uid}
|
||||
attendees={attendeeList}
|
||||
setIsOpen={setIsNoShowDialogOpen}
|
||||
isOpen={isNoShowDialogOpen}
|
||||
/>
|
||||
)}
|
||||
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
|
||||
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
|
||||
<div>
|
||||
@@ -555,8 +350,10 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
className="text-sm leading-6 text-blue-600 hover:underline dark:text-blue-400">
|
||||
<div className="flex items-center gap-2">
|
||||
{provider?.iconUrl && (
|
||||
<img
|
||||
<Image
|
||||
src={provider.iconUrl}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4 rounded-sm"
|
||||
alt={`${provider?.label} logo`}
|
||||
/>
|
||||
@@ -661,94 +458,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</div>
|
||||
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
|
||||
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
|
||||
{shouldShowEditActions(actionContext) && (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="ellipsis"
|
||||
data-testid="booking-actions-dropdown"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
|
||||
{editEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
|
||||
{afterEventActions.map((action) => (
|
||||
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={action.color}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
data-testid={action.id}
|
||||
className={action.disabled ? "text-muted" : undefined}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={reportActionWithHandler.id}
|
||||
disabled={reportActionWithHandler.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={reportActionWithHandler.color}
|
||||
StartIcon={reportActionWithHandler.icon}
|
||||
onClick={reportActionWithHandler.onClick}
|
||||
disabled={reportActionWithHandler.disabled}
|
||||
data-testid={reportActionWithHandler.id}
|
||||
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
|
||||
{reportActionWithHandler.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="rounded-lg"
|
||||
key={cancelEventAction.id}
|
||||
disabled={cancelEventAction.disabled}>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
color={cancelEventAction.color}
|
||||
StartIcon={cancelEventAction.icon}
|
||||
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
|
||||
onClick={cancelEventAction.onClick}
|
||||
disabled={cancelEventAction.disabled}
|
||||
data-bookingid={cancelEventAction.bookingId}
|
||||
data-testid={cancelEventAction.id}
|
||||
className={cancelEventAction.disabled ? "text-muted" : undefined}>
|
||||
{cancelEventAction.label}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
)}
|
||||
<BookingActionsDropdown booking={booking} />
|
||||
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
|
||||
{shouldShowIndividualReportButton(actionContext) && (
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -782,14 +492,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
isRescheduled={isRescheduled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isBookingFromRoutingForm && (
|
||||
<RerouteDialog
|
||||
isOpenDialog={rerouteDialogIsOpen}
|
||||
setIsOpenDialog={setRerouteDialogIsOpen}
|
||||
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1174,79 +876,6 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const NoShowAttendeesDialog = ({
|
||||
attendees,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
bookingUid,
|
||||
}: {
|
||||
attendees: AttendeeProps[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (value: boolean) => void;
|
||||
bookingUid: string;
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
const [noShowAttendees, setNoShowAttendees] = useState(
|
||||
attendees.map((attendee) => ({
|
||||
id: attendee.id,
|
||||
email: attendee.email,
|
||||
name: attendee.name,
|
||||
noShow: attendee.noShow || false,
|
||||
}))
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
const newValue = data.attendees[0];
|
||||
setNoShowAttendees((old) =>
|
||||
old.map((attendee) =>
|
||||
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
|
||||
)
|
||||
);
|
||||
showToast(t(data.message), "success");
|
||||
await utils.viewer.bookings.invalidate();
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
|
||||
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")} enableOverflow>
|
||||
{noShowAttendees.map((attendee, index) => (
|
||||
<form
|
||||
key={attendee.id}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
noShowMutation.mutate({
|
||||
bookingUid,
|
||||
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
|
||||
});
|
||||
}}>
|
||||
<div
|
||||
className={`bg-muted flex items-center justify-between px-4 py-2 ${
|
||||
index === 0 ? "rounded-t-md" : ""
|
||||
} ${index === noShowAttendees.length - 1 ? "rounded-b-md" : "border-subtle border-b"}`}>
|
||||
<span className="text-emphasis flex flex-col text-sm">
|
||||
{attendee.name}
|
||||
{attendee.email && <span className="text-muted">({attendee.email})</span>}
|
||||
</span>
|
||||
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
|
||||
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
))}
|
||||
<DialogFooter noSticky>
|
||||
<DialogClose>{t("done")}</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
|
||||
const [openDropdown, setOpenDropdown] = useState(false);
|
||||
const { t } = useLocale();
|
||||
@@ -1369,4 +998,13 @@ const AssignmentReasonTooltip = ({ assignmentReason }: { assignmentReason: Assig
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItem;
|
||||
// Wrap BookingListItem with BookingActionsStoreProvider to provide isolated store for each booking
|
||||
const BookingListItemWithProvider = (props: BookingItemProps) => {
|
||||
return (
|
||||
<BookingActionsStoreProvider>
|
||||
<BookingListItem {...props} />
|
||||
</BookingActionsStoreProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingListItemWithProvider;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { ActionType } from "@calcom/ui/components/table";
|
||||
|
||||
import type { BookingItemProps } from "./BookingListItem";
|
||||
import type { BookingItemProps } from "./types";
|
||||
|
||||
export interface BookingActionContext {
|
||||
booking: BookingItemProps;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { createStore } from "zustand";
|
||||
|
||||
export type BookingActionsStore = {
|
||||
// Dialog states
|
||||
rejectionDialogIsOpen: boolean;
|
||||
chargeCardDialogIsOpen: boolean;
|
||||
viewRecordingsDialogIsOpen: boolean;
|
||||
meetingSessionDetailsDialogIsOpen: boolean;
|
||||
isNoShowDialogOpen: boolean;
|
||||
isOpenRescheduleDialog: boolean;
|
||||
isOpenReassignDialog: boolean;
|
||||
isOpenSetLocationDialog: boolean;
|
||||
isOpenAddGuestsDialog: boolean;
|
||||
isOpenReportDialog: boolean;
|
||||
rerouteDialogIsOpen: boolean;
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setChargeCardDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setViewRecordingsDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setMeetingSessionDetailsDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsNoShowDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenRescheduleDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReassignDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenLocationDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenAddGuestsDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setIsOpenReportDialog: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: string;
|
||||
setRejectionReason: (reason: string) => void;
|
||||
};
|
||||
|
||||
export const createBookingActionsStore = () => {
|
||||
return createStore<BookingActionsStore>((set) => ({
|
||||
// Initial dialog states
|
||||
rejectionDialogIsOpen: false,
|
||||
chargeCardDialogIsOpen: false,
|
||||
viewRecordingsDialogIsOpen: false,
|
||||
meetingSessionDetailsDialogIsOpen: false,
|
||||
isNoShowDialogOpen: false,
|
||||
isOpenRescheduleDialog: false,
|
||||
isOpenReassignDialog: false,
|
||||
isOpenSetLocationDialog: false,
|
||||
isOpenAddGuestsDialog: false,
|
||||
isOpenReportDialog: false,
|
||||
rerouteDialogIsOpen: false,
|
||||
|
||||
// Dialog setters
|
||||
setRejectionDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
rejectionDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rejectionDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setChargeCardDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
chargeCardDialogIsOpen: typeof isOpen === "function" ? isOpen(state.chargeCardDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setViewRecordingsDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
viewRecordingsDialogIsOpen:
|
||||
typeof isOpen === "function" ? isOpen(state.viewRecordingsDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setMeetingSessionDetailsDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
meetingSessionDetailsDialogIsOpen:
|
||||
typeof isOpen === "function" ? isOpen(state.meetingSessionDetailsDialogIsOpen) : isOpen,
|
||||
})),
|
||||
setIsNoShowDialogOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
isNoShowDialogOpen: typeof isOpen === "function" ? isOpen(state.isNoShowDialogOpen) : isOpen,
|
||||
})),
|
||||
setIsOpenRescheduleDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenRescheduleDialog: typeof isOpen === "function" ? isOpen(state.isOpenRescheduleDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenReassignDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenReassignDialog: typeof isOpen === "function" ? isOpen(state.isOpenReassignDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenLocationDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenSetLocationDialog:
|
||||
typeof isOpen === "function" ? isOpen(state.isOpenSetLocationDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenAddGuestsDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenAddGuestsDialog: typeof isOpen === "function" ? isOpen(state.isOpenAddGuestsDialog) : isOpen,
|
||||
})),
|
||||
setIsOpenReportDialog: (isOpen) =>
|
||||
set((state) => ({
|
||||
isOpenReportDialog: typeof isOpen === "function" ? isOpen(state.isOpenReportDialog) : isOpen,
|
||||
})),
|
||||
setRerouteDialogIsOpen: (isOpen) =>
|
||||
set((state) => ({
|
||||
rerouteDialogIsOpen: typeof isOpen === "function" ? isOpen(state.rerouteDialogIsOpen) : isOpen,
|
||||
})),
|
||||
|
||||
// Rejection reason state
|
||||
rejectionReason: "",
|
||||
setRejectionReason: (reason: string) => set({ rejectionReason: reason }),
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
|
||||
|
||||
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
|
||||
|
||||
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
|
||||
export type BookingItemProps = BookingItem & {
|
||||
listingStatus: BookingListingStatus;
|
||||
recurringInfo: RouterOutputs["viewer"]["bookings"]["get"]["recurringInfo"][number] | undefined;
|
||||
loggedInUser: {
|
||||
userId: number | undefined;
|
||||
userTimeZone: string | undefined;
|
||||
userTimeFormat: number | null | undefined;
|
||||
userEmail: string | undefined;
|
||||
};
|
||||
isToday: boolean;
|
||||
};
|
||||
Reference in new issue
Block a user