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
47 changes: 27 additions & 20 deletions src/app/api/staff/analytics/export/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";

import type { ExportRow } from "@/services/analytics.service";
import { getExportData } from "@/services/analytics.service";
import type { MonthlyExportData } from "@/services/analytics.service";
import { getMonthlyExportData } from "@/services/analytics.service";
import handleError from "@/utils/handle-error";
import {
AuthError,
Expand All @@ -11,33 +11,40 @@ import {

function escapeCsvField(value: string | number): string {
const str = String(value);
// Wrap in quotes if value contains comma, quote, or newline
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replaceAll('"', '""')}"`;
}
return str;
}

function rowsToCsv(rows: ExportRow[]): string {
function buildCsv({ volunteerTypes, rows }: MonthlyExportData): string {
const typeHeaders = volunteerTypes.map((t) => `Hours (${t})`);
const headers = [
"Name",
"Total Hours",
"Month",
"Verified Hours",
"Events Attended",
"Volunteer Type",
"Unique Volunteers",
"New Volunteers",
"Events Held",
"Attendance Rate",
...typeHeaders,
];

const lines = [
headers.join(","),
...rows.map((r) =>
[
escapeCsvField(r.name),
escapeCsvField(r.totalHours),
headers.map((h) => escapeCsvField(h)).join(","),
...rows.map((r) => {
const typeValues = volunteerTypes.map((t) =>
escapeCsvField(r.hoursByType[t] ?? 0),
);
return [
escapeCsvField(r.month),
escapeCsvField(r.verifiedHours),
escapeCsvField(r.eventsAttended),
escapeCsvField(r.volunteerType),
].join(","),
),
escapeCsvField(r.uniqueVolunteers),
escapeCsvField(r.newVolunteers),
escapeCsvField(r.eventsHeld),
escapeCsvField(r.attendanceRate),
...typeValues,
].join(",");
}),
];

return lines.join("\r\n");
Expand All @@ -51,14 +58,14 @@ export async function GET(request: NextRequest): Promise<Response> {
const startDate = searchParams.get("startDate") ?? undefined;
const endDate = searchParams.get("endDate") ?? undefined;

const rows = await getExportData(startDate, endDate);
const csv = rowsToCsv(rows);
const data = await getMonthlyExportData(startDate, endDate);
const csv = buildCsv(data);

return new Response(csv, {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="volunteers.csv"',
"Content-Disposition": 'attachment; filename="monthly-report.csv"',
},
});
} catch (error) {
Expand Down
75 changes: 75 additions & 0 deletions src/app/api/staff/volunteers/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";

import { getVolunteerExportData } from "@/services/volunteer-export.service";
import handleError from "@/utils/handle-error";
import {
AuthError,
authErrorResponse,
requireStaffAuth,
} from "@/utils/server/auth";

function escapeCsvField(value: string | number): string {
const str = String(value);
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replaceAll('"', '""')}"`;
}
return str;
}

export async function GET(): Promise<Response> {
try {
await requireStaffAuth();

const { docs, rows } = await getVolunteerExportData();

const paperworkHeaders = docs.map((d) =>
escapeCsvField(`Paperwork: ${d.title}`),
);
const headers = [
"First",
"Last",
"Email",
"Phone",
"Employer",
"Job Title",
...paperworkHeaders,
"City/State",
"Hours (Verified)",
"Events Attended",
"How Did They Hear About Us?",
];

const lines = [
headers.join(","),
...rows.map((r) => {
const paperworkValues = docs.map((d) =>
escapeCsvField(r.paperwork[d.id] ?? ""),
);
return [
escapeCsvField(r.firstName),
escapeCsvField(r.lastName),
escapeCsvField(r.email),
escapeCsvField(r.phone),
escapeCsvField(r.employer),
escapeCsvField(r.jobTitle),
...paperworkValues,
escapeCsvField(r.cityState),
escapeCsvField(r.verifiedHours),
escapeCsvField(r.eventsAttended),
escapeCsvField(r.referralSource),
].join(",");
}),
];

return new Response(lines.join("\r\n"), {
status: 200,
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": 'attachment; filename="volunteers.csv"',
},
});
} catch (error) {
if (error instanceof AuthError) return authErrorResponse(error);
return NextResponse.json({ error: handleError(error) }, { status: 500 });
}
}
2 changes: 1 addition & 1 deletion src/app/staff/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default function StaffAnalyticsPage(): JSX.Element {
const query = params.size > 0 ? `?${params.toString()}` : "";
const link = document.createElement("a");
link.href = `/api/staff/analytics/export${query}`;
link.download = "volunteers.csv";
link.download = "monthly-report.csv";
document.body.append(link);
link.click();
link.remove();
Expand Down
18 changes: 18 additions & 0 deletions src/components/staff/volunteer-management/volunteer-list.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import AddIcon from "@mui/icons-material/Add";
import DownloadIcon from "@mui/icons-material/Download";
import FilterListIcon from "@mui/icons-material/FilterList";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import {
Expand Down Expand Up @@ -304,6 +305,23 @@ export default function VolunteerList(): ReactElement {
<FilterListIcon />
</IconButton>
</Tooltip>
<Tooltip title="Export CSV">
<IconButton
color="primary"
onClick={() => {
globalThis.location.href = "/api/staff/volunteers/export";
}}
sx={{
backgroundColor: "primary.main",
color: "primary.contrastText",
"&:hover": {
backgroundColor: "primary.dark",
},
}}
>
<DownloadIcon />
</IconButton>
</Tooltip>
<Tooltip title="Import CSV">
<IconButton
color="primary"
Expand Down
Loading
Loading