1+ 'use client' ;
2+
3+ import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
4+ import {
5+ Chip ,
6+ Select ,
7+ SelectItem ,
8+ Spinner ,
9+ Table ,
10+ TableBody ,
11+ TableCell ,
12+ TableColumn ,
13+ TableHeader ,
14+ TableRow
15+ } from '@nextui-org/react' ;
16+ import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' ;
17+
18+ import AdminTableTopContent from '@/components/admin/AdminTableTopContent' ;
19+ import AdminTableBottomContent from '@/components/admin/AdminTableBottomContent' ;
20+
21+ const ROLE_OPTIONS = [ 'GUEST' , 'MEMBER' , 'CORE' , 'LEAD' , 'ORGANIZER' , 'ADMIN' ] ;
22+ const TEAM_OPTIONS = [ 'BD' , 'HR' , 'TECH' , 'PR/DESIGN' ] ;
23+
24+ const roleColor = ( r ) => ( {
25+ ADMIN : 'danger' , ORGANIZER : 'warning' , LEAD : 'secondary' , CORE : 'success' , MEMBER : 'primary' ,
26+ } [ r ] || 'default' ) ;
27+
28+ export default function AdminUsersPage ( ) {
29+ const { apiClient} = useAuthenticatedApi ( ) ;
30+
31+ const [ rows , setRows ] = useState ( [ ] ) ;
32+ const [ loading , setLoading ] = useState ( false ) ;
33+ const [ err , setErr ] = useState ( '' ) ;
34+
35+ // 검색/정렬/페이지
36+ const [ searchValue , setSearchValue ] = useState ( '' ) ;
37+ const [ query , setQuery ] = useState ( '' ) ;
38+ const [ page , setPage ] = useState ( 1 ) ;
39+ const rowsPerPage = 20 ;
40+ const [ totalPages , setTotalPages ] = useState ( 1 ) ;
41+ const [ totalUsers , setTotalUsers ] = useState ( 0 ) ;
42+ const [ sortDescriptor , setSortDescriptor ] = useState ( { column : 'name' , direction : 'ascending' } ) ;
43+
44+ // 프론트 → 백엔드 정렬 필드 매핑
45+ const sortColMap = useMemo ( ( ) => ( {
46+ name : 'name' ,
47+ major : 'major' ,
48+ studentId : 'studentId' ,
49+ email : 'email' ,
50+ userRole : 'userRole' ,
51+ team : 'team' ,
52+ createdAt : 'createdAt' , // 백업
53+ } ) , [ ] ) ;
54+
55+ const fetchUsers = useCallback ( async ( ) => {
56+ setLoading ( true ) ;
57+ setErr ( '' ) ;
58+ try {
59+ const sort = sortColMap [ sortDescriptor . column ] || 'name' ;
60+ const dir = sortDescriptor . direction === 'descending' ? 'DESC' : 'ASC' ;
61+ const params = { page : page - 1 , size : rowsPerPage , sort, dir, q : query || undefined } ;
62+
63+ const res = await apiClient . get ( '/admin/users' , { params} ) ;
64+ const pageData = res ?. data ?. data ; // Page<UserSummaryResponse>
65+ const meta = res ?. data ?. meta ;
66+
67+ const content = Array . isArray ( pageData ?. content ) ? pageData . content : [ ] ;
68+ setRows ( content ) ;
69+
70+ const total = meta ?. totalElements ?? pageData ?. totalElements ?? content . length ;
71+ setTotalUsers ( total ) ;
72+ setTotalPages ( Math . max ( 1 , Math . ceil ( total / rowsPerPage ) ) ) ;
73+ } catch ( e ) {
74+ setErr ( e ?. message || '사용자 목록을 불러오지 못했습니다.' ) ;
75+ setRows ( [ ] ) ;
76+ setTotalUsers ( 0 ) ;
77+ setTotalPages ( 1 ) ;
78+ } finally {
79+ setLoading ( false ) ;
80+ }
81+ } , [ apiClient , page , rowsPerPage , query , sortDescriptor , sortColMap ] ) ;
82+
83+ useEffect ( ( ) => {
84+ fetchUsers ( ) ;
85+ } , [ fetchUsers ] ) ;
86+
87+ const onSearch = useCallback ( ( ) => {
88+ setPage ( 1 ) ;
89+ setQuery ( ( searchValue || '' ) . trim ( ) ) ;
90+ } , [ searchValue ] ) ;
91+
92+ useEffect ( ( ) => {
93+ if ( searchValue === '' && query !== '' ) {
94+ setPage ( 1 ) ;
95+ setQuery ( '' ) ;
96+ }
97+ } , [ searchValue , query ] ) ;
98+
99+ // 역할/팀 패치: role만 → /role, 팀만/둘다 → /role-team
100+ const patchSmart = useCallback ( async ( { user, nextRole, nextTeam} ) => {
101+ const prev = rows ;
102+ setRows ( ( old ) => old . map ( ( u ) => ( u . id === user . id ? { ...u , userRole : nextRole , team : nextTeam } : u ) ) ) ;
103+ try {
104+ const roleChanged = nextRole !== user . userRole ;
105+ const teamChanged = ( nextTeam ?? null ) !== ( user . team ?? null ) ;
106+
107+ if ( teamChanged ) {
108+ await apiClient . patch ( `/admin/users/${ user . id } /role-team` , { role : nextRole , team : nextTeam ?? null } ) ;
109+ } else if ( roleChanged ) {
110+ await apiClient . patch ( `/admin/users/${ user . id } /role` , { role : nextRole } ) ;
111+ }
112+ } catch ( e ) {
113+ setRows ( prev ) ; // 롤백
114+ alert ( e ?. response ?. data ?. message || '변경 실패' ) ;
115+ }
116+ } , [ apiClient , rows ] ) ;
117+
118+ // ✅ 6개 컬럼 (id 제외)
119+ const columns = useMemo ( ( ) => [ { name : 'NAME' , uid : 'name' , sortable : true } , {
120+ name : 'MAJOR' ,
121+ uid : 'major' ,
122+ sortable : true
123+ } , { name : 'STUDENT ID' , uid : 'studentId' , sortable : true } , {
124+ name : 'EMAIL' ,
125+ uid : 'email' ,
126+ sortable : true
127+ } , { name : 'ROLE' , uid : 'userRole' , sortable : true } , { name : 'TEAM' , uid : 'team' , sortable : true } , ] , [ ] ) ;
128+
129+ return ( < div className = "dark text-white py-[30px] px-[96px] mobile:px-[10px]" >
130+ < h1 className = "text-3xl font-bold mb-6" > 사용자 관리</ h1 >
131+
132+ < div className = "mb-4" >
133+ < AdminTableTopContent searchValue = { searchValue } setSearchValue = { setSearchValue } onSearch = { onSearch } />
134+ </ div >
135+
136+ < Table
137+ aria-label = "Users table"
138+ className = "dark"
139+ sortDescriptor = { sortDescriptor }
140+ onSortChange = { setSortDescriptor }
141+ bottomContent = { < AdminTableBottomContent
142+ page = { page }
143+ totalPages = { totalPages }
144+ totalUsers = { totalUsers }
145+ onChangePage = { setPage }
146+ /> }
147+ >
148+ < TableHeader columns = { columns } >
149+ { ( col ) => ( < TableColumn
150+ key = { col . uid }
151+ allowsSorting = { col . sortable }
152+ className = { col . uid === 'userRole' || col . uid === 'team' ? 'w-[220px]' : col . uid === 'email' ? 'w-[260px]' : '' }
153+ >
154+ { col . name }
155+ </ TableColumn > ) }
156+ </ TableHeader >
157+
158+ < TableBody
159+ items = { rows }
160+ isLoading = { loading }
161+ loadingContent = { < Spinner label = "불러오는 중..." /> }
162+ emptyContent = { err || '데이터가 없습니다.' }
163+ >
164+ { ( user ) => ( < TableRow key = { user . id } className = "hover:bg-[#35353b99]" >
165+ { /* NAME */ }
166+ < TableCell >
167+ < span className = "font-medium" > { user . name } </ span >
168+ </ TableCell >
169+
170+ { /* MAJOR */ }
171+ < TableCell > { user . major } </ TableCell >
172+
173+ { /* STUDENT ID */ }
174+ < TableCell > { user . studentId } </ TableCell >
175+
176+ { /* EMAIL */ }
177+ < TableCell >
178+ < span className = "text-sm" > { user . email } </ span >
179+ </ TableCell >
180+
181+ { /* ROLE */ }
182+ < TableCell >
183+ < div className = "flex items-center gap-3" >
184+ < Chip size = "sm" variant = "flat" color = { roleColor ( user . userRole ) } >
185+ { user . userRole }
186+ </ Chip >
187+ < Select
188+ aria-label = "역할 수정"
189+ selectedKeys = { new Set ( [ user . userRole || '' ] ) }
190+ onSelectionChange = { ( keys ) => {
191+ const nextRole = String ( Array . from ( keys ) [ 0 ] || user . userRole ) ;
192+ if ( nextRole !== user . userRole ) {
193+ const ok = confirm ( `역할을 '${ user . userRole } ' → '${ nextRole } ' 로 변경할까요?` ) ;
194+ if ( ok ) patchSmart ( { user, nextRole, nextTeam : user . team ?? null } ) ;
195+ }
196+ } }
197+ size = "sm"
198+ className = "min-w-[140px]"
199+ classNames = { {
200+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
201+ value : 'text-white' ,
202+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
203+ listbox : 'text-white' ,
204+ selectorIcon : 'text-zinc-400' ,
205+ } }
206+ itemClasses = { {
207+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
208+ title : 'text-white' ,
209+ } }
210+ >
211+ { ROLE_OPTIONS . map ( ( r ) => ( < SelectItem key = { r } value = { r } >
212+ { r }
213+ </ SelectItem > ) ) }
214+ </ Select >
215+ </ div >
216+ </ TableCell >
217+
218+ { /* TEAM */ }
219+ < TableCell >
220+ < Select
221+ aria-label = "팀 수정"
222+ selectedKeys = { new Set ( [ user . team || '' ] ) }
223+ onSelectionChange = { ( keys ) => {
224+ const k = String ( Array . from ( keys ) [ 0 ] ?? '' ) ;
225+ const nextTeam = k === '' ? null : k ;
226+ if ( ( nextTeam ?? null ) !== ( user . team ?? null ) ) {
227+ const old = user . team ?? '(없음)' ;
228+ const neu = nextTeam ?? '(없음)' ;
229+ const ok = confirm ( `팀을 '${ old } ' → '${ neu } ' 로 변경할까요?` ) ;
230+ if ( ok ) patchSmart ( { user, nextRole : user . userRole , nextTeam} ) ;
231+ }
232+ } }
233+ size = "sm"
234+ className = "min-w-[160px]"
235+ classNames = { {
236+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
237+ value : 'text-white' ,
238+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
239+ listbox : 'text-white' ,
240+ selectorIcon : 'text-zinc-400' ,
241+ } }
242+ itemClasses = { {
243+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
244+ title : 'text-white' ,
245+ } }
246+ >
247+ < SelectItem key = "" value = "" >
248+ (없음)
249+ </ SelectItem >
250+ { TEAM_OPTIONS . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
251+ { t }
252+ </ SelectItem > ) ) }
253+ </ Select >
254+ </ TableCell >
255+ </ TableRow > ) }
256+ </ TableBody >
257+ </ Table >
258+ </ div > ) ;
259+ }
0 commit comments