Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/app/api/donorOffers/[donorOfferId]/csv/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { auth } from "@/auth";
import DonorOfferService from "@/services/donorOfferService";
import UserService from "@/services/userService";
import {
ArgumentError,
AuthenticationError,
errorResponse,
} from "@/util/errors";
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const paramSchema = z.object({
donorOfferId: z.string().transform((val) => {
const parsed = parseInt(val, 10);
if (Number.isNaN(parsed)) {
throw new Error("donorOfferId must be a valid number");
}
return parsed;
}),
});

export async function GET(
_: NextRequest,
{ params }: { params: Promise<{ donorOfferId: string }> }
) {
try {
const session = await auth();
if (!session?.user) {
throw new AuthenticationError("Session required");
}

UserService.checkPermission(session.user, "offerWrite");

const resolvedParams = await params;
const parsed = paramSchema.safeParse(resolvedParams);
if (!parsed.success) {
throw new ArgumentError(parsed.error.message);
}

const { csv, fileName } =
await DonorOfferService.getUnfinalizedDonorOfferCsv(
parsed.data.donorOfferId
);

return new NextResponse(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Cache-Control": "no-store",
},
});
} catch (error) {
return errorResponse(error);
}
}
72 changes: 69 additions & 3 deletions src/screens/DonorOffersScreens/AdminDonorOffersScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
PencilSimple,
Upload,
Archive,
FileCsv
} from "@phosphor-icons/react";
import { DonorOfferState } from "@prisma/client";
import React from "react";
Expand Down Expand Up @@ -90,6 +91,54 @@ export default function AdminDonorOffersScreen() {
})();
};

const handleConvertToCsv = useCallback(async (donorOfferId: number) => {
const loadingToastId = toast.loading("Generating CSV...");

try {
const response = await fetch(`/api/donorOffers/${donorOfferId}/csv`);

if (!response.ok) {
const contentType = response.headers.get("content-type") ?? "";
let errorMessage = "Failed to generate CSV";

if (contentType.includes("application/json")) {
const body = (await response.json()) as { error?: string; message?: string };
errorMessage = body.error ?? body.message ?? errorMessage;
} else {
const body = (await response.text()).trim();
if (body) {
errorMessage = body;
}
}

throw new Error(errorMessage);
}

const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
const contentDisposition = response.headers.get("content-disposition");
const fileName =
contentDisposition?.match(/filename="([^"]+)"/)?.[1] ??
`donor-offer-${donorOfferId}.csv`;

const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);

toast.dismiss(loadingToastId);
toast.success("CSV downloaded");
} catch (error) {
toast.dismiss(loadingToastId);
toast.error(
error instanceof Error ? error.message : "Failed to generate CSV"
);
}
}, []);

const columns: ColumnDefinition<AdminDonorOffer>[] = [
{
id: "offerName",
Expand Down Expand Up @@ -167,12 +216,17 @@ export default function AdminDonorOffersScreen() {
<MenuButton>
<DotsThree weight="bold" />
</MenuButton>
<MenuItems anchor="bottom end" className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max">
<MenuItems
anchor="bottom end"
className="z-10 rounded-md bg-white ring-1 shadow-lg ring-black/5 w-max"
>
{offer.state !== DonorOfferState.ARCHIVED && (
<MenuItem
as="button"
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => router.push(`/donorOffers/${offer.donorOfferId}/edit`)}
onClick={() =>
router.push(`/donorOffers/${offer.donorOfferId}/edit`)
}
>
<PencilSimple className="inline-block mr-2" size={22} />
Edit Offer Details
Expand All @@ -182,12 +236,24 @@ export default function AdminDonorOffersScreen() {
<MenuItem
as="button"
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => router.push(`/donorOffers/${offer.donorOfferId}/finalize`)}
onClick={() =>
router.push(`/donorOffers/${offer.donorOfferId}/finalize`)
}
>
<Upload className="inline-block mr-2" size={22} />
Upload Final Offer
</MenuItem>
)}
{offer.state === DonorOfferState.UNFINALIZED && (
<MenuItem
as="button"
className="flex w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => void handleConvertToCsv(offer.donorOfferId)}
>
<FileCsv className="inline-block mr-2" size={22} />
Convert to CSV
</MenuItem>
)}
{offer.state === DonorOfferState.FINALIZED && (
<MenuItem
as="button"
Expand Down
80 changes: 80 additions & 0 deletions src/services/donorOfferService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { buildQueryWithPagination, buildWhereFromFilters } from "@/util/table";
import { Partner } from "@/components/DonorOffers";
import { NotificationService } from "./notificationService";
import { MatchingService } from "./matchingService";
import Papa from "papaparse";

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

type FinalizeDonorOfferItem = z.infer<typeof FinalizeDonorOfferItemSchema>;
type GeneralItemWithRequests = GeneralItem & {
requests: (GeneralItemRequest & {
partner: { id: number; name: string };
})[];
};

export default class DonorOfferService {
private static normalizeExpirationDate(value: unknown): Date | null {
Expand Down Expand Up @@ -907,6 +913,80 @@ export default class DonorOfferService {
};
}

static async getUnfinalizedDonorOfferCsv(donorOfferId: number) {
const { donorOffer, partners, items } = await this.getAdminDonorOfferDetails(
donorOfferId,
true
);

if (donorOffer.state !== DonorOfferState.UNFINALIZED) {
throw new ArgumentError(
"Only unfinalized donor offers can be converted to CSV"
);
}

const itemsWithRequests = items as GeneralItemWithRequests[];
const partnerMap = new Map<number, { id: number; name: string }>();

for (const partner of partners) {
partnerMap.set(partner.id, { id: partner.id, name: partner.name });
}

for (const item of itemsWithRequests) {
for (const request of item.requests) {
partnerMap.set(request.partner.id, request.partner);
}
}

const orderedPartners = [...partnerMap.values()].sort((a, b) =>
a.name.localeCompare(b.name)
);

const rows = itemsWithRequests.map((item) => {
const requestTotal = item.requests.reduce(
(sum, request) => sum + (request.finalQuantity ?? request.quantity ?? 0),
0
Comment thread
greptile-apps[bot] marked this conversation as resolved.
);

const row: Record<string, string | number> = {
"Item Name": item.title,
Expiration: item.expirationDate
? format(new Date(item.expirationDate), "M/d/yyyy")
: "None",
"Unit Type": item.unitType,
Quantity: item.initialQuantity,
"Request Total": requestTotal,
};

for (const partner of orderedPartners) {
const request = item.requests.find((r) => r.partner.id === partner.id);
row[partner.name] = request?.finalQuantity ?? request?.quantity ?? 0;
}

return row;
});

const columns = [
"Item Name",
"Expiration",
"Unit Type",
"Quantity",
"Request Total",
...orderedPartners.map((partner) => partner.name),
];

const csv = `\uFEFF${Papa.unparse(rows, { columns })}`;
const safeOfferName = donorOffer.offerName
.replace(/[^a-z0-9]+/gi, "-")
.replace(/^-+|-+$/g, "")
.toLowerCase();

return {
csv,
fileName: `${safeOfferName || "donor-offer"}-requests.csv`,
};
}

static async createDonorOfferRequests(
requests: DonorOfferItemsRequestsDTO[],
partnerId: number
Expand Down
Loading