Skip to content

Commit c87002a

Browse files
authored
Merge pull request #181 from CSE-Shaco/develop
Admin 영역 리팩터링 & 출석관리 UX 개선 및 권한관리 스캐폴드 추가
2 parents e163b07 + 66993cd commit c87002a

File tree

8 files changed

+491
-187
lines changed

8 files changed

+491
-187
lines changed
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import MenuHeader from '@/components/ui/common/MenuHeader';
2+
import ApiCodeGuard from '@/components/auth/ApiCodeGuard.jsx';
3+
4+
export const metadata = {
5+
title: "Member Manager", description: "Admin management platform",
6+
};
7+
8+
export default function AdminLayout({children}) {
9+
return (<ApiCodeGuard requiredRole="LEAD" requiredTeam="HR" nextOverride="/admin/member-manager">
10+
<>
11+
<MenuHeader/>
12+
{children}
13+
</>
14+
</ApiCodeGuard>);
15+
}
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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+
}
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import MenuHeader from '@/components/ui/common/MenuHeader';
22
import ApiCodeGuard from '@/components/auth/ApiCodeGuard.jsx';
33

44
export const metadata = {
5-
title: "Admin", description: "Admin management and participation platform",
5+
title: "Recruit Manager", description: "Admin management and participation platform",
66
};
77

88
export default function AdminLayout({children}) {
9-
return (<ApiCodeGuard requiredRole="ADMIN" nextOverride="/admin">
9+
return (<ApiCodeGuard requiredRole="LEAD" requiredTeam="HR" nextOverride="/admin/recruit-manager">
1010
<>
1111
<MenuHeader/>
1212
{children}

0 commit comments

Comments
 (0)