Skip to content

Commit 6701e2f

Browse files
authored
Merge pull request #1695 from Shelf-nu/1675-feature-request-export-of-bookings-to-csv
feature: export of bookings to csv
2 parents 5990057 + 7dd08de commit 6701e2f

File tree

9 files changed

+493
-139
lines changed

9 files changed

+493
-139
lines changed
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useState } from "react";
2+
import { useAtomValue } from "jotai";
3+
import { selectedBulkItemsAtom } from "~/atoms/list";
4+
import { useSearchParams } from "~/hooks/search-params";
5+
import { ALL_SELECTED_KEY, isSelectingAllItems } from "~/utils/list";
6+
import { Button } from "../shared/button";
7+
import { Spinner } from "../shared/spinner";
8+
9+
export function ExportBookingsButton() {
10+
const selectedBookings = useAtomValue(selectedBulkItemsAtom);
11+
const disabled = selectedBookings.length === 0;
12+
const [isDownloading, setIsDownloading] = useState(false);
13+
const [searchParams] = useSearchParams();
14+
15+
const allSelected = isSelectingAllItems(selectedBookings);
16+
const title = `Export selection ${
17+
disabled ? "" : allSelected ? "(All)" : `(${selectedBookings.length})`
18+
}`;
19+
20+
/** Get the bookingsIds from the atom and add them to bookingsIds search param */
21+
const bookingsIds = selectedBookings.map((booking) => booking.id);
22+
23+
const hasAllSelected = bookingsIds.includes(ALL_SELECTED_KEY);
24+
let fetchSearchParams = "";
25+
/**
26+
* We have to check if ALL_SELECTED_KEY is included, and if it is, we need to strip the bookingsIds from the searchParams and send all the rest of the search params to the loader
27+
* Then inside the bookings.export loader we can know how to query the bookings
28+
* It is important to keep the ALL_SELECTED_KEY because that helps us know how to query
29+
*/
30+
if (hasAllSelected) {
31+
const searchParamsCopy = new URLSearchParams(searchParams);
32+
// Delete bookingsIds
33+
searchParamsCopy.delete("bookingsIds");
34+
// Add back ALL_SELECTED_KEY to bookingsIds
35+
searchParamsCopy.append("bookingsIds", ALL_SELECTED_KEY);
36+
fetchSearchParams = `?${searchParamsCopy.toString()}`;
37+
} else {
38+
// In this case only specific keys are selected so we dont need the filters, we just pass the ids of the selected bookings
39+
fetchSearchParams = `?bookingsIds=${bookingsIds.join(",")}`;
40+
}
41+
42+
/** Handle the download via fetcher and track state */
43+
const handleExport = async () => {
44+
setIsDownloading(true);
45+
try {
46+
const url = `/bookings/export/bookings-${new Date()
47+
.toISOString()
48+
.slice(0, 10)}.csv`;
49+
const response = await fetch(`${url}${fetchSearchParams}`);
50+
const blob = await response.blob();
51+
const downloadUrl = window.URL.createObjectURL(blob);
52+
const link = document.createElement("a");
53+
link.href = downloadUrl;
54+
link.setAttribute("download", url.split("/").pop() || "export.csv");
55+
document.body.appendChild(link);
56+
link.click();
57+
link.remove();
58+
} finally {
59+
setIsDownloading(false);
60+
}
61+
};
62+
63+
return (
64+
<Button
65+
onClick={handleExport}
66+
variant="secondary"
67+
className="font-medium"
68+
title={title}
69+
disabled={
70+
disabled
71+
? { reason: "You must select at least 1 booking to export" }
72+
: isDownloading
73+
}
74+
>
75+
<div className="flex items-center gap-1">
76+
{isDownloading ? (
77+
<span>
78+
<Spinner />
79+
</span>
80+
) : null}{" "}
81+
<span>{title}</span>
82+
</div>
83+
</Button>
84+
);
85+
}

app/modules/booking/service.server.ts

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@ import { bookingUpdatesTemplateString } from "~/emails/bookings-updates-template
1414
import { sendEmail } from "~/emails/mail.server";
1515
import { getStatusClasses, isOneDayEvent } from "~/utils/calendar";
1616
import { getDateTimeFormat } from "~/utils/client-hints";
17+
import { updateCookieWithPerPage } from "~/utils/cookies.server";
1718
import { calcTimeDifference } from "~/utils/date-fns";
1819
import { sendNotification } from "~/utils/emitter/send-notification.server";
1920
import type { ErrorLabel } from "~/utils/error";
2021
import { isLikeShelfError, isNotFoundError, ShelfError } from "~/utils/error";
2122
import { getRedirectUrlFromRequest } from "~/utils/http";
2223
import { getCurrentSearchParams } from "~/utils/http.server";
23-
import { ALL_SELECTED_KEY } from "~/utils/list";
24+
import { ALL_SELECTED_KEY, getParamsValues } from "~/utils/list";
2425
import { Logger } from "~/utils/logger";
2526
import { QueueNames, scheduler } from "~/utils/scheduler.server";
2627
import type { MergeInclude } from "~/utils/utils";
@@ -146,7 +147,7 @@ async function updateBookingKitStates({
146147
}
147148
}
148149

149-
const BOOKING_COMMON_INCLUDE = {
150+
export const BOOKING_COMMON_INCLUDE = {
150151
custodianTeamMember: true,
151152
custodianUser: true,
152153
} as Prisma.BookingInclude;
@@ -524,6 +525,75 @@ export async function upsertBooking(
524525
}
525526
}
526527

528+
export async function getBookingsFilterData({
529+
request,
530+
isSelfServiceOrBase,
531+
userId,
532+
organizationId,
533+
}: {
534+
request: Request;
535+
isSelfServiceOrBase: boolean;
536+
userId: string;
537+
organizationId: string;
538+
}) {
539+
const searchParams = getCurrentSearchParams(request);
540+
const { page, perPageParam, search, status, teamMemberIds } =
541+
getParamsValues(searchParams);
542+
const cookie = await updateCookieWithPerPage(request, perPageParam);
543+
const { perPage } = cookie;
544+
545+
const orderBy = searchParams.get("orderBy") ?? "from";
546+
const orderDirection = (searchParams.get("orderDirection") ??
547+
"asc") as SortingDirection;
548+
549+
/**
550+
* For self service and base users, we need to get the teamMember to be able to filter by it as well.
551+
* This is to handle a case when a booking was assigned when there wasn't a user attached to a team member but they were later on linked.
552+
* This is to ensure that the booking is still visible to the user that was assigned to it.
553+
* Also this shouldn't really happen as we now have a fix implemented when accepting invites,
554+
* to make sure it doesnt happen, hwoever its good to keep this as an extra safety thing.
555+
* Ideally in the future we should remove this as it adds another query to the db
556+
* @TODO this can safely be remove 3-6 months after this commit
557+
*/
558+
let selfServiceData = null;
559+
if (isSelfServiceOrBase) {
560+
const teamMember = await db.teamMember.findFirst({
561+
where: {
562+
userId,
563+
organizationId,
564+
},
565+
});
566+
if (!teamMember) {
567+
throw new ShelfError({
568+
cause: null,
569+
title: "Team member not found",
570+
message:
571+
"You are not part of a team in this organization. Please contact your organization admin to resolve this",
572+
label: "Booking",
573+
shouldBeCaptured: false,
574+
});
575+
}
576+
selfServiceData = {
577+
// If the user is self service, we only show bookings that belong to that user)
578+
custodianUserId: userId,
579+
custodianTeamMemberId: teamMember.id,
580+
};
581+
}
582+
583+
return {
584+
searchParams,
585+
cookie,
586+
page,
587+
perPage,
588+
search,
589+
status,
590+
teamMemberIds,
591+
orderBy,
592+
orderDirection,
593+
selfServiceData,
594+
};
595+
}
596+
527597
export async function getBookings(params: {
528598
organizationId: Organization["id"];
529599
/** Page number. Starts at 1 */
@@ -682,6 +752,7 @@ export async function getBookings(params: {
682752
...BOOKING_COMMON_INCLUDE,
683753
assets: {
684754
select: {
755+
title: true,
685756
id: true,
686757
custody: true,
687758
availableToBook: true,

app/modules/booking/utils.server.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { BookingStatus, Organization, Prisma } from "@prisma/client";
22
import type { HeaderData } from "~/components/layout/header/types";
3-
import { getClientHint, getDateTimeFormat } from "~/utils/client-hints";
3+
import { getClientHint } from "~/utils/client-hints";
44
import type { ResponsePayload } from "~/utils/http.server";
55
import { getCurrentSearchParams } from "~/utils/http.server";
66
import { getParamsValues } from "~/utils/list";
77
// eslint-disable-next-line import/no-cycle
8-
import { getBookings } from "./service.server";
8+
import { formatBookingsDates, getBookings } from "./service.server";
99

1010
export function getBookingWhereInput({
1111
organizationId,
@@ -111,33 +111,7 @@ export async function loadBookingsData({
111111
const hints = getClientHint(request);
112112

113113
// Format booking dates
114-
const items = bookings.map((b) => {
115-
if (b.from && b.to) {
116-
const from = new Date(b.from);
117-
const displayFrom = getDateTimeFormat(request, {
118-
dateStyle: "short",
119-
timeStyle: "short",
120-
}).format(from);
121-
122-
const to = new Date(b.to);
123-
const displayTo = getDateTimeFormat(request, {
124-
dateStyle: "short",
125-
timeStyle: "short",
126-
}).format(to);
127-
128-
return {
129-
...b,
130-
displayFrom: displayFrom.split(","),
131-
displayTo: displayTo.split(","),
132-
metadata: {
133-
...b,
134-
displayFrom: displayFrom.split(","),
135-
displayTo: displayTo.split(","),
136-
},
137-
};
138-
}
139-
return b;
140-
});
114+
const items = formatBookingsDates(bookings, request);
141115

142116
return {
143117
showModal: true,

app/routes/_layout+/assets.$assetId.bookings.tsx

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ import { json, type LoaderFunctionArgs } from "@remix-run/node";
33
import { z } from "zod";
44
import type { HeaderData } from "~/components/layout/header/types";
55
import { hasGetAllValue } from "~/hooks/use-model-filters";
6-
import { getBookings } from "~/modules/booking/service.server";
6+
import {
7+
formatBookingsDates,
8+
getBookings,
9+
} from "~/modules/booking/service.server";
710
import { setSelectedOrganizationIdCookie } from "~/modules/organization/context.server";
811
import { getTeamMemberForCustodianFilter } from "~/modules/team-member/service.server";
9-
import { getDateTimeFormat } from "~/utils/client-hints";
1012
import {
1113
setCookie,
1214
updateCookieWithPerPage,
@@ -97,28 +99,7 @@ export async function loader({ context, request, params }: LoaderFunctionArgs) {
9799
};
98100

99101
/** We format the dates on the server based on the users timezone and locale */
100-
const items = bookings.map((b) => {
101-
if (b.from && b.to) {
102-
const from = new Date(b.from);
103-
const displayFrom = getDateTimeFormat(request, {
104-
dateStyle: "short",
105-
timeStyle: "short",
106-
}).format(from);
107-
108-
const to = new Date(b.to);
109-
const displayTo = getDateTimeFormat(request, {
110-
dateStyle: "short",
111-
timeStyle: "short",
112-
}).format(to);
113-
114-
return {
115-
...b,
116-
displayFrom: displayFrom.split(","),
117-
displayTo: displayTo.split(","),
118-
};
119-
}
120-
return b;
121-
});
102+
const items = formatBookingsDates(bookings, request);
122103

123104
return json(
124105
data({
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { json, type LoaderFunctionArgs } from "@remix-run/node";
2+
import { z } from "zod";
3+
import { exportBookingsFromIndexToCsv } from "~/utils/csv.server";
4+
import { makeShelfError, ShelfError } from "~/utils/error";
5+
import { error, getCurrentSearchParams } from "~/utils/http.server";
6+
import {
7+
PermissionAction,
8+
PermissionEntity,
9+
} from "~/utils/permissions/permission.data";
10+
import { requirePermission } from "~/utils/roles.server";
11+
import { assertCanUseBookings } from "~/utils/subscription.server";
12+
13+
export const ExportBookingsSchema = z.object({
14+
bookingIds: z.array(z.string()).min(1),
15+
});
16+
17+
export const loader = async ({ context, request }: LoaderFunctionArgs) => {
18+
const authSession = context.getSession();
19+
const { userId } = authSession;
20+
21+
try {
22+
const { organizationId, currentOrganization, isSelfServiceOrBase } =
23+
await requirePermission({
24+
userId: authSession.userId,
25+
request,
26+
entity: PermissionEntity.booking,
27+
action: PermissionAction.export,
28+
});
29+
30+
assertCanUseBookings(currentOrganization);
31+
32+
const searchParams = getCurrentSearchParams(request);
33+
const bookingsIds = searchParams.get("bookingsIds");
34+
35+
if (!bookingsIds) {
36+
throw new ShelfError({
37+
cause: null,
38+
message: "No bookings selected",
39+
label: "Booking",
40+
});
41+
}
42+
43+
/** Join the rows with a new line */
44+
const csvString = await exportBookingsFromIndexToCsv({
45+
request,
46+
organizationId,
47+
bookingsIds: bookingsIds.split(","),
48+
userId,
49+
isSelfServiceOrBase,
50+
});
51+
52+
return new Response(csvString, {
53+
status: 200,
54+
headers: {
55+
"content-type": "text/csv",
56+
},
57+
});
58+
} catch (cause) {
59+
const reason = makeShelfError(cause, { userId });
60+
return json(error(reason), { status: reason.status });
61+
}
62+
};

0 commit comments

Comments
 (0)