diff --git a/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/booking-reports/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/booking-reports/page.tsx new file mode 100644 index 00000000000000..a681fce0050f77 --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(admin-layout)/admin/booking-reports/page.tsx @@ -0,0 +1,26 @@ +import { _generateMetadata } from "app/_utils"; +import { getTranslate } from "app/_utils"; + +import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader"; + +import BookingReportsView from "~/settings/admin/booking-reports-view"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("booking_reports"), + (t) => t("admin_booking_reports_description"), + undefined, + undefined, + "/settings/admin/booking-reports" + ); + +const Page = async () => { + const t = await getTranslate(); + return ( + + + + ); +}; + +export default Page; diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index 43190417291565..354a1448f30887 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -142,6 +142,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => { { name: "apps", href: "/settings/admin/apps/calendar" }, { name: "users", href: "/settings/admin/users" }, { name: "organizations", href: "/settings/admin/organizations" }, + { name: "booking_reports", href: "/settings/admin/booking-reports" }, { name: "lockedSMS", href: "/settings/admin/lockedSMS" }, { name: "oAuth", href: "/settings/admin/oAuth" }, { name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" }, diff --git a/apps/web/modules/settings/admin/booking-reports-view.tsx b/apps/web/modules/settings/admin/booking-reports-view.tsx new file mode 100644 index 00000000000000..f4b5bedc8e594d --- /dev/null +++ b/apps/web/modules/settings/admin/booking-reports-view.tsx @@ -0,0 +1,433 @@ +"use client"; + +import { keepPreviousData } from "@tanstack/react-query"; +import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; +import { useMemo, useState } from "react"; + +import { + DataTableWrapper, + DataTableToolbar, + DataTableFilters, + ColumnFilterType, + useDataTable, + useColumnFilters, + convertFacetedValuesToMap, + DataTableSelectionBar, + DataTableProvider, +} from "@calcom/features/data-table"; +import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Badge } from "@calcom/ui/components/badge"; +import { Button } from "@calcom/ui/components/button"; +import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; +import { ConfirmationDialogContent, Dialog } from "@calcom/ui/components/dialog"; +import { + Dropdown, + DropdownItem, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuPortal, +} from "@calcom/ui/components/dropdown"; +import { Checkbox } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; + +import { AddToBlocklistModal } from "./components/add-to-blocklist-modal"; +import { BookingReportDetailsSheet } from "./components/booking-report-details-sheet"; +import { BulkAddToBlocklist } from "./components/bulk-add-to-blocklist"; + +type BookingReport = RouterOutputs["viewer"]["admin"]["listBookingReports"]["rows"][number]; + +function BookingReportsTable() { + const { t } = useLocale(); + const { limit, offset, searchTerm } = useDataTable(); + const columnFilters = useColumnFilters(); + + const [selectedReport, setSelectedReport] = useState(null); + const [showWatchlistModal, setShowWatchlistModal] = useState(false); + const [showDetailsSheet, setShowDetailsSheet] = useState(false); + const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [rowSelection, setRowSelection] = useState({}); + + const filters = useMemo(() => { + const statusFilter = columnFilters.find((f) => f.id === "status"); + + let hasWatchlistValue: boolean | undefined = undefined; + + if (statusFilter && statusFilter.value.type === ColumnFilterType.MULTI_SELECT) { + const filterValues = statusFilter.value.data; + if (Array.isArray(filterValues) && filterValues.length > 0) { + if (filterValues.length === 2 || filterValues.length === 0) { + hasWatchlistValue = undefined; + } else { + hasWatchlistValue = filterValues[0] === "blocked"; + } + } + } + + return { + hasWatchlist: hasWatchlistValue, + }; + }, [columnFilters]); + + const { data, isPending } = trpc.viewer.admin.listBookingReports.useQuery( + { + limit, + offset, + searchTerm, + filters, + }, + { + placeholderData: keepPreviousData, + } + ); + + const utils = trpc.useUtils(); + + const deleteReportMutation = trpc.viewer.admin.deleteBookingReport.useMutation({ + onSuccess: async () => { + await utils.viewer.admin.listBookingReports.invalidate(); + showToast(t("booking_report_deleted"), "success"); + setShowDeleteDialog(false); + setSelectedReport(null); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const totalRowCount = data?.meta?.totalRowCount ?? 0; + const flatData = useMemo(() => data?.rows ?? [], [data]); + + const columns = useMemo[]>( + () => [ + { + id: "select", + enableHiding: false, + enableSorting: false, + enableResizing: false, + size: 30, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + }, + { + id: "bookerEmail", + header: t("booker_email"), + accessorKey: "bookerEmail", + enableHiding: false, + cell: ({ row }) => {row.original.bookerEmail}, + }, + { + id: "reportedBy", + header: t("reported_by"), + accessorFn: (row) => row.reporter?.email ?? "-", + size: 180, + cell: ({ row }) => {row.original.reporter?.email ?? "-"}, + }, + { + id: "reason", + header: t("reason"), + accessorKey: "reason", + size: 90, + cell: ({ row }) => { + const reasonColors: Record = { + SPAM: "red", + DONT_KNOW_PERSON: "orange", + OTHER: "gray", + }; + const reason = t(row.original.reason.toLowerCase()); + return ( + + {reason.charAt(0).toUpperCase() + reason.slice(1)} + + ); + }, + }, + { + id: "status", + header: t("status"), + accessorFn: (row) => (row.watchlistId ? "blocked" : "pending"), + size: 120, + cell: ({ row }) => ( + + {row.original.watchlistId ? t("blocked") : t("pending")} + + ), + }, + { + id: "actions", + header: "", + size: 90, + enableHiding: false, + enableSorting: false, + enableResizing: false, + cell: ({ row }) => { + const report = row.original; + return ( +
+
+ + ); + }, + }, + ], + [t, setSelectedReport, setShowDetailsSheet] + ); + + const table = useReactTable({ + data: flatData, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + manualPagination: true, + getRowId: (row) => row.id, + onRowSelectionChange: setRowSelection, + state: { + rowSelection, + }, + enableRowSelection: true, + getFacetedUniqueValues: (_, columnId) => () => { + switch (columnId) { + case "status": + return convertFacetedValuesToMap([ + { label: t("pending"), value: "pending" }, + { label: t("blocked"), value: "blocked" }, + ]); + case "reason": + return convertFacetedValuesToMap([ + { label: t("spam"), value: "SPAM" }, + { label: t("dont_know_person"), value: "DONT_KNOW_PERSON" }, + { label: t("other"), value: "OTHER" }, + ]); + default: + return new Map(); + } + }, + }); + + const numberOfSelectedRows = table.getFilteredSelectedRowModel().rows.length; + + return ( + <> + + + + + } + ToolbarRight={ + <> + + + }> + {numberOfSelectedRows > 0 && ( + +

+ {t("number_selected", { count: numberOfSelectedRows })} +

+ row.original)} + onSuccess={() => table.toggleAllPageRowsSelected(false)} + /> +
+ )} +
+ + {selectedReport && showDetailsSheet && ( + { + setShowDetailsSheet(false); + setSelectedReport(null); + }} + report={selectedReport} + onAddToBlocklist={ + !selectedReport.watchlistId + ? () => { + setShowDetailsSheet(false); + setShowWatchlistModal(true); + } + : undefined + } + /> + )} + + {selectedReport && showWatchlistModal && ( + { + setShowWatchlistModal(false); + setSelectedReport(null); + }} + report={selectedReport} + /> + )} + + {selectedReport && showDeleteDialog && ( + { + if (!open) { + setShowDeleteDialog(false); + setSelectedReport(null); + } + }}> + { + deleteReportMutation.mutate({ reportId: selectedReport.id }); + }}> + {t("delete_booking_report_confirmation")} + + + )} + + ); +} + +export default function BookingReportsView() { + return ( + + + + ); +} diff --git a/apps/web/modules/settings/admin/components/add-to-blocklist-modal.tsx b/apps/web/modules/settings/admin/components/add-to-blocklist-modal.tsx new file mode 100644 index 00000000000000..6b0682a9a5a65e --- /dev/null +++ b/apps/web/modules/settings/admin/components/add-to-blocklist-modal.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { WatchlistType } from "@calcom/prisma/enums"; +import { trpc } from "@calcom/trpc"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Button } from "@calcom/ui/components/button"; +import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@calcom/ui/components/dialog"; +import { Form, Label, Select, TextAreaField } from "@calcom/ui/components/form"; +import { showToast } from "@calcom/ui/components/toast"; + +type BookingReport = RouterOutputs["viewer"]["admin"]["listBookingReports"]["rows"][number]; + +interface AddToWatchlistModalProps { + open: boolean; + onClose: () => void; + report?: BookingReport; + reports?: BookingReport[]; +} + +const formSchema = z.object({ + type: z.nativeEnum(WatchlistType), + description: z.string().optional(), +}); + +type FormValues = z.infer; + +export function AddToBlocklistModal({ open, onClose, report, reports }: AddToWatchlistModalProps) { + const { t } = useLocale(); + const utils = trpc.useUtils(); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + type: WatchlistType.EMAIL, + description: "", + }, + }); + + const isBulk = !!reports && reports.length > 0; + const firstReport = isBulk ? reports[0] : report; + + if (!firstReport) { + return null; + } + + const watchlistType = form.watch("type"); + + const addToWatchlistMutation = trpc.viewer.admin.addToWatchlist.useMutation({ + onSuccess: () => { + showToast(t("successfully_added_to_blocklist"), "success"); + utils.viewer.admin.listBookingReports.invalidate(); + onClose(); + form.reset(); + }, + onError: (error) => { + showToast(error.message, "error"); + }, + }); + + const onSubmit = (data: FormValues) => { + const reportIds = isBulk ? reports.map((r) => r.id) : [firstReport.id]; + + addToWatchlistMutation.mutate({ + reportIds, + type: data.type, + description: data.description, + }); + }; + + return ( + !isOpen && onClose()}> + + + +
+
+
+ +