Skip to content

Commit 2ea1648

Browse files
committed
able to turn unfinalized donor offers into csv files now and also implemented the strike through ui
1 parent 70426ca commit 2ea1648

3 files changed

Lines changed: 147 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: 23 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";
@@ -167,12 +168,17 @@ export default function AdminDonorOffersScreen() {
167168
<MenuButton>
168169
<DotsThree weight="bold" />
169170
</MenuButton>
170-
<MenuItems anchor="bottom end" className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max">
171+
<MenuItems
172+
anchor="bottom end"
173+
className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max"
174+
>
171175
{offer.state !== DonorOfferState.ARCHIVED && (
172176
<MenuItem
173177
as="button"
174178
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`)}
179+
onClick={() =>
180+
router.push(`/donorOffers/${offer.donorOfferId}/edit`)
181+
}
176182
>
177183
<PencilSimple className="inline-block mr-2" size={22} />
178184
Edit Offer Details
@@ -182,12 +188,26 @@ export default function AdminDonorOffersScreen() {
182188
<MenuItem
183189
as="button"
184190
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`)}
191+
onClick={() =>
192+
router.push(`/donorOffers/${offer.donorOfferId}/finalize`)
193+
}
186194
>
187195
<Upload className="inline-block mr-2" size={22} />
188196
Upload Final Offer
189197
</MenuItem>
190198
)}
199+
{offer.state === DonorOfferState.UNFINALIZED && (
200+
<MenuItem
201+
as="button"
202+
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
203+
onClick={() =>
204+
window.location.href = `/api/donorOffers/${offer.donorOfferId}/csv`
205+
}
206+
>
207+
<FileCsv className="inline-block mr-2" size={22} />
208+
Convert to CSV
209+
</MenuItem>
210+
)}
191211
{offer.state === DonorOfferState.FINALIZED && (
192212
<MenuItem
193213
as="button"

‎src/services/donorOfferService.ts‎

Lines changed: 68 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,68 @@ 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 orderedPartners = [...partners].sort((a, b) =>
929+
a.name.localeCompare(b.name)
930+
);
931+
const itemsWithRequests = items as GeneralItemWithRequests[];
932+
933+
const rows = itemsWithRequests.map((item) => {
934+
const requestTotal = item.requests.reduce(
935+
(sum, request) => sum + (request.finalQuantity ?? request.quantity ?? 0),
936+
0
937+
);
938+
939+
const row: Record<string, string | number> = {
940+
"Item Name": item.title,
941+
Expiration: item.expirationDate
942+
? format(new Date(item.expirationDate), "M/d/yyyy")
943+
: "None",
944+
"Unit Type": item.unitType,
945+
Quantity: item.initialQuantity,
946+
"Request Total": requestTotal,
947+
};
948+
949+
for (const partner of orderedPartners) {
950+
const request = item.requests.find((r) => r.partner.id === partner.id);
951+
row[partner.name] = request?.finalQuantity ?? 0;
952+
}
953+
954+
return row;
955+
});
956+
957+
const columns = [
958+
"Item Name",
959+
"Expiration",
960+
"Unit Type",
961+
"Quantity",
962+
"Request Total",
963+
...orderedPartners.map((partner) => partner.name),
964+
];
965+
966+
const csv = `\uFEFF${Papa.unparse(rows, { columns })}`;
967+
const safeOfferName = donorOffer.offerName
968+
.replace(/[^a-z0-9]+/gi, "-")
969+
.replace(/^-+|-+$/g, "")
970+
.toLowerCase();
971+
972+
return {
973+
csv,
974+
fileName: `${safeOfferName || "donor-offer"}-requests.csv`,
975+
};
976+
}
977+
910978
static async createDonorOfferRequests(
911979
requests: DonorOfferItemsRequestsDTO[],
912980
partnerId: number

0 commit comments

Comments
 (0)