Skip to content

Commit 82fb42c

Browse files
authored
Merge pull request #183 from CSE-Shaco/develop
feat(admin): 사용자 관리 페이지에 삭제 버튼 추가 및 권한 기반 렌더링 적용
2 parents a9f5fea + d482d88 commit 82fb42c

File tree

1 file changed

+213
-138
lines changed

1 file changed

+213
-138
lines changed

src/app/admin/member-manager/page.jsx

Lines changed: 213 additions & 138 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import React, {useCallback, useEffect, useMemo, useState} from 'react';
44
import {
5+
Button,
56
Chip,
67
Select,
78
SelectItem,
@@ -13,22 +14,31 @@ import {
1314
TableHeader,
1415
TableRow
1516
} from '@nextui-org/react';
17+
1618
import {useAuthenticatedApi} from '@/hooks/useAuthenticatedApi';
19+
import {useAuth} from '@/hooks/useAuth';
1720

1821
import AdminTableTopContent from '@/components/admin/AdminTableTopContent';
1922
import AdminTableBottomContent from '@/components/admin/AdminTableBottomContent';
2023

24+
/** 백엔드 enum과 일치하는 값(전송용) */
2125
const 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

2433
const roleColor = (r) => ({
2534
ADMIN: 'danger', ORGANIZER: 'warning', LEAD: 'secondary', CORE: 'success', MEMBER: 'primary',
2635
}[r] || 'default');
2736

2837
export 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

Comments
 (0)