Skip to content

Commit e789014

Browse files
Add CSV and resume export
1 parent 07d9966 commit e789014

6 files changed

Lines changed: 238 additions & 3 deletions

File tree

package-lock.json

Lines changed: 96 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"date-fns": "^4.1.0",
3232
"firebase": "^12.4.0",
3333
"jotai": "^2.15.0",
34+
"jszip": "^3.10.1",
3435
"lucide-react": "^0.546.0",
3536
"motion": "^12.29.2",
3637
"react": "^19.1.1",

src/lib/export-csv.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { Team } from "@/types/team";
2+
import type { UserData } from "@/types/user";
3+
import { firestoreService } from "@/services/firestore.service";
4+
5+
function escapeCsvField(value: string): string {
6+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
7+
return `"${value.replace(/"/g, '""')}"`;
8+
}
9+
return value;
10+
}
11+
12+
function toCsvRow(fields: string[]): string {
13+
return fields.map(escapeCsvField).join(",");
14+
}
15+
16+
export async function exportTeamsCsv(teams: Team[]): Promise<void> {
17+
const allMemberIds = [...new Set(teams.flatMap((t) => t.memberIds))];
18+
const memberMap = new Map<string, UserData>();
19+
20+
const users = await Promise.all(
21+
allMemberIds.map((id) => firestoreService.fetchUser(id)),
22+
);
23+
for (const user of users) {
24+
if (user) memberMap.set(user.id, user);
25+
}
26+
27+
const header = toCsvRow([
28+
"Team Name",
29+
"Track",
30+
"Challenges",
31+
"Members",
32+
"Member Emails",
33+
"Mentoring Help",
34+
"Status",
35+
]);
36+
37+
const rows = teams.map((team) => {
38+
const members = team.memberIds
39+
.map((id) => memberMap.get(id))
40+
.filter((m): m is UserData => m !== undefined);
41+
42+
const memberNames = members
43+
.map((m) => {
44+
const name =
45+
m.role === "participant"
46+
? `${m.firstName} ${m.lastName}`
47+
: m.username;
48+
return m.id === team.creatorId ? `${name} (creator)` : name;
49+
})
50+
.join("; ");
51+
52+
const memberEmails = members
53+
.map((m) => ("email" in m && m.email ? m.email : m.username))
54+
.join("; ");
55+
56+
return toCsvRow([
57+
team.name,
58+
team.track,
59+
team.challenges.join("; "),
60+
memberNames,
61+
memberEmails,
62+
team.mentoringHelp,
63+
team.status,
64+
]);
65+
});
66+
67+
const csv = [header, ...rows].join("\n");
68+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
69+
const url = URL.createObjectURL(blob);
70+
71+
const link = document.createElement("a");
72+
link.href = url;
73+
link.download = `teams-${new Date().toISOString().slice(0, 10)}.csv`;
74+
link.click();
75+
76+
URL.revokeObjectURL(url);
77+
}

src/lib/export-resumes.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import JSZip from "jszip";
2+
import type { Participant } from "@/types/user";
3+
4+
export async function exportResumesZip(
5+
participants: Participant[],
6+
): Promise<void> {
7+
const withResumes = participants.filter(
8+
(p): p is Participant & { resumeURL: string } => !!p.resumeURL,
9+
);
10+
11+
if (withResumes.length === 0) {
12+
alert("No participants have uploaded resumes.");
13+
return;
14+
}
15+
16+
const zip = new JSZip();
17+
let added = 0;
18+
19+
await Promise.all(
20+
withResumes.map(async (p) => {
21+
try {
22+
const response = await fetch(p.resumeURL);
23+
if (!response.ok) return;
24+
25+
const blob = await response.blob();
26+
const filename = `${p.firstName}_${p.lastName}_${p.username}.pdf`;
27+
zip.file(filename, blob);
28+
added++;
29+
} catch (err) {
30+
console.warn(`Failed to fetch resume for ${p.username}`, err);
31+
}
32+
}),
33+
);
34+
35+
if (added === 0) {
36+
alert("Could not download any resumes. Check the console for errors.");
37+
return;
38+
}
39+
40+
const content = await zip.generateAsync({ type: "blob" });
41+
const url = URL.createObjectURL(content);
42+
43+
const link = document.createElement("a");
44+
link.href = url;
45+
link.download = `resumes-${new Date().toISOString().slice(0, 10)}.zip`;
46+
link.click();
47+
48+
URL.revokeObjectURL(url);
49+
}

src/pages/ParticipantManager.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { firestoreService } from "@/services/firestore.service";
1111
import type { Participant, UserData } from "@/types/user";
1212
import { useEffect, useState, useCallback } from "react";
1313
import { useNavigate } from "react-router-dom";
14+
import { exportResumesZip } from "@/lib/export-resumes";
1415

1516
export default function ParticipantManager() {
1617
const navigate = useNavigate();
@@ -71,6 +72,16 @@ export default function ParticipantManager() {
7172
<Button variant="outline" onClick={() => fetchUsers()}>
7273
<p>refresh table</p>
7374
</Button>
75+
<Button
76+
variant="outline"
77+
onClick={() =>
78+
exportResumesZip(
79+
users.filter((u): u is Participant => u.role === "participant"),
80+
)
81+
}
82+
>
83+
<p>download resumes in zip (may take a bit)</p>
84+
</Button>
7485
</header>
7586

7687
<main>

src/pages/TeamManager.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { TooltipTrigger } from "@radix-ui/react-tooltip";
2525
import { useEffect, useState, useCallback } from "react";
2626
import { useNavigate } from "react-router-dom";
2727
import { useAtomValue } from "jotai";
28+
import { exportTeamsCsv } from "@/lib/export-csv";
2829

2930
function TeamMembersList({ team }: { team: Team }) {
3031
const [members, setMembers] = useState<UserData[]>([]);
@@ -261,6 +262,9 @@ export default function TeamManager() {
261262
<Button variant="outline" onClick={() => fetchTeams()}>
262263
<p>refresh table</p>
263264
</Button>
265+
<Button variant="outline" onClick={() => exportTeamsCsv(teams)}>
266+
<p>export CSV</p>
267+
</Button>
264268
</header>
265269

266270
<main>

0 commit comments

Comments
 (0)