Skip to content

Commit de70c31

Browse files
authored
Merge pull request #316 from GTBitsOfGood/tiffany/308-parse-unfinalized-donor-offer-clean
Tiffany/308 parse unfinalized donor offer
2 parents 8622e7e + cb25f08 commit de70c31

3 files changed

Lines changed: 205 additions & 3 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { auth } from "@/auth";
2+
import DonorOfferService from "@/services/donorOfferService";
3+
import UserService from "@/services/userService";
4+
import {
5+
ArgumentError,
6+
AuthenticationError,
7+
errorResponse,
8+
} from "@/util/errors";
9+
import { NextRequest, NextResponse } from "next/server";
10+
import { z } from "zod";
11+
12+
const paramSchema = z.object({
13+
donorOfferId: z.string().transform((val) => {
14+
const parsed = parseInt(val, 10);
15+
if (Number.isNaN(parsed)) {
16+
throw new Error("donorOfferId must be a valid number");
17+
}
18+
return parsed;
19+
}),
20+
});
21+
22+
export async function GET(
23+
_: NextRequest,
24+
{ params }: { params: Promise<{ donorOfferId: string }> }
25+
) {
26+
try {
27+
const session = await auth();
28+
if (!session?.user) {
29+
throw new AuthenticationError("Session required");
30+
}
31+
32+
UserService.checkPermission(session.user, "offerWrite");
33+
34+
const resolvedParams = await params;
35+
const parsed = paramSchema.safeParse(resolvedParams);
36+
if (!parsed.success) {
37+
throw new ArgumentError(parsed.error.message);
38+
}
39+
40+
const { csv, fileName } =
41+
await DonorOfferService.getUnfinalizedDonorOfferCsv(
42+
parsed.data.donorOfferId
43+
);
44+
45+
return new NextResponse(csv, {
46+
status: 200,
47+
headers: {
48+
"Content-Type": "text/csv; charset=utf-8",
49+
"Content-Disposition": `attachment; filename="${fileName}"`,
50+
"Cache-Control": "no-store",
51+
},
52+
});
53+
} catch (error) {
54+
return errorResponse(error);
55+
}
56+
}

‎src/screens/DonorOffersScreens/AdminDonorOffersScreen.tsx‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
PencilSimple,
88
Upload,
99
Archive,
10+
FileCsv
1011
} from "@phosphor-icons/react";
1112
import { DonorOfferState } from "@prisma/client";
1213
import React from "react";
@@ -90,6 +91,54 @@ export default function AdminDonorOffersScreen() {
9091
})();
9192
};
9293

94+
const handleConvertToCsv = useCallback(async (donorOfferId: number) => {
95+
const loadingToastId = toast.loading("Generating CSV...");
96+
97+
try {
98+
const response = await fetch(`/api/donorOffers/${donorOfferId}/csv`);
99+
100+
if (!response.ok) {
101+
const contentType = response.headers.get("content-type") ?? "";
102+
let errorMessage = "Failed to generate CSV";
103+
104+
if (contentType.includes("application/json")) {
105+
const body = (await response.json()) as { error?: string; message?: string };
106+
errorMessage = body.error ?? body.message ?? errorMessage;
107+
} else {
108+
const body = (await response.text()).trim();
109+
if (body) {
110+
errorMessage = body;
111+
}
112+
}
113+
114+
throw new Error(errorMessage);
115+
}
116+
117+
const blob = await response.blob();
118+
const objectUrl = URL.createObjectURL(blob);
119+
const contentDisposition = response.headers.get("content-disposition");
120+
const fileName =
121+
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ??
122+
`donor-offer-${donorOfferId}.csv`;
123+
124+
const link = document.createElement("a");
125+
link.href = objectUrl;
126+
link.download = fileName;
127+
document.body.appendChild(link);
128+
link.click();
129+
link.remove();
130+
URL.revokeObjectURL(objectUrl);
131+
132+
toast.dismiss(loadingToastId);
133+
toast.success("CSV downloaded");
134+
} catch (error) {
135+
toast.dismiss(loadingToastId);
136+
toast.error(
137+
error instanceof Error ? error.message : "Failed to generate CSV"
138+
);
139+
}
140+
}, []);
141+
93142
const columns: ColumnDefinition<AdminDonorOffer>[] = [
94143
{
95144
id: "offerName",
@@ -167,12 +216,17 @@ export default function AdminDonorOffersScreen() {
167216
<MenuButton>
168217
<DotsThree weight="bold" />
169218
</MenuButton>
170-
<MenuItems anchor="bottom end" className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max">
219+
<MenuItems
220+
anchor="bottom end"
221+
className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max"
222+
>
171223
{offer.state !== DonorOfferState.ARCHIVED && (
172224
<MenuItem
173225
as="button"
174226
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
175-
onClick={() => router.push(`/donorOffers/${offer.donorOfferId}/edit`)}
227+
onClick={() =>
228+
router.push(`/donorOffers/${offer.donorOfferId}/edit`)
229+
}
176230
>
177231
<PencilSimple className="inline-block mr-2" size={22} />
178232
Edit Offer Details
@@ -182,12 +236,24 @@ export default function AdminDonorOffersScreen() {
182236
<MenuItem
183237
as="button"
184238
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
185-
onClick={() => router.push(`/donorOffers/${offer.donorOfferId}/finalize`)}
239+
onClick={() =>
240+
router.push(`/donorOffers/${offer.donorOfferId}/finalize`)
241+
}
186242
>
187243
<Upload className="inline-block mr-2" size={22} />
188244
Upload Final Offer
189245
</MenuItem>
190246
)}
247+
{offer.state === DonorOfferState.UNFINALIZED && (
248+
<MenuItem
249+
as="button"
250+
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
251+
onClick={() => void handleConvertToCsv(offer.donorOfferId)}
252+
>
253+
<FileCsv className="inline-block mr-2" size={22} />
254+
Convert to CSV
255+
</MenuItem>
256+
)}
191257
{offer.state === DonorOfferState.FINALIZED && (
192258
<MenuItem
193259
as="button"

‎src/services/donorOfferService.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table";
3434
import { Partner } from "@/components/DonorOffers";
3535
import { NotificationService } from "./notificationService";
3636
import { MatchingService } from "./matchingService";
37+
import Papa from "papaparse";
3738

3839
const DonorOfferItemSchema = z.object({
3940
title: z.string().trim().min(1, "Title is required"),
@@ -169,6 +170,11 @@ const FinalizeDonorOfferItemSchema = z.object({
169170
});
170171

171172
type FinalizeDonorOfferItem = z.infer<typeof FinalizeDonorOfferItemSchema>;
173+
type GeneralItemWithRequests = GeneralItem & {
174+
requests: (GeneralItemRequest & {
175+
partner: { id: number; name: string };
176+
})[];
177+
};
172178

173179
export default class DonorOfferService {
174180
private static normalizeExpirationDate(value: unknown): Date | null {
@@ -907,6 +913,80 @@ export default class DonorOfferService {
907913
};
908914
}
909915

916+
static async getUnfinalizedDonorOfferCsv(donorOfferId: number) {
917+
const { donorOffer, partners, items } = await this.getAdminDonorOfferDetails(
918+
donorOfferId,
919+
true
920+
);
921+
922+
if (donorOffer.state !== DonorOfferState.UNFINALIZED) {
923+
throw new ArgumentError(
924+
"Only unfinalized donor offers can be converted to CSV"
925+
);
926+
}
927+
928+
const itemsWithRequests = items as GeneralItemWithRequests[];
929+
const partnerMap = new Map<number, { id: number; name: string }>();
930+
931+
for (const partner of partners) {
932+
partnerMap.set(partner.id, { id: partner.id, name: partner.name });
933+
}
934+
935+
for (const item of itemsWithRequests) {
936+
for (const request of item.requests) {
937+
partnerMap.set(request.partner.id, request.partner);
938+
}
939+
}
940+
941+
const orderedPartners = [...partnerMap.values()].sort((a, b) =>
942+
a.name.localeCompare(b.name)
943+
);
944+
945+
const rows = itemsWithRequests.map((item) => {
946+
const requestTotal = item.requests.reduce(
947+
(sum, request) => sum + (request.finalQuantity ?? request.quantity ?? 0),
948+
0
949+
);
950+
951+
const row: Record<string, string | number> = {
952+
"Item Name": item.title,
953+
Expiration: item.expirationDate
954+
? format(new Date(item.expirationDate), "M/d/yyyy")
955+
: "None",
956+
"Unit Type": item.unitType,
957+
Quantity: item.initialQuantity,
958+
"Request Total": requestTotal,
959+
};
960+
961+
for (const partner of orderedPartners) {
962+
const request = item.requests.find((r) => r.partner.id === partner.id);
963+
row[partner.name] = request?.finalQuantity ?? request?.quantity ?? 0;
964+
}
965+
966+
return row;
967+
});
968+
969+
const columns = [
970+
"Item Name",
971+
"Expiration",
972+
"Unit Type",
973+
"Quantity",
974+
"Request Total",
975+
...orderedPartners.map((partner) => partner.name),
976+
];
977+
978+
const csv = `\uFEFF${Papa.unparse(rows, { columns })}`;
979+
const safeOfferName = donorOffer.offerName
980+
.replace(/[^a-z0-9]+/gi, "-")
981+
.replace(/^-+|-+$/g, "")
982+
.toLowerCase();
983+
984+
return {
985+
csv,
986+
fileName: `${safeOfferName || "donor-offer"}-requests.csv`,
987+
};
988+
}
989+
910990
static async createDonorOfferRequests(
911991
requests: DonorOfferItemsRequestsDTO[],
912992
partnerId: number

0 commit comments

Comments
 (0)