feat: Add download CSV button on bookings page (#27107)
* Add download CSV button on bookings page * fix: localize CSV headers and filename using i18n Co-Authored-By: alex@cal.com <me@alexvanandel.com> * fix: prevent infinite loop when batch returns empty bookings during CSV download Co-Authored-By: alex@cal.com <me@alexvanandel.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: alex@cal.com <me@alexvanandel.com>
This commit is contained in:
co-authored by
alex@cal.com <me@alexvanandel.com>
alex@cal.com <me@alexvanandel.com>
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
alex@cal.com <me@alexvanandel.com>
parent
aaabcf4430
commit
89ae748681
@@ -1,19 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState, useMemo, useEffect, useCallback } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
useDataTable,
|
||||
useDisplayedFilterCount,
|
||||
} from "@calcom/features/data-table";
|
||||
import { DataTableSegment, DataTableFilters } from "~/data-table/components";
|
||||
import { useDataTable, useDisplayedFilterCount } from "@calcom/features/data-table";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
@@ -22,7 +10,9 @@ import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
|
||||
|
||||
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
|
||||
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
|
||||
@@ -30,18 +20,15 @@ import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
|
||||
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
|
||||
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
|
||||
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
|
||||
|
||||
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
|
||||
import {
|
||||
BookingDetailsSheetStoreProvider,
|
||||
useBookingDetailsSheetStore,
|
||||
} from "../store/bookingDetailsSheetStore";
|
||||
import type {
|
||||
RowData,
|
||||
BookingListingStatus,
|
||||
BookingsGetOutput,
|
||||
} from "../types";
|
||||
import type { BookingListingStatus, BookingsGetOutput, RowData } from "../types";
|
||||
import { BookingDetailsSheet } from "./BookingDetailsSheet";
|
||||
import { BookingList } from "./BookingList";
|
||||
import { BookingsCsvDownload } from "./BookingsCsvDownload";
|
||||
import { ViewToggleButton } from "./ViewToggleButton";
|
||||
|
||||
interface FilterButtonProps {
|
||||
@@ -50,11 +37,7 @@ interface FilterButtonProps {
|
||||
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
table,
|
||||
displayedFilterCount,
|
||||
setShowFilters,
|
||||
}: FilterButtonProps) {
|
||||
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (displayedFilterCount === 0) {
|
||||
@@ -67,8 +50,7 @@ function FilterButton({
|
||||
StartIcon="list-filter"
|
||||
className="h-full"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters((value) => !value)}
|
||||
>
|
||||
onClick={() => setShowFilters((value) => !value)}>
|
||||
{t("filter")}
|
||||
<Badge variant="gray" className="ml-1">
|
||||
{displayedFilterCount}
|
||||
@@ -109,9 +91,7 @@ function BookingListInner({
|
||||
}: BookingListInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore(
|
||||
(state) => state.setSelectedBookingUid
|
||||
);
|
||||
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
|
||||
const router = useRouter();
|
||||
const [showFilters, setShowFilters] = useState(true);
|
||||
|
||||
@@ -119,11 +99,7 @@ function BookingListInner({
|
||||
useListAutoSelector(bookings);
|
||||
|
||||
const ErrorView = errorMessage ? (
|
||||
<Alert
|
||||
severity="error"
|
||||
title={t("something_went_wrong")}
|
||||
message={errorMessage}
|
||||
/>
|
||||
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
|
||||
) : undefined;
|
||||
|
||||
const handleBookingClick = useCallback(
|
||||
@@ -190,9 +166,7 @@ function BookingListInner({
|
||||
value={currentTab}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
const selectedTab = tabOptions.find(
|
||||
(tab) => tab.value === value
|
||||
);
|
||||
const selectedTab = tabOptions.find((tab) => tab.value === value);
|
||||
if (selectedTab?.href) {
|
||||
router.push(selectedTab.href);
|
||||
}
|
||||
@@ -213,9 +187,8 @@ function BookingListInner({
|
||||
<div className="hidden grow md:block" />
|
||||
|
||||
<DataTableSegment.Select />
|
||||
{bookingsV3Enabled && (
|
||||
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
|
||||
)}
|
||||
<BookingsCsvDownload status={status} />
|
||||
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
|
||||
</div>
|
||||
{displayedFilterCount > 0 && showFilters && (
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
@@ -230,11 +203,7 @@ function BookingListInner({
|
||||
</div>
|
||||
)}
|
||||
{status === "upcoming" && !isEmpty && (
|
||||
<WipeMyCalActionButton
|
||||
className="mt-4"
|
||||
bookingStatus={status}
|
||||
bookingsEmpty={isEmpty}
|
||||
/>
|
||||
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
|
||||
)}
|
||||
<div className="mt-4">
|
||||
<BookingList
|
||||
@@ -250,9 +219,7 @@ function BookingListInner({
|
||||
{bookingsV3Enabled && (
|
||||
<BookingDetailsSheet
|
||||
userTimeZone={user?.timeZone}
|
||||
userTimeFormat={
|
||||
user?.timeFormat === null ? undefined : user?.timeFormat
|
||||
}
|
||||
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
|
||||
userId={user?.id}
|
||||
userEmail={user?.email}
|
||||
bookingAuditEnabled={bookingAuditEnabled}
|
||||
@@ -264,15 +231,8 @@ function BookingListInner({
|
||||
|
||||
export function BookingListContainer(props: BookingListContainerProps) {
|
||||
const { limit, offset, setPageIndex } = useDataTable();
|
||||
const {
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
dateRange,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
} = useBookingFilters();
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
// Build query input once - shared between query and prefetching
|
||||
const queryInput = useMemo(
|
||||
@@ -290,9 +250,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate
|
||||
? dayjs(dateRange?.endDate).endOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -314,10 +272,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
|
||||
});
|
||||
|
||||
const bookings = useMemo(
|
||||
() => query.data?.bookings ?? [],
|
||||
[query.data?.bookings]
|
||||
);
|
||||
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
|
||||
|
||||
// Always call the hook and provide navigation capabilities
|
||||
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
|
||||
@@ -330,10 +285,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<BookingDetailsSheetStoreProvider
|
||||
bookings={bookings}
|
||||
capabilities={capabilities}
|
||||
>
|
||||
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
|
||||
<BookingListInner
|
||||
{...props}
|
||||
data={query.data}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { hideProgressToast, showProgressToast, showToast } from "@calcom/ui/components/toast";
|
||||
import { useState } from "react";
|
||||
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
|
||||
import type { BookingListingStatus } from "../types";
|
||||
|
||||
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
|
||||
type TranslationFunction = (key: string) => string;
|
||||
|
||||
const BATCH_SIZE = 100;
|
||||
|
||||
interface BookingsCsvDownloadProps {
|
||||
status: BookingListingStatus;
|
||||
}
|
||||
|
||||
function transformBookingToCsv(booking: BookingOutput, t: TranslationFunction) {
|
||||
return {
|
||||
[t("booking_uid")]: booking.uid,
|
||||
[t("title")]: booking.title,
|
||||
[t("status")]: booking.status,
|
||||
[t("start_time")]: dayjs(booking.startTime).format("YYYY-MM-DD HH:mm:ss"),
|
||||
[t("end_time")]: dayjs(booking.endTime).format("YYYY-MM-DD HH:mm:ss"),
|
||||
[t("attendee_name")]: booking.attendees.map((a) => a.name).join("; "),
|
||||
[t("email")]: booking.attendees.map((a) => a.email).join("; "),
|
||||
[t("event_type")]: booking.eventType?.title ?? "",
|
||||
[t("location")]: booking.location ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
|
||||
const { t } = useLocale();
|
||||
const { data: user, isPending: isUserPending } = useMeQuery();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
|
||||
useBookingFilters();
|
||||
|
||||
// Only show for users who are part of an organization
|
||||
const isOrgUser = Boolean(user?.organizationId);
|
||||
|
||||
if (isUserPending || !isOrgUser) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetchBatch = async (offset: number) => {
|
||||
const result = await utils.viewer.bookings.get.fetch({
|
||||
limit: BATCH_SIZE,
|
||||
offset,
|
||||
filters: {
|
||||
statuses: [status],
|
||||
eventTypeIds,
|
||||
teamIds,
|
||||
userIds,
|
||||
attendeeName,
|
||||
attendeeEmail,
|
||||
bookingUid,
|
||||
afterStartDate: dateRange?.startDate
|
||||
? dayjs(dateRange?.startDate).startOf("day").toISOString()
|
||||
: undefined,
|
||||
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
bookings: result.bookings,
|
||||
totalCount: result.totalCount,
|
||||
};
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
setIsDownloading(true);
|
||||
showProgressToast(0);
|
||||
|
||||
// Fetch first batch to get total count
|
||||
const firstBatch = await fetchBatch(0);
|
||||
let allBookings = firstBatch.bookings;
|
||||
const totalCount = firstBatch.totalCount;
|
||||
|
||||
// Continue fetching remaining batches
|
||||
while (allBookings.length < totalCount) {
|
||||
const offset = allBookings.length;
|
||||
const batch = await fetchBatch(offset);
|
||||
if (batch.bookings.length === 0) break; // Prevent infinite loop if batch returns empty
|
||||
allBookings = [...allBookings, ...batch.bookings];
|
||||
|
||||
const currentProgress = Math.min(Math.round((allBookings.length / totalCount) * 100), 99);
|
||||
showProgressToast(currentProgress);
|
||||
}
|
||||
|
||||
showProgressToast(100);
|
||||
|
||||
// Transform and download
|
||||
const csvData = allBookings.map((booking) => transformBookingToCsv(booking, t));
|
||||
const filename = `${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`;
|
||||
downloadAsCsv(csvData, filename);
|
||||
} catch {
|
||||
showToast(t("unexpected_error_try_again"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
hideProgressToast();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
color="secondary"
|
||||
StartIcon="download"
|
||||
loading={isDownloading}
|
||||
onClick={handleDownload}
|
||||
size="sm"
|
||||
className="h-full">
|
||||
{t("download")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user