diff --git a/src/app/api/staff/analytics/export/route.ts b/src/app/api/staff/analytics/export/route.ts index 5e593e7..41bfa42 100644 --- a/src/app/api/staff/analytics/export/route.ts +++ b/src/app/api/staff/analytics/export/route.ts @@ -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, @@ -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"); @@ -51,14 +58,14 @@ export async function GET(request: NextRequest): Promise { 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) { diff --git a/src/app/api/staff/volunteers/export/route.ts b/src/app/api/staff/volunteers/export/route.ts new file mode 100644 index 0000000..641f8fb --- /dev/null +++ b/src/app/api/staff/volunteers/export/route.ts @@ -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 { + 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 }); + } +} diff --git a/src/app/staff/analytics/page.tsx b/src/app/staff/analytics/page.tsx index 3fa271b..0adce23 100644 --- a/src/app/staff/analytics/page.tsx +++ b/src/app/staff/analytics/page.tsx @@ -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(); diff --git a/src/components/staff/volunteer-management/volunteer-list.tsx b/src/components/staff/volunteer-management/volunteer-list.tsx index 404d01b..e53b4b6 100644 --- a/src/components/staff/volunteer-management/volunteer-list.tsx +++ b/src/components/staff/volunteer-management/volunteer-list.tsx @@ -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 { @@ -304,6 +305,23 @@ export default function VolunteerList(): ReactElement { + + { + globalThis.location.href = "/api/staff/volunteers/export"; + }} + sx={{ + backgroundColor: "primary.main", + color: "primary.contrastText", + "&:hover": { + backgroundColor: "primary.dark", + }, + }} + > + + + ; +}; + +export type MonthlyExportData = { + volunteerTypes: string[]; + rows: MonthlyExportRow[]; }; export async function getAnalyticsStats( @@ -158,35 +165,171 @@ export async function getAnalyticsStats( }; } -export async function getExportData( +export async function getMonthlyExportData( startDate?: string, endDate?: string, -): Promise { +): Promise { const hoursDateFilter = and( startDate ? gte(volunteerHours.date, new Date(startDate)) : undefined, endDate ? lte(volunteerHours.date, new Date(endDate)) : undefined, ); - const rows = await db - .select({ - name: sql`${users.firstName} || ' ' || ${users.lastName}`, - totalHours: sql`coalesce(sum(${volunteerHours.hours}), 0)`, - verifiedHours: sql`coalesce(sum(${volunteerHours.hours}) filter (where ${volunteerHours.status} = 'approved'), 0)`, - eventsAttended: sql`count(distinct ${volunteerHours.opportunityId})`, - volunteerType: sql`coalesce(max(${volunteers.volunteerType}), 'Unspecified')`, - }) - .from(volunteerHours) - .innerJoin(volunteers, eq(volunteerHours.volunteerId, volunteers.id)) - .innerJoin(users, eq(volunteers.userId, users.id)) - .where(hoursDateFilter) - .groupBy(volunteerHours.volunteerId, users.firstName, users.lastName) - .orderBy(users.lastName, users.firstName); - - return rows.map((r) => ({ - name: r.name, - totalHours: toNumber(r.totalHours), - verifiedHours: toNumber(r.verifiedHours), - eventsAttended: toNumber(r.eventsAttended), - volunteerType: r.volunteerType, - })); + const oppDateFilter = and( + startDate ? gte(opportunities.startDate, new Date(startDate)) : undefined, + endDate ? lte(opportunities.startDate, new Date(endDate)) : undefined, + ); + + const volunteerCreatedFilter = and( + startDate ? gte(volunteers.createdAt, new Date(startDate)) : undefined, + endDate ? lte(volunteers.createdAt, new Date(endDate)) : undefined, + ); + + const [ + hoursRows, + rsvpRows, + eventsRows, + newVolRows, + hoursByTypeRows, + typeRows, + ] = await Promise.all([ + // Hours metrics grouped by the month hours were logged + db + .select({ + month: sql`to_char(${volunteerHours.date}, 'YYYY-MM')`, + verifiedHours: sql`coalesce(sum(${volunteerHours.hours}) filter (where ${volunteerHours.status} = 'approved'), 0)`, + uniqueVolunteers: sql`count(distinct ${volunteerHours.volunteerId})`, + }) + .from(volunteerHours) + .where(hoursDateFilter) + .groupBy(sql`to_char(${volunteerHours.date}, 'YYYY-MM')`) + .orderBy(sql`to_char(${volunteerHours.date}, 'YYYY-MM')`), + + // RSVP metrics grouped by the month the event occurred + db + .select({ + month: sql`to_char(${opportunities.startDate}, 'YYYY-MM')`, + tracked: sql`count(*) filter (where ${volunteerRsvps.status} in ('attended', 'no_show'))`, + attended: sql`count(*) filter (where ${volunteerRsvps.status} = 'attended')`, + }) + .from(volunteerRsvps) + .innerJoin( + opportunities, + eq(volunteerRsvps.opportunityId, opportunities.id), + ) + .where(oppDateFilter) + .groupBy(sql`to_char(${opportunities.startDate}, 'YYYY-MM')`), + + // Events held + total capacity by month + db + .select({ + month: sql`to_char(${opportunities.startDate}, 'YYYY-MM')`, + eventsHeld: sql`count(*)`, + }) + .from(opportunities) + .where(oppDateFilter) + .groupBy(sql`to_char(${opportunities.startDate}, 'YYYY-MM')`), + + // New volunteer registrations by month + db + .select({ + month: sql`to_char(${volunteers.createdAt}, 'YYYY-MM')`, + newVolunteers: sql`count(*)`, + }) + .from(volunteers) + .where(volunteerCreatedFilter) + .groupBy(sql`to_char(${volunteers.createdAt}, 'YYYY-MM')`), + + // Hours by volunteer type by month + db + .select({ + month: sql`to_char(${volunteerHours.date}, 'YYYY-MM')`, + volunteerType: sql`coalesce(${volunteers.volunteerType}, 'Unspecified')`, + hours: sql`coalesce(sum(${volunteerHours.hours}), 0)`, + }) + .from(volunteerHours) + .innerJoin(volunteers, eq(volunteerHours.volunteerId, volunteers.id)) + .where(hoursDateFilter) + .groupBy( + sql`to_char(${volunteerHours.date}, 'YYYY-MM')`, + sql`coalesce(${volunteers.volunteerType}, 'Unspecified')`, + ), + + // All distinct volunteer types for consistent column headers + db + .selectDistinct({ volunteerType: volunteers.volunteerType }) + .from(volunteers) + .where(isNotNull(volunteers.volunteerType)) + .orderBy(volunteers.volunteerType), + ]); + + const volunteerTypes = typeRows.map((r) => r.volunteerType as string); + + // Build lookup maps by month + const rsvpByMonth = new Map( + rsvpRows.map((r) => [ + r.month, + { tracked: toNumber(r.tracked), attended: toNumber(r.attended) }, + ]), + ); + + const eventsByMonth = new Map( + eventsRows.map((r) => [r.month, toNumber(r.eventsHeld)]), + ); + + const newVolByMonth = new Map( + newVolRows.map((r) => [r.month, toNumber(r.newVolunteers)]), + ); + + // Build nested map: month → volunteerType → hours + const typeHoursByMonth = new Map>(); + for (const r of hoursByTypeRows) { + if (!typeHoursByMonth.has(r.month)) { + typeHoursByMonth.set(r.month, new Map()); + } + typeHoursByMonth.get(r.month)!.set(r.volunteerType, toNumber(r.hours)); + } + + const currentMonth = new Date().toISOString().slice(0, 7); + + // Collect all months across all queries, sorted, capped at current month + const allMonths = [ + ...new Set([ + ...hoursRows.map((r) => r.month), + ...rsvpRows.map((r) => r.month), + ...eventsRows.map((r) => r.month), + ...newVolRows.map((r) => r.month), + ]), + ] + .sort() + .filter((m) => m <= currentMonth); + + const hoursMap = new Map(hoursRows.map((r) => [r.month, r])); + + const rows: MonthlyExportRow[] = allMonths.map((month) => { + const h = hoursMap.get(month); + const rsvp = rsvpByMonth.get(month); + const typeHours = typeHoursByMonth.get(month) ?? new Map(); + + const tracked = rsvp?.tracked ?? 0; + const attended = rsvp?.attended ?? 0; + const attendanceRate = + tracked > 0 ? `${((attended / tracked) * 100).toFixed(1)}%` : "N/A"; + + const hoursByType: Record = {}; + for (const type of volunteerTypes) { + hoursByType[type] = typeHours.get(type) ?? 0; + } + + return { + month, + verifiedHours: toNumber(h?.verifiedHours), + uniqueVolunteers: toNumber(h?.uniqueVolunteers), + newVolunteers: newVolByMonth.get(month) ?? 0, + eventsHeld: eventsByMonth.get(month) ?? 0, + attendanceRate, + hoursByType, + }; + }); + + return { volunteerTypes, rows }; } diff --git a/src/services/volunteer-export.service.ts b/src/services/volunteer-export.service.ts new file mode 100644 index 0000000..200e873 --- /dev/null +++ b/src/services/volunteer-export.service.ts @@ -0,0 +1,166 @@ +import { and, eq, sql } from "drizzle-orm"; + +import db from "@/db"; +import { + onboardingDocuments, + users, + volunteerDocumentSignatures, + volunteerHours, + volunteers, +} from "@/db/schema"; +import { toNumber } from "@/services/shared/db-helpers"; + +type ActiveDoc = { + id: number; + title: string; + actionType: string; +}; + +type VolunteerExportRow = { + firstName: string; + lastName: string; + email: string; + phone: string; + employer: string; + jobTitle: string; + paperwork: Record; + cityState: string; + verifiedHours: number; + eventsAttended: number; + referralSource: string; +}; + +export type VolunteerExportData = { + docs: ActiveDoc[]; + rows: VolunteerExportRow[]; +}; + +function formatSignature( + signedAt: Date, + actionType: string, + consentGiven: boolean | null, +): string { + const date = signedAt.toISOString().split("T")[0]; + switch (actionType) { + case "consent": { + return `${date} (${consentGiven ? "consented" : "declined"})`; + } + case "sign": { + return `${date} (signed)`; + } + case "acknowledge": { + return `${date} (acknowledged)`; + } + case "informational": { + return `${date} (viewed)`; + } + default: { + return `${date} (completed)`; + } + } +} + +export async function getVolunteerExportData(): Promise { + const [docs, volunteerRows, signatureRows] = await Promise.all([ + // All active onboarding documents (for dynamic column headers) + db + .select({ + id: onboardingDocuments.id, + title: onboardingDocuments.title, + actionType: onboardingDocuments.actionType, + }) + .from(onboardingDocuments) + .where(eq(onboardingDocuments.isActive, true)) + .orderBy(onboardingDocuments.sortOrder, onboardingDocuments.id), + + // All active, email-verified volunteers with aggregated hours/events + db + .select({ + volunteerId: volunteers.id, + firstName: users.firstName, + lastName: users.lastName, + email: users.email, + phone: users.phone, + employer: volunteers.employer, + jobTitle: volunteers.jobTitle, + city: volunteers.city, + state: volunteers.state, + referralSource: volunteers.referralSource, + verifiedHours: sql`coalesce(sum(${volunteerHours.hours}) filter (where ${volunteerHours.status} = 'approved'), 0)`, + eventsAttended: sql`count(distinct ${volunteerHours.opportunityId})`, + }) + .from(volunteers) + .innerJoin(users, eq(volunteers.userId, users.id)) + .leftJoin(volunteerHours, eq(volunteerHours.volunteerId, volunteers.id)) + .where(and(eq(users.isActive, true), eq(users.isEmailVerified, true))) + .groupBy( + volunteers.id, + users.firstName, + users.lastName, + users.email, + users.phone, + volunteers.employer, + volunteers.jobTitle, + volunteers.city, + volunteers.state, + volunteers.referralSource, + ) + .orderBy(users.lastName, users.firstName), + + // All signatures for active documents (batch fetch — no N+1) + db + .select({ + volunteerId: volunteerDocumentSignatures.volunteerId, + documentId: volunteerDocumentSignatures.documentId, + signedAt: volunteerDocumentSignatures.signedAt, + consentGiven: volunteerDocumentSignatures.consentGiven, + actionType: onboardingDocuments.actionType, + }) + .from(volunteerDocumentSignatures) + .innerJoin( + onboardingDocuments, + eq(volunteerDocumentSignatures.documentId, onboardingDocuments.id), + ) + .where(eq(onboardingDocuments.isActive, true)), + ]); + + // Build lookup: volunteerId → docId → formatted value + const sigMap = new Map>(); + for (const sig of signatureRows) { + if (!sigMap.has(sig.volunteerId)) { + sigMap.set(sig.volunteerId, new Map()); + } + sigMap + .get(sig.volunteerId)! + .set( + sig.documentId, + formatSignature(sig.signedAt, sig.actionType, sig.consentGiven ?? null), + ); + } + + const rows: VolunteerExportRow[] = volunteerRows.map((v) => { + const volSigs = sigMap.get(v.volunteerId) ?? new Map(); + const paperwork: Record = {}; + for (const doc of docs) { + paperwork[doc.id] = volSigs.get(doc.id) ?? ""; + } + + const cityParts = [v.city, v.state].filter(Boolean); + + return { + firstName: v.firstName, + lastName: v.lastName, + email: v.email, + phone: v.phone ?? "", + employer: v.employer ?? "", + jobTitle: v.jobTitle ?? "", + paperwork, + cityState: cityParts.join(", "), + verifiedHours: toNumber(v.verifiedHours), + eventsAttended: toNumber(v.eventsAttended), + referralSource: v.referralSource ?? "", + }; + }); + + return { docs, rows }; +}