11"use client" ;
2- import { useState , useEffect , useMemo , useCallback } from "react" ;
2+ import { useState , useEffect , useMemo , useCallback , useRef } from "react" ;
33import { useRouter } from "next/navigation" ;
44import useSWR from "swr" ;
55import 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
2335const 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