Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions apps/web/components/booking/BookingListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ 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";

Expand Down Expand Up @@ -132,6 +133,7 @@ function BookingListItem(booking: BookingItemProps) {
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [meetingSessionDetailsDialogIsOpen, setMeetingSessionDetailsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const [isReportDialogOpen, setIsReportDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;

const attendeeList = booking.attendees.map((attendee) => {
Expand Down Expand Up @@ -402,6 +404,28 @@ function BookingListItem(booking: BookingItemProps) {
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];

const hasBeenReported = booking.reportLogs && booking.reportLogs.length > 0;

const reportAction: ActionType = hasBeenReported
? {
id: "report",
label: t("already_reported"),
icon: "flag",
disabled: true,
}
: {
id: "report",
label: t("report"),
icon: "flag",
disabled: false,
onClick: () => setIsReportDialogOpen(true),
};

const shouldShowIndividualReportButton = isTabRecurring || isPending || isCancelled;

const shouldShowReportInThreeDotsMenu =
shouldShowEditActions(actionContext) && !shouldShowIndividualReportButton;

return (
<>
<RescheduleDialog
Expand Down Expand Up @@ -703,11 +727,42 @@ function BookingListItem(booking: BookingItemProps) {
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
{shouldShowReportInThreeDotsMenu && (
<DropdownMenuItem
className="rounded-lg"
key={reportAction.id}
disabled={reportAction.disabled}>
<DropdownItem
type="button"
StartIcon={reportAction.icon}
onClick={reportAction.onClick}
disabled={reportAction.disabled}
data-testid={reportAction.id}
className={reportAction.disabled ? "text-muted" : undefined}>
{reportAction.label}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
)}
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton && (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="icon"
color="secondary"
StartIcon={reportAction.icon}
onClick={reportAction.onClick}
disabled={reportAction.disabled}
data-testid={reportAction.id}
className="h-8 w-8"
tooltip={reportAction.label}
/>
</div>
)}
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
{isCancelled && booking.rescheduled && (
<div className="hidden h-full items-center md:flex">
Expand All @@ -726,6 +781,21 @@ function BookingListItem(booking: BookingItemProps) {
/>
</div>

<ReportBookingDialog
isOpen={isReportDialogOpen}
onClose={() => setIsReportDialogOpen(false)}
bookingId={booking.id}
bookingTitle={booking.title}
isUpcoming={isUpcoming}
isCancelled={isCancelled}
onSuccess={async () => {
// Invalidate all booking queries to ensure UI reflects the changes
await utils.viewer.bookings.invalidate();
// Also invalidate any cached booking data
await utils.invalidate();
}}
/>

{isBookingFromRoutingForm && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
Expand Down Expand Up @@ -753,6 +823,7 @@ const BookingItemBadges = ({
isRescheduled: boolean;
}) => {
const { t } = useLocale();
const hasBeenReported = booking.reportLogs && booking.reportLogs.length > 0;

return (
<div className="hidden h-9 flex-row items-center pb-4 pl-6 sm:flex">
Expand All @@ -768,6 +839,14 @@ const BookingItemBadges = ({
</Badge>
</Tooltip>
)}
{hasBeenReported && (
<Badge variant="red" className="ltr:mr-2 rtl:ml-2">
{t("reported")}:{" "}
{booking.reportLogs?.[0]?.reason
? t(booking.reportLogs?.[0]?.reason?.toLowerCase())
: t("unavailable")}
</Badge>
)}
{booking.eventType?.team && (
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{booking.eventType.team.name}
Expand Down
202 changes: 202 additions & 0 deletions apps/web/components/booking/bookingActions.report.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { describe, it, expect } from "vitest";

import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";

import { getReportActions, type BookingActionContext } from "./bookingActions";

const mockT = (key: string) => key;

function createMockContext(overrides: Partial<BookingActionContext> = {}): BookingActionContext {
const now = new Date();
const startTime = new Date(now.getTime() + 24 * 60 * 60 * 1000); // Tomorrow
const endTime = new Date(startTime.getTime() + 60 * 60 * 1000); // 1 hour later

return {
booking: {
id: 1,
uid: "test-uid",
title: "Test Booking",
status: BookingStatus.ACCEPTED,
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
createdAt: now,
updatedAt: now,
userPrimaryEmail: "user@example.com",
user: {
id: 1,
name: "Test User",
email: "user@example.com",
},
eventType: {
id: 1,
title: "Test Event",
schedulingType: SchedulingType.ROUND_ROBIN,
allowReschedulingPastBookings: false,
recurringEvent: null,
eventTypeColor: null,
price: 0,
currency: "USD",
metadata: null,
length: 60,
slug: "test-event",
team: null,
hosts: [],
},
attendees: [],
payment: [],
paid: false,
isRecorded: false,
reportLogs: [],
rescheduler: null,
rescheduled: null,
fromReschedule: null,
responses: null,
location: null,
description: null,
customInputs: [],
references: [],
recurringEventId: null,
seatsReferences: [],
metadata: null,
routedFromRoutingFormReponse: null,
assignmentReason: [],
...overrides.booking,
listingStatus: "upcoming" as const,
recurringInfo: undefined,
loggedInUser: {
userId: 1,
userTimeZone: "UTC",
userTimeFormat: 24,
userEmail: "user@example.com",
},
isToday: false,
},
isUpcoming: true,
isOngoing: false,
isBookingInPast: false,
isCancelled: false,
isConfirmed: true,
isRejected: false,
isPending: false,
isRescheduled: false,
isRecurring: false,
isTabRecurring: false,
isTabUnconfirmed: false,
isBookingFromRoutingForm: false,
isDisabledCancelling: false,
isDisabledRescheduling: false,
isCalVideoLocation: true,
showPendingPayment: false,
cardCharged: false,
isAttendee: false,
attendeeList: [
{
name: "Test Attendee",
email: "attendee@example.com",
id: 1,
noShow: false,
phoneNumber: null,
},
],
getSeatReferenceUid: () => undefined,
t: mockT,
...overrides,
};
}

describe("Report Booking Actions", () => {
describe("getReportActions", () => {
it("should return report action for unreported booking", () => {
const context = createMockContext();
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0]).toEqual({
id: "report",
label: "report",
icon: "flag",
disabled: false,
});
});

it("should return report action for any booking status", () => {
const statuses = [
BookingStatus.ACCEPTED,
BookingStatus.CANCELLED,
BookingStatus.PENDING,
BookingStatus.REJECTED,
];

statuses.forEach((status) => {
const context = createMockContext();
context.booking.status = status;
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("report");
});
});

it("should return report action for past bookings", () => {
const pastDate = new Date(Date.now() - 24 * 60 * 60 * 1000); // Yesterday
const context = createMockContext({
isUpcoming: false,
isBookingInPast: true,
});
context.booking.startTime = pastDate.toISOString();
context.booking.endTime = new Date(pastDate.getTime() + 60 * 60 * 1000).toISOString();
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("report");
});

it("should return report action for upcoming bookings", () => {
const futureDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // Tomorrow
const context = createMockContext({
isUpcoming: true,
isBookingInPast: false,
});
context.booking.startTime = futureDate.toISOString();
context.booking.endTime = new Date(futureDate.getTime() + 60 * 60 * 1000).toISOString();
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("report");
});

it("should return report action for team bookings", () => {
const context = createMockContext();
context.booking.eventType = {
...context.booking.eventType,
id: 1,
title: "Team Event",
schedulingType: SchedulingType.COLLECTIVE,
team: {
id: 1,
name: "Test Team",
slug: "test-team",
},
};
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("report");
});

it("should return report action for individual bookings", () => {
const context = createMockContext();
context.booking.eventType = {
...context.booking.eventType,
id: 2,
title: "Individual Event",
schedulingType: null,
team: null,
};
const actions = getReportActions(context);

expect(actions).toHaveLength(1);
expect(actions[0].id).toBe("report");
});
});
});
29 changes: 26 additions & 3 deletions apps/web/components/booking/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,21 @@ export function getAfterEventActions(context: BookingActionContext): ActionType[
return actions.filter(Boolean) as ActionType[];
}

export function getReportActions(context: BookingActionContext): ActionType[] {
const { booking: _booking, t } = context;

const actions: ActionType[] = [
{
id: "report",
label: t("report"),
icon: "flag",
disabled: false,
},
];

return actions;
}

export function shouldShowPendingActions(context: BookingActionContext): boolean {
const { isPending, isUpcoming, isCancelled } = context;
return isPending && isUpcoming && !isCancelled;
Expand All @@ -204,8 +219,14 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext):
}

export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
context;
const {
booking,
isBookingInPast,
isDisabledRescheduling,
isDisabledCancelling,
isPending: _isPending,
isConfirmed: _isConfirmed,
} = context;

switch (actionId) {
case "reschedule":
Expand All @@ -225,7 +246,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
}

export function getActionLabel(actionId: string, context: BookingActionContext): string {
const { booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;
const { booking: _booking, isTabRecurring, isRecurring, attendeeList, cardCharged, t } = context;

switch (actionId) {
case "reject":
Expand All @@ -240,6 +261,8 @@ export function getActionLabel(actionId: string, context: BookingActionContext):
: t("mark_as_no_show");
case "charge_card":
return cardCharged ? t("no_show_fee_charged") : t("collect_no_show_fee");
case "report":
return t("report");
default:
return t(actionId);
}
Expand Down
Loading
Loading