@@ -78,21 +78,34 @@ export default function AdminUsersPage() {
7878
7979 /** 삭제 버튼 렌더링 권한(서버로 체크: LEAD 이상) */
8080 const [ canRenderDelete , setCanRenderDelete ] = useState ( false ) ;
81+
8182 useEffect ( ( ) => {
8283 let alive = true ;
84+
8385 ( async ( ) => {
8486 try {
8587 const res = await apiClient . get ( '/auth/LEAD' , {
8688 validateStatus : ( s ) => s === 200 || s === 204 || s === 401 || s === 403 ,
8789 headers : { Accept : 'application/json' } ,
8890 } ) ;
91+
92+ if ( ! alive ) return ;
93+
94+ // 403이면 바로 discard
95+ if ( res ?. status === 403 ) {
96+ setCanRenderDelete ( false ) ;
97+ return ;
98+ }
99+
89100 const okHttp = res ?. status === 200 || res ?. status === 204 ;
90101 const okBody = ( res ?. data ?. code ?? 200 ) === 200 ;
91- if ( alive ) setCanRenderDelete ( okHttp && okBody ) ;
102+
103+ setCanRenderDelete ( okHttp && okBody ) ;
92104 } catch {
93105 if ( alive ) setCanRenderDelete ( false ) ;
94106 }
95107 } ) ( ) ;
108+
96109 return ( ) => {
97110 alive = false ;
98111 } ;
@@ -234,13 +247,9 @@ export default function AdminUsersPage() {
234247 // 6개 데이터 컬럼 + ACTIONS
235248 const columns = useMemo ( ( ) => {
236249 const base = [ { name : 'NAME' , uid : 'name' , sortable : true } , {
237- name : 'MAJOR' ,
238- uid : 'major' ,
239- sortable : true
250+ name : 'MAJOR' , uid : 'major' , sortable : true
240251 } , { name : 'STUDENT ID' , uid : 'studentId' , sortable : true } , {
241- name : 'EMAIL' ,
242- uid : 'email' ,
243- sortable : true
252+ name : 'EMAIL' , uid : 'email' , sortable : true
244253 } , { name : 'ROLE' , uid : 'userRole' , sortable : true } , { name : 'TEAM' , uid : 'team' , sortable : true } , ] ;
245254 if ( canRenderDelete ) {
246255 base . push ( { name : 'ACTIONS' , uid : 'actions' , sortable : false } ) ;
@@ -249,144 +258,144 @@ export default function AdminUsersPage() {
249258 } , [ canRenderDelete ] ) ;
250259
251260 return ( < div className = "dark text-white py-[30px] px-[96px] mobile:px-[10px]" >
252- < h1 className = "text-3xl font-bold mb-6" > 사용자 관리</ h1 >
253-
254- { /* 검색바 */ }
255- < div className = "mb-3" >
256- < AdminTableTopContent searchValue = { searchValue } setSearchValue = { setSearchValue } onSearch = { onSearch } />
257- </ div >
258-
259- < Table
260- aria-label = "Users table"
261- className = "dark"
262- sortDescriptor = { pendingSort }
263- onSortChange = { setPendingSort }
264- bottomContent = { < AdminTableBottomContent page = { page } totalPages = { totalPages } totalUsers = { totalUsers }
265- onChangePage = { setPage } /> }
266- >
267- < TableHeader columns = { columns } >
268- { ( col ) => ( < TableColumn
269- key = { col . uid }
270- allowsSorting = { col . sortable }
271- className = { col . uid === 'userRole' || col . uid === 'team' ? 'w-[220px]' : col . uid === 'email' ? 'w-[260px]' : col . uid === 'actions' ? 'w-[120px] text-right' : '' }
272- align = { col . uid === 'actions' ? 'end' : 'start' }
261+ < h1 className = "text-3xl font-bold mb-6" > 사용자 관리</ h1 >
262+
263+ { /* 검색바 */ }
264+ < div className = "mb-3" >
265+ < AdminTableTopContent searchValue = { searchValue } setSearchValue = { setSearchValue } onSearch = { onSearch } />
266+ </ div >
267+
268+ < Table
269+ aria-label = "Users table"
270+ className = "dark"
271+ sortDescriptor = { pendingSort }
272+ onSortChange = { setPendingSort }
273+ bottomContent = { < AdminTableBottomContent page = { page } totalPages = { totalPages } totalUsers = { totalUsers }
274+ onChangePage = { setPage } /> }
275+ >
276+ < TableHeader columns = { columns } >
277+ { ( col ) => ( < TableColumn
278+ key = { col . uid }
279+ allowsSorting = { col . sortable }
280+ className = { col . uid === 'userRole' || col . uid === 'team' ? 'w-[220px]' : col . uid === 'email' ? 'w-[260px]' : col . uid === 'actions' ? 'w-[120px] text-right' : '' }
281+ align = { col . uid === 'actions' ? 'end' : 'start' }
282+ >
283+ { col . name }
284+ </ TableColumn > ) }
285+ </ TableHeader >
286+
287+ < TableBody items = { rows } isLoading = { loading } loadingContent = { < Spinner label = "불러오는 중..." /> }
288+ emptyContent = { err || '데이터가 없습니다.' } >
289+ { ( user ) => ( < TableRow key = { user . id } className = "hover:bg-[#35353b99]" >
290+ { /* NAME */ }
291+ < TableCell >
292+ < span className = "font-medium mr-2" > { user . name } </ span >
293+ < Chip size = "sm" variant = "flat" color = { roleColor ( user . userRole ) } >
294+ { user . userRole }
295+ </ Chip >
296+ </ TableCell >
297+
298+ { /* MAJOR */ }
299+ < TableCell > { user . major } </ TableCell >
300+
301+ { /* STUDENT ID */ }
302+ < TableCell > { user . studentId } </ TableCell >
303+
304+ { /* EMAIL */ }
305+ < TableCell >
306+ < span className = "text-sm" > { user . email } </ span >
307+ </ TableCell >
308+
309+ { /* ROLE */ }
310+ < TableCell >
311+ < div className = "flex items-center gap-3" >
312+ < Select
313+ aria-label = "역할 수정"
314+ selectedKeys = { new Set ( [ user . userRole || '' ] ) }
315+ onSelectionChange = { ( keys ) => {
316+ const nextRole = String ( Array . from ( keys ) [ 0 ] || user . userRole ) ;
317+ if ( nextRole !== user . userRole ) {
318+ const ok = confirm ( `역할을 '${ user . userRole } ' → '${ nextRole } ' 로 변경할까요?` ) ;
319+ if ( ok ) void patchSmart ( { user, nextRole, nextTeam : user . team ?? null } ) ;
320+ }
321+ } }
322+ size = "sm"
323+ className = "min-w-[140px]"
324+ classNames = { {
325+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
326+ value : 'text-white' ,
327+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
328+ listbox : 'text-white' ,
329+ selectorIcon : 'text-zinc-400' ,
330+ } }
331+ itemClasses = { {
332+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
333+ title : 'text-white' ,
334+ } }
335+ >
336+ { ROLE_OPTIONS . map ( ( r ) => ( < SelectItem key = { r } value = { r } >
337+ { r }
338+ </ SelectItem > ) ) }
339+ </ Select >
340+ </ div >
341+ </ TableCell >
342+
343+ { /* TEAM */ }
344+ < TableCell >
345+ < Select
346+ aria-label = "팀 수정"
347+ selectedKeys = { new Set ( [ user . team || '' ] ) }
348+ onSelectionChange = { ( keys ) => {
349+ const k = String ( Array . from ( keys ) [ 0 ] ?? '' ) ;
350+ const nextTeam = k === '' ? null : k ; // 서버에는 enum name로 전송
351+ if ( ( nextTeam ?? null ) !== ( user . team ?? null ) ) {
352+ const old = user . team ? TEAM_LABEL [ user . team ] || user . team : '(없음)' ;
353+ const neu = nextTeam ? TEAM_LABEL [ nextTeam ] || nextTeam : '(없음)' ;
354+ const ok = confirm ( `팀을 '${ old } ' → '${ neu } ' 로 변경할까요?` ) ;
355+ if ( ok ) void patchSmart ( { user, nextRole : user . userRole , nextTeam} ) ;
356+ }
357+ } }
358+ size = "sm"
359+ className = "min-w-[160px]"
360+ classNames = { {
361+ trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
362+ value : 'text-white' ,
363+ popoverContent : 'bg-zinc-900 border border-zinc-700' ,
364+ listbox : 'text-white' ,
365+ selectorIcon : 'text-zinc-400' ,
366+ } }
367+ itemClasses = { {
368+ base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
369+ title : 'text-white' ,
370+ } }
273371 >
274- { col . name }
275- </ TableColumn > ) }
276- </ TableHeader >
277-
278- < TableBody items = { rows } isLoading = { loading } loadingContent = { < Spinner label = "불러오는 중..." /> }
279- emptyContent = { err || '데이터가 없습니다.' } >
280- { ( user ) => ( < TableRow key = { user . id } className = "hover:bg-[#35353b99]" >
281- { /* NAME */ }
282- < TableCell >
283- < span className = "font-medium mr-2" > { user . name } </ span >
284- < Chip size = "sm" variant = "flat" color = { roleColor ( user . userRole ) } >
285- { user . userRole }
286- </ Chip >
287- </ TableCell >
288-
289- { /* MAJOR */ }
290- < TableCell > { user . major } </ TableCell >
291-
292- { /* STUDENT ID */ }
293- < TableCell > { user . studentId } </ TableCell >
294-
295- { /* EMAIL */ }
296- < TableCell >
297- < span className = "text-sm" > { user . email } </ span >
298- </ TableCell >
299-
300- { /* ROLE */ }
301- < TableCell >
302- < div className = "flex items-center gap-3" >
303- < Select
304- aria-label = "역할 수정"
305- selectedKeys = { new Set ( [ user . userRole || '' ] ) }
306- onSelectionChange = { ( keys ) => {
307- const nextRole = String ( Array . from ( keys ) [ 0 ] || user . userRole ) ;
308- if ( nextRole !== user . userRole ) {
309- const ok = confirm ( `역할을 '${ user . userRole } ' → '${ nextRole } ' 로 변경할까요?` ) ;
310- if ( ok ) void patchSmart ( { user, nextRole, nextTeam : user . team ?? null } ) ;
311- }
312- } }
313- size = "sm"
314- className = "min-w-[140px]"
315- classNames = { {
316- trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
317- value : 'text-white' ,
318- popoverContent : 'bg-zinc-900 border border-zinc-700' ,
319- listbox : 'text-white' ,
320- selectorIcon : 'text-zinc-400' ,
321- } }
322- itemClasses = { {
323- base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
324- title : 'text-white' ,
325- } }
326- >
327- { ROLE_OPTIONS . map ( ( r ) => ( < SelectItem key = { r } value = { r } >
328- { r }
329- </ SelectItem > ) ) }
330- </ Select >
331- </ div >
332- </ TableCell >
333-
334- { /* TEAM */ }
335- < TableCell >
336- < Select
337- aria-label = "팀 수정"
338- selectedKeys = { new Set ( [ user . team || '' ] ) }
339- onSelectionChange = { ( keys ) => {
340- const k = String ( Array . from ( keys ) [ 0 ] ?? '' ) ;
341- const nextTeam = k === '' ? null : k ; // 서버에는 enum name로 전송
342- if ( ( nextTeam ?? null ) !== ( user . team ?? null ) ) {
343- const old = user . team ? TEAM_LABEL [ user . team ] || user . team : '(없음)' ;
344- const neu = nextTeam ? TEAM_LABEL [ nextTeam ] || nextTeam : '(없음)' ;
345- const ok = confirm ( `팀을 '${ old } ' → '${ neu } ' 로 변경할까요?` ) ;
346- if ( ok ) void patchSmart ( { user, nextRole : user . userRole , nextTeam} ) ;
347- }
348- } }
349- size = "sm"
350- className = "min-w-[160px]"
351- classNames = { {
352- trigger : 'bg-zinc-900 text-white border border-zinc-700 data-[hover=true]:bg-zinc-800' ,
353- value : 'text-white' ,
354- popoverContent : 'bg-zinc-900 border border-zinc-700' ,
355- listbox : 'text-white' ,
356- selectorIcon : 'text-zinc-400' ,
357- } }
358- itemClasses = { {
359- base : 'rounded-md data-[hover=true]:bg-zinc-800 data-[focus=true]:bg-zinc-800' ,
360- title : 'text-white' ,
361- } }
362- >
363- < SelectItem key = "" value = "" >
364- (없음)
365- </ SelectItem >
366- { TEAM_ENUM_VALUES . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
367- { TEAM_LABEL [ t ] }
368- </ SelectItem > ) ) }
369- </ Select >
370- </ TableCell >
371-
372- { /* ACTIONS (Delete) */ }
373- { canRenderDelete ? ( < TableCell >
374- < div className = "flex justify-end" >
375- { user . id !== me ?. id ? ( < Button
376- size = "sm"
377- color = "danger"
378- variant = "flat"
379- onClick = { ( e ) => {
380- e . stopPropagation ( ) ;
381- void handleDelete ( user ) ;
382- } }
383- >
384- 삭제
385- </ Button > ) : null }
386- </ div >
387- </ TableCell > ) : null }
388- </ TableRow > ) }
389- </ TableBody >
390- </ Table >
391- </ div > ) ;
372+ < SelectItem key = "" value = "" >
373+ (없음)
374+ </ SelectItem >
375+ { TEAM_ENUM_VALUES . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
376+ { TEAM_LABEL [ t ] }
377+ </ SelectItem > ) ) }
378+ </ Select >
379+ </ TableCell >
380+
381+ { /* ACTIONS (Delete) */ }
382+ { canRenderDelete ? ( < TableCell >
383+ < div className = "flex justify-end" >
384+ { user . id !== me ?. id ? ( < Button
385+ size = "sm"
386+ color = "danger"
387+ variant = "flat"
388+ onClick = { ( e ) => {
389+ e . stopPropagation ( ) ;
390+ void handleDelete ( user ) ;
391+ } }
392+ >
393+ 삭제
394+ </ Button > ) : null }
395+ </ div >
396+ </ TableCell > ) : null }
397+ </ TableRow > ) }
398+ </ TableBody >
399+ </ Table >
400+ </ div > ) ;
392401}
0 commit comments