Skip to content

Commit 86e3f76

Browse files
PLlamas28ManuP6789
andauthored
added search bar, filter, copy to clipboard, save to csv, redirect to… (#79)
* added search bar, filter, copy to clipboard, save to csv, redirect to emailing * fix stuff * fix typeerror --------- Co-authored-by: ManuP6789 <manuel.pena654576@tufts.edu>
1 parent 13d4412 commit 86e3f76

3 files changed

Lines changed: 285 additions & 16 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"@clerk/nextjs": "^6.36.1",
1616
"@headlessui/react": "^2.2.9",
1717
"@prisma/client": "^6.19.0",
18-
"@react-email/components": "^1.0.6",
18+
"@react-email/components": "^1.0.8",
1919
"@react-email/render": "^2.0.4",
2020
"@react-email/tailwind": "^2.0.3",
2121
"@svgr/webpack": "^8.1.0",

src/app/admin/manage/page.tsx

Lines changed: 283 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"use client";
2-
import { useState, useEffect, useMemo, useCallback } from "react";
2+
import { useState, useEffect, useMemo, useCallback, useRef } from "react";
33
import { useRouter } from "next/navigation";
44
import useSWR from "swr";
55
import Button from "@/components/common/buttons/Button";
@@ -18,6 +18,18 @@ interface FrontEndUser {
1818
phoneNumber: string;
1919
role: string;
2020
selected: boolean;
21+
createdAt?: string;
22+
}
23+
24+
interface ApiUser {
25+
id?: string;
26+
userId?: string;
27+
firstName: string;
28+
lastName: string;
29+
emailAddress: string;
30+
phoneNumber: string;
31+
role: string;
32+
createdAt?: string;
2133
}
2234

2335
const fetcher = async (url: string) => {
@@ -32,6 +44,22 @@ const ManageRolesPage = () => {
3244
const { user } = useUser();
3345
const currentUserId = user?.id;
3446
const [volunteers, setVolunteers] = useState<FrontEndUser[]>([]);
47+
48+
// search/dropdown helpers copied from admin/email/page.tsx
49+
const [searchQuery, setSearchQuery] = useState("");
50+
const [dropdownOpen, setDropdownOpen] = useState(false);
51+
const searchInputRef = useRef<HTMLInputElement>(null);
52+
const containerRef = useRef<HTMLDivElement>(null);
53+
54+
const [sortOption, setSortOption] = useState<
55+
| "NAME_AZ"
56+
| "NAME_ZA"
57+
| "ADMIN"
58+
| "VOLUNTEER"
59+
| "DATE_NEWEST"
60+
| "DATE_OLDEST"
61+
>("NAME_AZ");
62+
3563
const [modalTitle, setModalTitle] = useState<string | null>(null);
3664
const [modalMessage, setModalMessage] = useState<string | null>(null);
3765
const [showEditConfirm, setShowEditConfirm] = useState(false);
@@ -42,20 +70,27 @@ const ManageRolesPage = () => {
4270
const router = useRouter();
4371

4472
// Fetch signups for the position (volunteers)
45-
const { data: allVols, isLoading: isLoadingVols } = useSWR<FrontEndUser[]>(`/api/users`, fetcher);
73+
const { data: allVols, isLoading: isLoadingVols } = useSWR<ApiUser[]>(
74+
`/api/users`,
75+
fetcher
76+
);
4677

4778
const frontEndUsers = useMemo(() => {
4879
if (!allVols) return [];
4980
return allVols
50-
.map((v: any) => ({
51-
userId: v.id || v.userId,
52-
firstName: v.firstName,
53-
lastName: v.lastName,
54-
emailAddress: v.emailAddress,
55-
phoneNumber: v.phoneNumber,
56-
role: v.role,
57-
selected: false,
58-
}))
81+
.filter((v: ApiUser) => v.id || v.userId) // Filter out invalid users first
82+
.map(
83+
(v: ApiUser): FrontEndUser => ({
84+
userId: (v.id || v.userId)!,
85+
firstName: v.firstName,
86+
lastName: v.lastName,
87+
emailAddress: v.emailAddress,
88+
phoneNumber: v.phoneNumber,
89+
role: v.role,
90+
selected: false,
91+
createdAt: v.createdAt,
92+
})
93+
)
5994
.sort((a, b) => {
6095
const firstNameCompare = a.firstName
6196
.toLowerCase()
@@ -91,15 +126,132 @@ const ManageRolesPage = () => {
91126

92127
const selectedCount = volunteers.filter((v) => v.selected).length;
93128

129+
const seenVolunteers = volunteers.filter((v) => {
130+
const full = `${v.lastName} ${v.firstName} ${v.emailAddress}`.toLowerCase();
131+
return full.includes(searchQuery.toLowerCase());
132+
});
133+
134+
const sortedVolunteers = useMemo(() => {
135+
let list = [...volunteers];
136+
137+
// Apply role filters
138+
if (sortOption === "ADMIN") {
139+
list = list.filter((v) => v.role === "ADMIN");
140+
} else if (sortOption === "VOLUNTEER") {
141+
list = list.filter((v) => v.role === "VOLUNTEER");
142+
}
143+
144+
// Apply sorting
145+
if (sortOption === "NAME_AZ") {
146+
list.sort((a, b) => {
147+
const aName = `${a.lastName} ${a.firstName}`.toLowerCase();
148+
const bName = `${b.lastName} ${b.firstName}`.toLowerCase();
149+
return aName.localeCompare(bName);
150+
});
151+
} else if (sortOption === "NAME_ZA") {
152+
list.sort((a, b) => {
153+
const aName = `${a.lastName} ${a.firstName}`.toLowerCase();
154+
const bName = `${b.lastName} ${b.firstName}`.toLowerCase();
155+
return bName.localeCompare(aName);
156+
});
157+
} else if (sortOption === "DATE_NEWEST") {
158+
list.sort((a, b) => {
159+
const aDate = new Date(a.createdAt || 0).getTime();
160+
const bDate = new Date(b.createdAt || 0).getTime();
161+
return bDate - aDate;
162+
});
163+
} else if (sortOption === "DATE_OLDEST") {
164+
list.sort((a, b) => {
165+
const aDate = new Date(a.createdAt || 0).getTime();
166+
const bDate = new Date(b.createdAt || 0).getTime();
167+
return aDate - bDate;
168+
});
169+
}
170+
171+
return list;
172+
}, [volunteers, sortOption]);
173+
174+
function addVolunteer(id: string) {
175+
toggleSelect(id);
176+
setSearchQuery("");
177+
setDropdownOpen(false);
178+
}
179+
180+
useEffect(() => {
181+
function handleClickOutside(e: MouseEvent) {
182+
if (
183+
containerRef.current &&
184+
!containerRef.current.contains(e.target as Node)
185+
) {
186+
setDropdownOpen(false);
187+
setSearchQuery("");
188+
}
189+
}
190+
191+
document.addEventListener("mousedown", handleClickOutside);
192+
return () => document.removeEventListener("mousedown", handleClickOutside);
193+
}, []);
194+
195+
useEffect(() => {
196+
if (dropdownOpen) {
197+
setTimeout(() => searchInputRef.current?.focus(), 50);
198+
}
199+
}, [dropdownOpen]);
200+
const copyEmailString = volunteers
201+
.filter((v) => v.selected)
202+
.map((v) => v.emailAddress)
203+
.join("\r\n");
204+
205+
const handleCopy = async () => {
206+
try {
207+
await navigator.clipboard.writeText(copyEmailString);
208+
} catch (err) {
209+
console.error(err);
210+
}
211+
};
212+
const handleSaveCSV = () => {
213+
const header = "Last Name,First Name,Email Address,Phone Number";
214+
const content = volunteers
215+
.filter((v) => v.selected)
216+
.map(
217+
(v) => `${v.lastName},${v.firstName},${v.emailAddress},${v.phoneNumber}`
218+
)
219+
.join("\n");
220+
221+
// const blob = new Blob([content], { type: "text/plain" });
222+
const blob = new Blob([`${header}\n${content}`], { type: "text/plain" });
223+
const url = URL.createObjectURL(blob);
224+
const a = document.createElement("a");
225+
a.href = url;
226+
a.download = "users.csv";
227+
document.body.appendChild(a);
228+
a.click();
229+
document.body.removeChild(a);
230+
URL.revokeObjectURL(url);
231+
};
232+
94233
const closeModal = useCallback(() => {
95234
setModalTitle(null);
96235
setModalMessage(null);
97236
router.refresh();
98237
}, [router]);
99238

239+
const handleMessage = () => {
240+
const selectedIds = volunteers
241+
.filter((v) => v.selected)
242+
.map((v) => v.userId);
243+
244+
sessionStorage.setItem(
245+
"adminEmailRecipientUserIds",
246+
JSON.stringify(selectedIds)
247+
);
248+
sessionStorage.setItem("adminEmailSource", "manage");
249+
250+
router.push("/admin/email");
251+
};
100252
if (isLoadingVols) {
101-
return <ManageRolesSkeleton/>;
102-
}
253+
return <ManageRolesSkeleton />;
254+
}
103255

104256
// Delete User - show confirmation first
105257
const handleDeleteConfirm = () => {
@@ -226,6 +378,120 @@ const ManageRolesPage = () => {
226378
Manage Roles
227379
</Link>
228380
</h1>
381+
382+
{/* search bar + sort dropdown copied/adapted from admin/email/page.tsx */}
383+
<div className="mb-4 flex items-center gap-4 w-full">
384+
<div ref={containerRef} className="relative flex-1">
385+
<div
386+
className={`min-h-[44px] w-full rounded-lg border px-3 py-2
387+
flex flex-wrap gap-2 cursor-text focus-within:ring-2`}
388+
onClick={() => setDropdownOpen(true)}
389+
>
390+
{volunteers
391+
.filter((v) => v.selected)
392+
.map((u) => (
393+
<span
394+
key={u.userId}
395+
className="flex items-center gap-1 border border-gray-400
396+
rounded-full px-3 py-0.5 text-sm text-medium-black bg-white
397+
whitespace-nowrap"
398+
>
399+
{u.lastName}, {u.firstName}
400+
<button
401+
type="button"
402+
onClick={(e) => {
403+
e.stopPropagation();
404+
toggleSelect(u.userId);
405+
}}
406+
className="ml-1 text-gray-500 hover:text-red-500
407+
leading-none"
408+
aria-label={`Remove ${u.firstName}`}
409+
>
410+
x
411+
</button>
412+
</span>
413+
))}
414+
<span className="flex-1 min-w-[4px]" />
415+
</div>
416+
417+
{dropdownOpen && (
418+
<div
419+
className="absolute z-50 left-0 right-0 bg-white border
420+
border-medium-gray rounded-lg shadow-lg mt-1"
421+
>
422+
<div className="p-2 border-b border-gray-100">
423+
<input
424+
ref={searchInputRef}
425+
type="text"
426+
placeholder="Search by name or email..."
427+
value={searchQuery}
428+
onChange={(e) => setSearchQuery(e.target.value)}
429+
onClick={(e) => e.stopPropagation()}
430+
onKeyDown={(e) => {
431+
if (e.key === "Enter" && seenVolunteers.length > 0) {
432+
addVolunteer(seenVolunteers[0].userId);
433+
}
434+
}}
435+
className="w-full border-none outline-none focus:ring-0 text-sm"
436+
/>
437+
</div>
438+
{seenVolunteers.length === 0 && searchQuery ? (
439+
<div className="p-2 text-sm text-gray-500">No results</div>
440+
) : (
441+
<div className="max-h-[320px] overflow-y-auto">
442+
{seenVolunteers.map((u) => (
443+
<button
444+
key={u.userId}
445+
type="button"
446+
onMouseDown={(e) => {
447+
e.preventDefault();
448+
addVolunteer(u.userId);
449+
}}
450+
className="flex items-center gap-2 w-full px-3 py-2 hover:bg-gray-100 text-left text-sm"
451+
>
452+
{u.lastName}, {u.firstName}{" "}
453+
<span className="text-gray-500">
454+
({u.emailAddress})
455+
</span>
456+
</button>
457+
))}
458+
</div>
459+
)}
460+
</div>
461+
)}
462+
</div>
463+
464+
{/* sort-by dropdown */}
465+
<div className="w-40">
466+
<label htmlFor="sort" className="sr-only">
467+
Sort by role
468+
</label>
469+
<select
470+
id="sort"
471+
value={sortOption}
472+
onChange={(e) =>
473+
setSortOption(
474+
e.target.value as
475+
| "NAME_AZ"
476+
| "NAME_ZA"
477+
| "ADMIN"
478+
| "VOLUNTEER"
479+
| "DATE_NEWEST"
480+
| "DATE_OLDEST"
481+
)
482+
}
483+
className="h-[44px] w-full rounded-lg border px-3 py-2 text-sm"
484+
>
485+
<option value="NAME_AZ">Name (A–Z)</option>
486+
<option value="NAME_ZA">Name (Z–A)</option>
487+
<option value="ADMIN">Admin</option>
488+
<option value="VOLUNTEER">Volunteer</option>
489+
<option value="DATE_NEWEST">Date Created (Newest)</option>
490+
<option value="DATE_OLDEST">Date Created (Oldest)</option>
491+
</select>
492+
</div>
493+
</div>
494+
229495
<div className="bg-white border border-black font-sans max-h-[550px] overflow-y-auto">
230496
{/* Volunteer Table (populated by `/api/users`) */}
231497
<table className="w-full border-white-700 text-bcp-blue">
@@ -247,7 +513,7 @@ const ManageRolesPage = () => {
247513
</tr>
248514
</thead>
249515
<tbody>
250-
{volunteers.map((p, i) => {
516+
{sortedVolunteers.map((p, i) => {
251517
const rowNumber = i + 1;
252518

253519
return (
@@ -287,16 +553,19 @@ const ManageRolesPage = () => {
287553
disabled={selectedCount <= 0}
288554
label="Message"
289555
altStyle="bg-[#f4f4f4] text-gray-700 px-5 py-2 rounded-md shadow hover:bg-gray-400"
556+
onClick={handleMessage}
290557
/>
291558
<Button
292559
disabled={selectedCount <= 0}
293560
label="Copy to Clipboard"
294561
altStyle="bg-[#f4f4f4] text-gray-700 px-5 py-2 rounded-md shadow hover:bg-gray-400"
562+
onClick={handleCopy}
295563
/>
296564
<Button
297565
disabled={selectedCount <= 0}
298566
label="Save as CSV"
299567
altStyle="bg-[#f4f4f4] text-gray-700 px-5 py-2 rounded-md shadow hover:bg-gray-400"
568+
onClick={handleSaveCSV}
300569
/>
301570
</div>
302571
<div className="flex justify-between gap-4">

0 commit comments

Comments
 (0)