22
33import React , { useCallback , useEffect , useMemo , useState } from 'react' ;
44import {
5+ Button ,
56 Chip ,
67 Select ,
78 SelectItem ,
@@ -13,22 +14,31 @@ import {
1314 TableHeader ,
1415 TableRow
1516} from '@nextui-org/react' ;
17+
1618import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' ;
19+ import { useAuth } from '@/hooks/useAuth' ;
1720
1821import AdminTableTopContent from '@/components/admin/AdminTableTopContent' ;
1922import AdminTableBottomContent from '@/components/admin/AdminTableBottomContent' ;
2023
24+ /** 백엔드 enum과 일치하는 값(전송용) */
2125const ROLE_OPTIONS = [ 'GUEST' , 'MEMBER' , 'CORE' , 'LEAD' , 'ORGANIZER' , 'ADMIN' ] ;
22- const TEAM_OPTIONS = [ 'BD' , 'HR' , 'TECH' , 'PR/DESIGN' ] ;
26+ const TEAM_ENUM_VALUES = [ 'BD' , 'HR' , 'TECH' , 'PR_DESIGN' ] ;
27+
28+ /** 화면 표시용 라벨 */
29+ const TEAM_LABEL = {
30+ BD : 'BD' , HR : 'HR' , TECH : 'TECH' , PR_DESIGN : 'PR/DESIGN' ,
31+ } ;
2332
2433const roleColor = ( r ) => ( {
2534 ADMIN : 'danger' , ORGANIZER : 'warning' , LEAD : 'secondary' , CORE : 'success' , MEMBER : 'primary' ,
2635} [ r ] || 'default' ) ;
2736
2837export default function AdminUsersPage ( ) {
2938 const { apiClient} = useAuthenticatedApi ( ) ;
39+ const { accessToken} = useAuth ( ) ;
3040
31- const [ rows , setRows ] = useState ( [ ] ) ;
41+ const [ rows , setRows ] = useState ( [ ] ) ; // [{id,name,major,studentId,email,userRole,team}]
3242 const [ loading , setLoading ] = useState ( false ) ;
3343 const [ err , setErr ] = useState ( '' ) ;
3444
@@ -41,6 +51,39 @@ export default function AdminUsersPage() {
4151 const [ totalUsers , setTotalUsers ] = useState ( 0 ) ;
4252 const [ sortDescriptor , setSortDescriptor ] = useState ( { column : 'name' , direction : 'ascending' } ) ;
4353
54+ /** 내 정보(자기 자신 삭제/변경 방지용) */
55+ const me = useMemo ( ( ) => {
56+ if ( ! accessToken ) return null ;
57+ try {
58+ const p = JSON . parse ( atob ( accessToken . split ( '.' ) [ 1 ] ) ) ;
59+ return { id : Number ( p . id ) || null } ;
60+ } catch {
61+ return null ;
62+ }
63+ } , [ accessToken ] ) ;
64+
65+ /** 삭제 버튼 렌더링 권한(서버로 체크: LEAD 이상) */
66+ const [ canRenderDelete , setCanRenderDelete ] = useState ( false ) ;
67+ useEffect ( ( ) => {
68+ let alive = true ;
69+ ( async ( ) => {
70+ try {
71+ const res = await apiClient . get ( '/auth/LEAD' , {
72+ validateStatus : ( s ) => s === 200 || s === 204 || s === 401 || s === 403 ,
73+ headers : { Accept : 'application/json' } ,
74+ } ) ;
75+ const okHttp = res ?. status === 200 || res ?. status === 204 ;
76+ const okBody = ( res ?. data ?. code ?? 200 ) === 200 ;
77+ if ( alive ) setCanRenderDelete ( okHttp && okBody ) ;
78+ } catch {
79+ if ( alive ) setCanRenderDelete ( false ) ;
80+ }
81+ } ) ( ) ;
82+ return ( ) => {
83+ alive = false ;
84+ } ;
85+ } , [ apiClient ] ) ;
86+
4487 // 프론트 → 백엔드 정렬 필드 매핑
4588 const sortColMap = useMemo ( ( ) => ( {
4689 name : 'name' ,
@@ -115,145 +158,177 @@ export default function AdminUsersPage() {
115158 }
116159 } , [ apiClient , rows ] ) ;
117160
118- // ✅ 6개 컬럼 (id 제외)
119- const columns = useMemo ( ( ) => [ { name : 'NAME' , uid : 'name' , sortable : true } , {
120- name : 'MAJOR' ,
121- uid : 'major' ,
122- sortable : true
161+ // 삭제
162+ const handleDelete = useCallback ( async ( user ) => {
163+ const confirmed = window . confirm ( `{${ user . name } }을(를) 삭제하시겠습니까?` ) ;
164+ if ( ! confirmed ) return ;
165+
166+ const prev = rows ;
167+ // 낙관적 제거
168+ setRows ( ( old ) => old . filter ( ( u ) => u . id !== user . id ) ) ;
169+
170+ try {
171+ await apiClient . delete ( `/admin/users/${ user . id } ` ) ;
172+ } catch ( e ) {
173+ // 롤백
174+ setRows ( prev ) ;
175+ alert ( e ?. response ?. data ?. message || '삭제 실패' ) ;
176+ }
177+ } , [ apiClient , rows ] ) ;
178+
179+ // 6개 데이터 컬럼 + ACTIONS
180+ const columns = useMemo ( ( ) => ( [ { name : 'NAME' , uid : 'name' , sortable : true } , {
181+ name : 'MAJOR' , uid : 'major' , sortable : true
123182 } , { 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 } , ] , [ ] ) ;
183+ name : 'EMAIL' , uid : 'email' , sortable : true
184+ } , { name : 'ROLE' , uid : 'userRole' , sortable : true } , { name : 'TEAM' , uid : 'team' , sortable : true } , {
185+ name : 'ACTIONS' , uid : 'actions' , sortable : false
186+ } , ] ) , [ ] ) ;
128187
129188 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- /> }
189+ < h1 className = "text-3xl font-bold mb-6" > 사용자 관리</ h1 >
190+
191+ < div className = "mb-4" >
192+ < AdminTableTopContent searchValue = { searchValue } setSearchValue = { setSearchValue } onSearch = { onSearch } />
193+ </ div >
194+
195+ < Table
196+ aria-label = "Users table"
197+ className = "dark"
198+ sortDescriptor = { sortDescriptor }
199+ onSortChange = { setSortDescriptor }
200+ bottomContent = { < AdminTableBottomContent
201+ page = { page }
202+ totalPages = { totalPages }
203+ totalUsers = { totalUsers }
204+ onChangePage = { setPage }
205+ /> }
206+ >
207+ < TableHeader columns = { columns } >
208+ { ( col ) => ( < TableColumn
209+ key = { col . uid }
210+ allowsSorting = { col . sortable }
211+ className = { col . uid === 'userRole' || col . uid === 'team' ? 'w-[220px]' : col . uid === 'email' ? 'w-[260px]' : col . uid === 'actions' ? 'w-[120px] text-right' : '' }
212+ align = { col . uid === 'actions' ? 'end' : 'start' }
213+ >
214+ { col . name }
215+ </ TableColumn > ) }
216+ </ TableHeader >
217+
218+ < TableBody
219+ items = { rows }
220+ isLoading = { loading }
221+ loadingContent = { < Spinner label = "불러오는 중..." /> }
222+ emptyContent = { err || '데이터가 없습니다.' }
147223 >
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]' : '' }
224+ { ( user ) => ( < TableRow key = { user . id } className = "hover:bg-[#35353b99]" >
225+ { /* NAME */ }
226+ < TableCell >
227+ < span className = "font-medium" > { user . name } </ span >
228+ </ TableCell >
229+
230+ { /* MAJOR */ }
231+ < TableCell > { user . major } </ TableCell >
232+
233+ { /* STUDENT ID */ }
234+ < TableCell > { user . studentId } </ TableCell >
235+
236+ { /* EMAIL */ }
237+ < TableCell >
238+ < span className = "text-sm" > { user . email } </ span >
239+ </ TableCell >
240+
241+ { /* ROLE */ }
242+ < TableCell >
243+ < div className = "flex items-center gap-3" >
244+ < Chip size = "sm" variant = "flat" color = { roleColor ( user . userRole ) } >
245+ { user . userRole }
246+ </ Chip >
247+ < Select
248+ aria-label = "역할 수정"
249+ selectedKeys = { new Set ( [ user . userRole || '' ] ) }
250+ onSelectionChange = { ( keys ) => {
251+ const nextRole = String ( Array . from ( keys ) [ 0 ] || user . userRole ) ;
252+ if ( nextRole !== user . userRole ) {
253+ const ok = confirm ( `역할을 '${ user . userRole } ' → '${ nextRole } ' 로 변경할까요?` ) ;
254+ if ( ok ) patchSmart ( { user, nextRole, nextTeam : user . team ?? null } ) ;
255+ }
256+ } }
257+ size = "sm"
258+ className = "min-w-[140px]"
259+ classNames = { {
260+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
261+ value : 'text-white' ,
262+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
263+ listbox : 'text-white' ,
264+ selectorIcon : 'text-zinc-400' ,
265+ } }
266+ itemClasses = { {
267+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
268+ title : 'text-white' ,
269+ } }
270+ >
271+ { ROLE_OPTIONS . map ( ( r ) => ( < SelectItem key = { r } value = { r } > { r } </ SelectItem > ) ) }
272+ </ Select >
273+ </ div >
274+ </ TableCell >
275+
276+ { /* TEAM */ }
277+ < TableCell >
278+ < Select
279+ aria-label = "팀 수정"
280+ selectedKeys = { new Set ( [ user . team || '' ] ) }
281+ onSelectionChange = { ( keys ) => {
282+ const k = String ( Array . from ( keys ) [ 0 ] ?? '' ) ;
283+ const nextTeam = k === '' ? null : k ; // 서버에는 enum name로 전송
284+ if ( ( nextTeam ?? null ) !== ( user . team ?? null ) ) {
285+ const old = user . team ? ( TEAM_LABEL [ user . team ] || user . team ) : '(없음)' ;
286+ const neu = nextTeam ? ( TEAM_LABEL [ nextTeam ] || nextTeam ) : '(없음)' ;
287+ const ok = confirm ( `팀을 '${ old } ' → '${ neu } ' 로 변경할까요?` ) ;
288+ if ( ok ) patchSmart ( { user, nextRole : user . userRole , nextTeam} ) ;
289+ }
290+ } }
291+ size = "sm"
292+ className = "min-w-[160px]"
293+ classNames = { {
294+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
295+ value : 'text-white' ,
296+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
297+ listbox : 'text-white' ,
298+ selectorIcon : 'text-zinc-400' ,
299+ } }
300+ itemClasses = { {
301+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
302+ title : 'text-white' ,
303+ } }
153304 >
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 > ) ;
305+ < SelectItem key = "" value = "" >
306+ (없음)
307+ </ SelectItem >
308+ { TEAM_ENUM_VALUES . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
309+ { TEAM_LABEL [ t ] }
310+ </ SelectItem > ) ) }
311+ </ Select >
312+ </ TableCell >
313+
314+ { /* ACTIONS (Delete) */ }
315+ < TableCell >
316+ < div className = "flex justify-end" >
317+ { canRenderDelete && user . id !== me ?. id ? ( < Button
318+ size = "sm"
319+ color = "danger"
320+ variant = "flat"
321+ onClick = { ( e ) => {
322+ e . stopPropagation ( ) ;
323+ handleDelete ( user ) ;
324+ } }
325+ >
326+ 삭제
327+ </ Button > ) : null }
328+ </ div >
329+ </ TableCell >
330+ </ TableRow > ) }
331+ </ TableBody >
332+ </ Table >
333+ </ div > ) ;
259334}
0 commit comments