gc-guide/src/pages/admin/UserManagement.tsx
htlee d72799011d feat: 백엔드 API 연동 강화 및 통계 페이지 개편
- StatsResponse/LoginHistory 타입을 백엔드 스키마에 동기화
- 통계 카드 7개(사용자 상태별, 오늘 로그인, 전체 롤) + 로그인 이력 테이블 추가
- 권한 삭제 API 경로 수정 (/admin/roles/permissions/)
- 사용자 관리에 관리자 지정/해제 토글 및 ADMIN 뱃지 추가
- 가이드/홈 페이지에 활동 추적(POST /activity/track) 연동
- CiCdGuide.tsx ESLint no-useless-escape 에러 수정

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:00:32 +09:00

281 lines
11 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 handleToggleAdmin = async (userId: number, isAdmin: boolean) => {
try {
if (isAdmin) {
await api.delete(`/admin/users/${userId}/admin`);
} else {
await api.post(`/admin/users/${userId}/admin`);
}
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}
{user.isAdmin && (
<span className="ml-1.5 px-1.5 py-0.5 bg-accent/10 text-accent rounded text-[10px] font-semibold align-middle">
ADMIN
</span>
)}
</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>
{user.status === 'ACTIVE' && (
<button
onClick={() => handleToggleAdmin(user.id, user.isAdmin)}
className={`px-2.5 py-1 rounded text-xs font-medium cursor-pointer ${
user.isAdmin
? 'bg-warning/10 text-warning hover:bg-warning/20'
: 'bg-info/10 text-info hover:bg-info/20'
}`}
>
{user.isAdmin ? '관리자 해제' : '관리자 지정'}
</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>
);
}