Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
]
: []),
{
name: "privacy",
name: "privacy_and_security",
href: "/settings/organizations/privacy",
},

Expand Down Expand Up @@ -170,7 +170,14 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = ["privacy", "OAuth Clients", "SSO", "directory_sync", "delegation_credential"];
const organizationAdminKeys = [
"privacy",
"privacy_and_security",
"OAuth Clients",
"SSO",
"directory_sync",
"delegation_credential",
];

export interface SettingsPermissions {
canViewRoles?: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { DataTableSkeleton } from "@calcom/features/data-table";

export default function Loading() {
return <DataTableSkeleton />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,16 @@ import { validateUserHasOrg } from "../../actions/validateUserHasOrg";

export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("privacy"),
(t) => t("privacy_and_security"),
(t) => t("privacy_organization_description"),
undefined,
undefined,
"/settings/organizations/privacy"
);

const Page = async () => {
const t = await getTranslate();

const session = await validateUserHasOrg();
const t = await getTranslate();

if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
Expand All @@ -47,7 +46,7 @@ const Page = async () => {
}

return (
<SettingsHeader title={t("privacy")} description={t("privacy_organization_description")}>
<SettingsHeader title={t("privacy_and_security")} description={t("privacy_organization_description")}>
<PrivacyView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
Expand Down
74 changes: 74 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 All @@ -60,9 +61,11 @@ import {
getCancelEventAction,
getEditEventActions,
getAfterEventActions,
getReportAction,
shouldShowPendingActions,
shouldShowEditActions,
shouldShowRecurringCancelAction,
shouldShowIndividualReportButton,
type BookingActionContext,
} from "./bookingActions";

Expand Down Expand Up @@ -181,6 +184,13 @@ function BookingListItem(booking: BookingItemProps) {
const isPending = booking.status === BookingStatus.PENDING;
const isRescheduled = booking.fromReschedule !== null;
const isRecurring = booking.recurringEventId !== null;

const getReportStatus = (): "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);
Expand Down Expand Up @@ -292,6 +302,7 @@ function BookingListItem(booking: BookingItemProps) {
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: () => {
Expand Down Expand Up @@ -402,6 +413,14 @@ function BookingListItem(booking: BookingItemProps) {
(action.id === "view_recordings" && !booking.isRecorded),
})) as ActionType[];

const reportAction = getReportAction(actionContext);
const reportActionWithHandler = reportAction
? {
...reportAction,
onClick: () => setIsOpenReportDialog(true),
}
: null;

return (
<>
<RescheduleDialog
Expand Down Expand Up @@ -430,6 +449,13 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenAddGuestsDialog}
bookingId={booking.id}
/>
<ReportBookingDialog
isOpenDialog={isOpenReportDialog}
setIsOpenDialog={setIsOpenReportDialog}
bookingId={booking.id}
isRecurring={isRecurring}
status={getReportStatus()}
/>
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
isOpenDialog={chargeCardDialogIsOpen}
Expand Down Expand Up @@ -685,6 +711,21 @@ function BookingListItem(booking: BookingItemProps) {
</DropdownItem>
</DropdownMenuItem>
))}
{reportActionWithHandler && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem className="rounded-lg" key={reportActionWithHandler.id}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
data-testid={reportActionWithHandler.id}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
Expand All @@ -708,6 +749,21 @@ function BookingListItem(booking: BookingItemProps) {
</Dropdown>
)}
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && reportActionWithHandler && (
<div className="flex items-center space-x-2">
<Button
type="button"
variant="icon"
color="destructive"
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className="h-8 w-8"
tooltip={reportActionWithHandler.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 Down Expand Up @@ -776,6 +832,24 @@ const BookingItemBadges = ({
{booking?.assignmentReason.length > 0 && (
<AssignmentReasonTooltip assignmentReason={booking.assignmentReason[0]} />
)}
{booking.report && (
<Tooltip
content={
<div className="text-xs">
{(() => {
const reasonKey = `report_reason_${booking.report.reason.toLowerCase()}`;
const reasonText = t(reasonKey);
return booking.report.description
? `${reasonText}: ${booking.report.description}`
: reasonText;
})()}
</div>
}>
<Badge className="ltr:mr-2 rtl:ml-2" variant="red">
{t("reported")}
</Badge>
</Tooltip>
)}
{booking.paid && !booking.payment[0] ? (
<Badge className="ltr:mr-2 rtl:ml-2" variant="orange">
{t("error_collecting_card")}
Expand Down
22 changes: 22 additions & 0 deletions apps/web/components/booking/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,22 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
return actions.filter(Boolean) as ActionType[];
}

export function getReportAction(context: BookingActionContext): ActionType | null {
const { booking, t } = context;

if (booking.report) {
return null;
}

return {
id: "report",
label: t("report_booking"),
icon: "flag",
color: "destructive",
disabled: false,
};
}

export function getAfterEventActions(context: BookingActionContext): ActionType[] {
const { booking, cardCharged, attendeeList, t } = context;

Expand Down Expand Up @@ -203,6 +219,12 @@ export function shouldShowRecurringCancelAction(context: BookingActionContext):
return isTabRecurring && isRecurring;
}

export function shouldShowIndividualReportButton(context: BookingActionContext): boolean {
const { booking, isPending, isUpcoming, isCancelled, isRejected } = context;
const hasDropdown = shouldShowEditActions(context);
return !booking.report && !hasDropdown && (isCancelled || isRejected || (isPending && isUpcoming));
}

export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isPending, isConfirmed } =
context;
Expand Down
139 changes: 139 additions & 0 deletions apps/web/components/dialog/ReportBookingDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { Dispatch, SetStateAction } from "react";
import { Controller, useForm } from "react-hook-form";

import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { ReportReason } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert } from "@calcom/ui/components/alert";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
import { Select, Label } from "@calcom/ui/components/form";
import { TextArea } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";

type BookingReportStatus = "upcoming" | "past" | "cancelled" | "rejected";

interface IReportBookingDialog {
isOpenDialog: boolean;
setIsOpenDialog: Dispatch<SetStateAction<boolean>>;
bookingId: number;
isRecurring: boolean;
status: BookingReportStatus;
}

interface FormValues {
reason: ReportReason;
description: string;
}

export const ReportBookingDialog = (props: IReportBookingDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const { isOpenDialog, setIsOpenDialog, bookingId, status } = props;

const willBeCancelled = status === "upcoming";

const {
control,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
defaultValues: {
reason: ReportReason.SPAM,
description: "",
},
});

const { mutate: reportBooking, isPending } = trpc.viewer.bookings.reportBooking.useMutation({
async onSuccess(data) {
showToast(data.message, "success");
setIsOpenDialog(false);
await utils.viewer.bookings.invalidate();
},
onError(error) {
showToast(error.message || t("unexpected_error_try_again"), "error");
},
});

const onSubmit = (data: FormValues) => {
reportBooking({
bookingId,
reason: data.reason,
description: data.description || undefined,
});
};

const reasonOptions = [
{ label: t("report_reason_spam"), value: ReportReason.SPAM },
{ label: t("report_reason_dont_know_person"), value: ReportReason.DONT_KNOW_PERSON },
{ label: t("report_reason_other"), value: ReportReason.OTHER },
];

return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-row space-x-3">
<div className="w-full">
<DialogHeader title={t("report_booking")} subtitle={t("report_booking_description")} />
<div className="mb-4">
<Label htmlFor="reason" className="text-emphasis mb-2 block text-sm font-medium">
{t("reason")} <span className="text-destructive">*</span>
</Label>
<Controller
name="reason"
control={control}
rules={{ required: t("field_required") }}
render={({ field }) => (
<Select
{...field}
options={reasonOptions}
onChange={(option) => {
if (option) field.onChange(option.value);
}}
value={reasonOptions.find((opt) => opt.value === field.value)}
/>
)}
/>
{errors.reason && <p className="text-destructive mt-1 text-sm">{errors.reason.message}</p>}
</div>

<div className="mb-4">
<Label htmlFor="description" className="text-emphasis mb-2 block text-sm font-medium">
{t("description")} <span className="text-subtle font-normal">({t("optional")})</span>
</Label>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextArea {...field} placeholder={t("report_booking_description_placeholder")} rows={3} />
)}
/>
</div>

{willBeCancelled && (
<div className="mb-4">
<Alert severity="warning" title={t("report_booking_will_cancel_description")} />
</div>
)}
</div>
</div>

<DialogFooter showDivider className="mt-8">
<Button
type="button"
color="secondary"
onClick={() => setIsOpenDialog(false)}
disabled={isPending}>
{t("cancel")}
</Button>
<Button type="submit" color="primary" disabled={isPending} loading={isPending}>
{t("submit_report")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
Loading