Skip to content

Commit 89ae748

Browse files
pumfleetdevin-ai-integration[bot]emrysal
authored
feat: Add download CSV button on bookings page (calcom#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>
1 parent aaabcf4 commit 89ae748

2 files changed

Lines changed: 146 additions & 69 deletions

File tree

apps/web/modules/bookings/components/BookingListContainer.tsx

Lines changed: 21 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,7 @@
11
"use client";
22

3-
import {
4-
useReactTable,
5-
getCoreRowModel,
6-
getSortedRowModel,
7-
} from "@tanstack/react-table";
8-
import { useRouter } from "next/navigation";
9-
import React, { useState, useMemo, useEffect, useCallback } from "react";
10-
113
import dayjs from "@calcom/dayjs";
12-
import {
13-
useDataTable,
14-
useDisplayedFilterCount,
15-
} from "@calcom/features/data-table";
16-
import { DataTableSegment, DataTableFilters } from "~/data-table/components";
4+
import { useDataTable, useDisplayedFilterCount } from "@calcom/features/data-table";
175
import { useLocale } from "@calcom/lib/hooks/useLocale";
186
import { trpc } from "@calcom/trpc/react";
197
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
@@ -22,26 +10,25 @@ import { Badge } from "@calcom/ui/components/badge";
2210
import { Button } from "@calcom/ui/components/button";
2311
import { ToggleGroup } from "@calcom/ui/components/form";
2412
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
25-
13+
import { getCoreRowModel, getSortedRowModel, useReactTable } from "@tanstack/react-table";
14+
import { useRouter } from "next/navigation";
15+
import React, { useCallback, useEffect, useMemo, useState } from "react";
2616
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
2717
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
2818
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
2919
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
3020
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
3121
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
3222
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
33-
23+
import { DataTableFilters, DataTableSegment } from "~/data-table/components";
3424
import {
3525
BookingDetailsSheetStoreProvider,
3626
useBookingDetailsSheetStore,
3727
} from "../store/bookingDetailsSheetStore";
38-
import type {
39-
RowData,
40-
BookingListingStatus,
41-
BookingsGetOutput,
42-
} from "../types";
28+
import type { BookingListingStatus, BookingsGetOutput, RowData } from "../types";
4329
import { BookingDetailsSheet } from "./BookingDetailsSheet";
4430
import { BookingList } from "./BookingList";
31+
import { BookingsCsvDownload } from "./BookingsCsvDownload";
4532
import { ViewToggleButton } from "./ViewToggleButton";
4633

4734
interface FilterButtonProps {
@@ -50,11 +37,7 @@ interface FilterButtonProps {
5037
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
5138
}
5239

53-
function FilterButton({
54-
table,
55-
displayedFilterCount,
56-
setShowFilters,
57-
}: FilterButtonProps) {
40+
function FilterButton({ table, displayedFilterCount, setShowFilters }: FilterButtonProps) {
5841
const { t } = useLocale();
5942

6043
if (displayedFilterCount === 0) {
@@ -67,8 +50,7 @@ function FilterButton({
6750
StartIcon="list-filter"
6851
className="h-full"
6952
size="sm"
70-
onClick={() => setShowFilters((value) => !value)}
71-
>
53+
onClick={() => setShowFilters((value) => !value)}>
7254
{t("filter")}
7355
<Badge variant="gray" className="ml-1">
7456
{displayedFilterCount}
@@ -109,21 +91,15 @@ function BookingListInner({
10991
}: BookingListInnerProps) {
11092
const { t } = useLocale();
11193
const user = useMeQuery().data;
112-
const setSelectedBookingUid = useBookingDetailsSheetStore(
113-
(state) => state.setSelectedBookingUid
114-
);
94+
const setSelectedBookingUid = useBookingDetailsSheetStore((state) => state.setSelectedBookingUid);
11595
const router = useRouter();
11696
const [showFilters, setShowFilters] = useState(true);
11797

11898
// Handle auto-selection for list view
11999
useListAutoSelector(bookings);
120100

121101
const ErrorView = errorMessage ? (
122-
<Alert
123-
severity="error"
124-
title={t("something_went_wrong")}
125-
message={errorMessage}
126-
/>
102+
<Alert severity="error" title={t("something_went_wrong")} message={errorMessage} />
127103
) : undefined;
128104

129105
const handleBookingClick = useCallback(
@@ -190,9 +166,7 @@ function BookingListInner({
190166
value={currentTab}
191167
onValueChange={(value) => {
192168
if (!value) return;
193-
const selectedTab = tabOptions.find(
194-
(tab) => tab.value === value
195-
);
169+
const selectedTab = tabOptions.find((tab) => tab.value === value);
196170
if (selectedTab?.href) {
197171
router.push(selectedTab.href);
198172
}
@@ -213,9 +187,8 @@ function BookingListInner({
213187
<div className="hidden grow md:block" />
214188

215189
<DataTableSegment.Select />
216-
{bookingsV3Enabled && (
217-
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
218-
)}
190+
<BookingsCsvDownload status={status} />
191+
{bookingsV3Enabled && <ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />}
219192
</div>
220193
{displayedFilterCount > 0 && showFilters && (
221194
<div className="mt-3 flex flex-wrap items-center gap-2">
@@ -230,11 +203,7 @@ function BookingListInner({
230203
</div>
231204
)}
232205
{status === "upcoming" && !isEmpty && (
233-
<WipeMyCalActionButton
234-
className="mt-4"
235-
bookingStatus={status}
236-
bookingsEmpty={isEmpty}
237-
/>
206+
<WipeMyCalActionButton className="mt-4" bookingStatus={status} bookingsEmpty={isEmpty} />
238207
)}
239208
<div className="mt-4">
240209
<BookingList
@@ -250,9 +219,7 @@ function BookingListInner({
250219
{bookingsV3Enabled && (
251220
<BookingDetailsSheet
252221
userTimeZone={user?.timeZone}
253-
userTimeFormat={
254-
user?.timeFormat === null ? undefined : user?.timeFormat
255-
}
222+
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
256223
userId={user?.id}
257224
userEmail={user?.email}
258225
bookingAuditEnabled={bookingAuditEnabled}
@@ -264,15 +231,8 @@ function BookingListInner({
264231

265232
export function BookingListContainer(props: BookingListContainerProps) {
266233
const { limit, offset, setPageIndex } = useDataTable();
267-
const {
268-
eventTypeIds,
269-
teamIds,
270-
userIds,
271-
dateRange,
272-
attendeeName,
273-
attendeeEmail,
274-
bookingUid,
275-
} = useBookingFilters();
234+
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
235+
useBookingFilters();
276236

277237
// Build query input once - shared between query and prefetching
278238
const queryInput = useMemo(
@@ -290,9 +250,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
290250
afterStartDate: dateRange?.startDate
291251
? dayjs(dateRange?.startDate).startOf("day").toISOString()
292252
: undefined,
293-
beforeEndDate: dateRange?.endDate
294-
? dayjs(dateRange?.endDate).endOf("day").toISOString()
295-
: undefined,
253+
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
296254
},
297255
}),
298256
[
@@ -314,10 +272,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
314272
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
315273
});
316274

317-
const bookings = useMemo(
318-
() => query.data?.bookings ?? [],
319-
[query.data?.bookings]
320-
);
275+
const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]);
321276

322277
// Always call the hook and provide navigation capabilities
323278
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
@@ -330,10 +285,7 @@ export function BookingListContainer(props: BookingListContainerProps) {
330285
});
331286

332287
return (
333-
<BookingDetailsSheetStoreProvider
334-
bookings={bookings}
335-
capabilities={capabilities}
336-
>
288+
<BookingDetailsSheetStoreProvider bookings={bookings} capabilities={capabilities}>
337289
<BookingListInner
338290
{...props}
339291
data={query.data}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"use client";
2+
3+
import dayjs from "@calcom/dayjs";
4+
import { downloadAsCsv } from "@calcom/lib/csvUtils";
5+
import { useLocale } from "@calcom/lib/hooks/useLocale";
6+
import type { RouterOutputs } from "@calcom/trpc/react";
7+
import { trpc } from "@calcom/trpc/react";
8+
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
9+
import { Button } from "@calcom/ui/components/button";
10+
import { hideProgressToast, showProgressToast, showToast } from "@calcom/ui/components/toast";
11+
import { useState } from "react";
12+
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
13+
import type { BookingListingStatus } from "../types";
14+
15+
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
16+
type TranslationFunction = (key: string) => string;
17+
18+
const BATCH_SIZE = 100;
19+
20+
interface BookingsCsvDownloadProps {
21+
status: BookingListingStatus;
22+
}
23+
24+
function transformBookingToCsv(booking: BookingOutput, t: TranslationFunction) {
25+
return {
26+
[t("booking_uid")]: booking.uid,
27+
[t("title")]: booking.title,
28+
[t("status")]: booking.status,
29+
[t("start_time")]: dayjs(booking.startTime).format("YYYY-MM-DD HH:mm:ss"),
30+
[t("end_time")]: dayjs(booking.endTime).format("YYYY-MM-DD HH:mm:ss"),
31+
[t("attendee_name")]: booking.attendees.map((a) => a.name).join("; "),
32+
[t("email")]: booking.attendees.map((a) => a.email).join("; "),
33+
[t("event_type")]: booking.eventType?.title ?? "",
34+
[t("location")]: booking.location ?? "",
35+
};
36+
}
37+
38+
export function BookingsCsvDownload({ status }: BookingsCsvDownloadProps) {
39+
const { t } = useLocale();
40+
const { data: user, isPending: isUserPending } = useMeQuery();
41+
const [isDownloading, setIsDownloading] = useState(false);
42+
const utils = trpc.useUtils();
43+
44+
const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } =
45+
useBookingFilters();
46+
47+
// Only show for users who are part of an organization
48+
const isOrgUser = Boolean(user?.organizationId);
49+
50+
if (isUserPending || !isOrgUser) {
51+
return null;
52+
}
53+
54+
const fetchBatch = async (offset: number) => {
55+
const result = await utils.viewer.bookings.get.fetch({
56+
limit: BATCH_SIZE,
57+
offset,
58+
filters: {
59+
statuses: [status],
60+
eventTypeIds,
61+
teamIds,
62+
userIds,
63+
attendeeName,
64+
attendeeEmail,
65+
bookingUid,
66+
afterStartDate: dateRange?.startDate
67+
? dayjs(dateRange?.startDate).startOf("day").toISOString()
68+
: undefined,
69+
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
70+
},
71+
});
72+
73+
return {
74+
bookings: result.bookings,
75+
totalCount: result.totalCount,
76+
};
77+
};
78+
79+
const handleDownload = async () => {
80+
try {
81+
setIsDownloading(true);
82+
showProgressToast(0);
83+
84+
// Fetch first batch to get total count
85+
const firstBatch = await fetchBatch(0);
86+
let allBookings = firstBatch.bookings;
87+
const totalCount = firstBatch.totalCount;
88+
89+
// Continue fetching remaining batches
90+
while (allBookings.length < totalCount) {
91+
const offset = allBookings.length;
92+
const batch = await fetchBatch(offset);
93+
if (batch.bookings.length === 0) break; // Prevent infinite loop if batch returns empty
94+
allBookings = [...allBookings, ...batch.bookings];
95+
96+
const currentProgress = Math.min(Math.round((allBookings.length / totalCount) * 100), 99);
97+
showProgressToast(currentProgress);
98+
}
99+
100+
showProgressToast(100);
101+
102+
// Transform and download
103+
const csvData = allBookings.map((booking) => transformBookingToCsv(booking, t));
104+
const filename = `${t("bookings").toLowerCase()}-${status}-${dayjs().format("YYYY-MM-DD")}.csv`;
105+
downloadAsCsv(csvData, filename);
106+
} catch {
107+
showToast(t("unexpected_error_try_again"), "error");
108+
} finally {
109+
setIsDownloading(false);
110+
hideProgressToast();
111+
}
112+
};
113+
114+
return (
115+
<Button
116+
color="secondary"
117+
StartIcon="download"
118+
loading={isDownloading}
119+
onClick={handleDownload}
120+
size="sm"
121+
className="h-full">
122+
{t("download")}
123+
</Button>
124+
);
125+
}

0 commit comments

Comments
 (0)