- 테마 시스템: CSS 변수 + data-theme + Tailwind v4 시맨틱 색상 (다크모드 지원) - 공통 컴포넌트: CodeBlock, Alert, StepGuide, CopyButton, TableOfContents - 가이드 콘텐츠 8개 섹션 (React.lazy 동적 로딩, 실제 인프라 검증 완료) - 관리자 페이지 4개 (사용자/롤/권한/통계) - 레이아웃: 반응형 사이드바 + 테마 토글 + ScrollSpy 목차 - 인증: Google OAuth 로그인/세션복원/로그아웃 백엔드 API 연동 - 개발모드 mock 인증 (import.meta.env.DEV 전용) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
249 lines
9.9 KiB
TypeScript
249 lines
9.9 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import type { Role, User } from '../../types';
|
|
import { api } from '../../utils/api';
|
|
|
|
type FilterStatus = 'ALL' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED';
|
|
|
|
export function UserManagement() {
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const [roles, setRoles] = useState<Role[]>([]);
|
|
const [filter, setFilter] = useState<FilterStatus>('ALL');
|
|
const [loading, setLoading] = useState(true);
|
|
const [roleModalUser, setRoleModalUser] = useState<User | null>(null);
|
|
const [selectedRoleIds, setSelectedRoleIds] = useState<number[]>([]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
try {
|
|
const [usersData, rolesData] = await Promise.all([
|
|
api.get<User[]>('/admin/users'),
|
|
api.get<Role[]>('/admin/roles'),
|
|
]);
|
|
setUsers(usersData);
|
|
setRoles(rolesData);
|
|
} catch {
|
|
// API 미연동 시 빈 배열 유지
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
const handleAction = async (userId: number, action: 'approve' | 'reject' | 'disable') => {
|
|
try {
|
|
await api.put<User>(`/admin/users/${userId}/${action}`, {});
|
|
fetchData();
|
|
} catch {
|
|
// 에러 처리
|
|
}
|
|
};
|
|
|
|
const handleRoleSave = async () => {
|
|
if (!roleModalUser) return;
|
|
try {
|
|
await api.put<User>(`/admin/users/${roleModalUser.id}/roles`, { roleIds: selectedRoleIds });
|
|
setRoleModalUser(null);
|
|
fetchData();
|
|
} catch {
|
|
// 에러 처리
|
|
}
|
|
};
|
|
|
|
const filteredUsers = filter === 'ALL' ? users : users.filter((u) => u.status === filter);
|
|
|
|
const statusBadge = (status: User['status']) => {
|
|
const styles: Record<string, string> = {
|
|
ACTIVE: 'bg-success/10 text-success',
|
|
PENDING: 'bg-warning/10 text-warning',
|
|
REJECTED: 'bg-danger/10 text-danger',
|
|
DISABLED: 'bg-bg-tertiary text-text-muted',
|
|
};
|
|
const labels: Record<string, string> = {
|
|
ACTIVE: '활성', PENDING: '대기', REJECTED: '거절', DISABLED: '비활성',
|
|
};
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${styles[status]}`}>
|
|
{labels[status]}
|
|
</span>
|
|
);
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-20">
|
|
<div className="animate-spin h-6 w-6 border-3 border-accent border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-8">
|
|
<h1 className="text-2xl font-bold text-text-primary mb-6">사용자 관리</h1>
|
|
|
|
{/* 필터 */}
|
|
<div className="flex gap-2 mb-6">
|
|
{(['ALL', 'PENDING', 'ACTIVE', 'REJECTED', 'DISABLED'] as FilterStatus[]).map((s) => (
|
|
<button
|
|
key={s}
|
|
onClick={() => setFilter(s)}
|
|
className={`px-3 py-1.5 rounded-lg text-sm cursor-pointer transition-colors ${
|
|
filter === s
|
|
? 'bg-accent text-white'
|
|
: 'bg-surface border border-border-default text-text-secondary hover:bg-bg-tertiary'
|
|
}`}
|
|
>
|
|
{s === 'ALL' ? '전체' : s === 'PENDING' ? '대기' : s === 'ACTIVE' ? '활성' : s === 'REJECTED' ? '거절' : '비활성'}
|
|
{s !== 'ALL' && (
|
|
<span className="ml-1 text-xs opacity-70">
|
|
({users.filter((u) => u.status === s).length})
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* 테이블 */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full bg-surface border border-border-default rounded-lg overflow-hidden text-sm">
|
|
<thead>
|
|
<tr className="bg-bg-tertiary">
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">사용자</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">상태</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">롤</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">가입일</th>
|
|
<th className="text-left px-4 py-3 font-semibold text-text-primary">작업</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-subtle">
|
|
{filteredUsers.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={5} className="px-4 py-8 text-center text-text-muted">
|
|
{users.length === 0 ? '등록된 사용자가 없습니다.' : '해당 상태의 사용자가 없습니다.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredUsers.map((user) => (
|
|
<tr key={user.id}>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3">
|
|
{user.avatarUrl ? (
|
|
<img src={user.avatarUrl} alt="" className="w-8 h-8 rounded-full" />
|
|
) : (
|
|
<div className="w-8 h-8 bg-accent-soft rounded-full flex items-center justify-center text-xs font-medium text-accent">
|
|
{user.name[0]}
|
|
</div>
|
|
)}
|
|
<div>
|
|
<p className="font-medium text-text-primary">{user.name}</p>
|
|
<p className="text-xs text-text-muted">{user.email}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">{statusBadge(user.status)}</td>
|
|
<td className="px-4 py-3 text-text-secondary">
|
|
{user.roles.length > 0
|
|
? user.roles.map((r) => r.name).join(', ')
|
|
: <span className="text-text-muted">미배정</span>}
|
|
</td>
|
|
<td className="px-4 py-3 text-text-muted text-xs">
|
|
{new Date(user.createdAt).toLocaleDateString('ko-KR')}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex gap-1.5">
|
|
{user.status === 'PENDING' && (
|
|
<>
|
|
<button
|
|
onClick={() => handleAction(user.id, 'approve')}
|
|
className="px-2.5 py-1 bg-success/10 text-success rounded text-xs font-medium hover:bg-success/20 cursor-pointer"
|
|
>
|
|
승인
|
|
</button>
|
|
<button
|
|
onClick={() => handleAction(user.id, 'reject')}
|
|
className="px-2.5 py-1 bg-danger/10 text-danger rounded text-xs font-medium hover:bg-danger/20 cursor-pointer"
|
|
>
|
|
거절
|
|
</button>
|
|
</>
|
|
)}
|
|
{user.status === 'ACTIVE' && (
|
|
<button
|
|
onClick={() => handleAction(user.id, 'disable')}
|
|
className="px-2.5 py-1 bg-bg-tertiary text-text-muted rounded text-xs font-medium hover:bg-border-default cursor-pointer"
|
|
>
|
|
비활성화
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => {
|
|
setRoleModalUser(user);
|
|
setSelectedRoleIds(user.roles.map((r) => r.id));
|
|
}}
|
|
className="px-2.5 py-1 bg-accent-soft text-accent rounded text-xs font-medium hover:bg-accent/20 cursor-pointer"
|
|
>
|
|
롤 배정
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* 롤 배정 모달 */}
|
|
{roleModalUser && (
|
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
|
<div className="bg-surface rounded-xl shadow-xl max-w-md w-full p-6">
|
|
<h3 className="text-lg font-bold text-text-primary mb-4">
|
|
롤 배정 — {roleModalUser.name}
|
|
</h3>
|
|
<div className="space-y-2 mb-6">
|
|
{roles.map((role) => (
|
|
<label key={role.id} className="flex items-center gap-3 p-2 rounded-lg hover:bg-bg-tertiary cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedRoleIds.includes(role.id)}
|
|
onChange={(e) => {
|
|
setSelectedRoleIds(
|
|
e.target.checked
|
|
? [...selectedRoleIds, role.id]
|
|
: selectedRoleIds.filter((id) => id !== role.id),
|
|
);
|
|
}}
|
|
className="rounded accent-accent"
|
|
/>
|
|
<div>
|
|
<p className="text-sm font-medium text-text-primary">{role.name}</p>
|
|
<p className="text-xs text-text-muted">{role.description}</p>
|
|
</div>
|
|
</label>
|
|
))}
|
|
{roles.length === 0 && (
|
|
<p className="text-sm text-text-muted py-4 text-center">등록된 롤이 없습니다.</p>
|
|
)}
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
onClick={() => setRoleModalUser(null)}
|
|
className="px-4 py-2 text-sm text-text-secondary border border-border-default rounded-lg hover:bg-bg-tertiary cursor-pointer"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={handleRoleSave}
|
|
className="px-4 py-2 text-sm bg-accent text-white rounded-lg hover:bg-accent-hover cursor-pointer"
|
|
>
|
|
저장
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|