snp-connection-monitoring/frontend/src/pages/admin/UsersPage.tsx
HYOJIN 88e25abe14 feat(frontend): 디자인 시스템 적용 및 전체 UI 개선 (#42)
- 디자인 시스템 CSS 변수 토큰 적용 (success/warning/danger/info)
- PeriodFilter 공통 컴포넌트 생성 및 통계 페이지 적용
- SERVICE_BADGE_VARIANTS 공통 상수 추출
- 통계/요청로그/키관리/관리자 페이지 레퍼런스 디자인 반영
- 테이블 규격 통일 (h-8/h-7, px-3 py-1, text-xs, Button xs)
- 타이틀 아이콘 전체 페이지 통일
- 카드 테두리 디자인 통일 (border + rounded-xl)
- FHD 1920x1080 최적화
2026-04-17 14:45:27 +09:00

338 lines
15 KiB
TypeScript

import { useState, useEffect } from 'react';
import type { UserDetail, CreateUserRequest, UpdateUserRequest } from '../../types/user';
import type { Tenant } from '../../types/tenant';
import { getUsers, createUser, updateUser, deactivateUser } from '../../services/userService';
import { getTenants } from '../../services/tenantService';
import Button from '../../components/ui/Button';
import Badge from '../../components/ui/Badge';
import type { BadgeVariant } from '../../components/ui/Badge';
const ROLE_BADGE_VARIANT: Record<string, BadgeVariant> = {
ADMIN: 'danger',
MANAGER: 'warning',
USER: 'blue',
VIEWER: 'default',
};
const UsersPage = () => {
const [users, setUsers] = useState<UserDetail[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserDetail | null>(null);
const [loginId, setLoginId] = useState('');
const [password, setPassword] = useState('');
const [userName, setUserName] = useState('');
const [email, setEmail] = useState('');
const [tenantId, setTenantId] = useState<string>('');
const [role, setRole] = useState('USER');
const [isActive, setIsActive] = useState(true);
const fetchData = async () => {
try {
setLoading(true);
const [usersRes, tenantsRes] = await Promise.all([getUsers(), getTenants()]);
if (usersRes.success && usersRes.data) {
setUsers(usersRes.data);
}
if (tenantsRes.success && tenantsRes.data) {
setTenants(tenantsRes.data);
}
} catch {
setError('데이터를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
const handleOpenCreate = () => {
setEditingUser(null);
setLoginId('');
setPassword('');
setUserName('');
setEmail('');
setTenantId('');
setRole('USER');
setIsActive(true);
setIsModalOpen(true);
};
const handleOpenEdit = (user: UserDetail) => {
setEditingUser(user);
setLoginId(user.loginId);
setPassword('');
setUserName(user.userName);
setEmail(user.email || '');
setTenantId(user.tenantId ? String(user.tenantId) : '');
setRole(user.role);
setIsActive(user.isActive);
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingUser(null);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
try {
if (editingUser) {
const req: UpdateUserRequest = {
tenantId: tenantId ? Number(tenantId) : undefined,
userName,
email: email || undefined,
role,
password: password || undefined,
isActive,
};
const res = await updateUser(editingUser.userId, req);
if (!res.success) {
setError(res.message || '사용자 수정에 실패했습니다.');
return;
}
} else {
const req: CreateUserRequest = {
tenantId: tenantId ? Number(tenantId) : undefined,
loginId,
password,
userName,
email: email || undefined,
role,
};
const res = await createUser(req);
if (!res.success) {
setError(res.message || '사용자 생성에 실패했습니다.');
return;
}
}
handleCloseModal();
await fetchData();
} catch {
setError(editingUser ? '사용자 수정에 실패했습니다.' : '사용자 생성에 실패했습니다.');
}
};
const handleDeactivate = async (user: UserDetail) => {
if (!confirm(`'${user.userName}' 사용자를 비활성화하시겠습니까?`)) return;
try {
const res = await deactivateUser(user.userId);
if (!res.success) {
setError(res.message || '사용자 비활성화에 실패했습니다.');
return;
}
await fetchData();
} catch {
setError('사용자 비활성화에 실패했습니다.');
}
};
if (loading) {
return <div className="max-w-7xl mx-auto text-center py-10 text-[var(--color-text-secondary)]"> ...</div>;
}
return (
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-[var(--color-primary-subtle)] flex items-center justify-center">
<svg className="w-5 h-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}>
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /><circle cx="9" cy="7" r="4" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Users</h1>
<p className="text-sm text-[var(--color-text-secondary)]"> </p>
</div>
</div>
<Button onClick={handleOpenCreate} size="sm">Create User</Button>
</div>
{error && !isModalOpen && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div className="overflow-x-auto bg-[var(--color-bg-surface)] border border-[var(--color-border)] rounded-xl">
<table className="w-full divide-y divide-[var(--color-border)] text-xs">
<thead className="bg-[var(--color-bg-base)]">
<tr className="h-8">
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Login ID</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Email</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Tenant</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Role</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Active</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Last Login</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wider text-[var(--color-text-secondary)]">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--color-border)]">
{users.map((user) => (
<tr key={user.userId} className="h-7 hover:bg-[var(--color-bg-base)]">
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.loginId}</td>
<td className="px-3 py-1 text-[var(--color-text-primary)]">{user.userName}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.email || '-'}</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">{user.tenantName || '-'}</td>
<td className="px-3 py-1">
<Badge variant={ROLE_BADGE_VARIANT[user.role] ?? 'default'} size="sm">
{user.role}
</Badge>
</td>
<td className="px-3 py-1">
<Badge variant={user.isActive ? 'success' : 'danger'} size="sm">
{user.isActive ? 'Active' : 'Inactive'}
</Badge>
</td>
<td className="px-3 py-1 text-[var(--color-text-secondary)]">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleString()
: '-'}
</td>
<td className="px-3 py-1">
<div className="flex items-center gap-2">
<Button variant="outline" size="xs" onClick={() => handleOpenEdit(user)}>
</Button>
{user.isActive && (
<Button variant="danger" size="xs" onClick={() => handleDeactivate(user)}>
</Button>
)}
</div>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-[var(--color-text-tertiary)]">
.
</td>
</tr>
)}
</tbody>
</table>
</div>
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--color-bg-surface)] rounded-lg shadow-xl w-full max-w-md mx-4">
<div className="px-6 py-4 border-b border-[var(--color-border)]">
<h2 className="text-lg font-semibold text-[var(--color-text-primary)]">
{editingUser ? '사용자 수정' : '사용자 생성'}
</h2>
</div>
<form onSubmit={handleSubmit}>
<div className="px-6 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{error && (
<div className="p-3 bg-red-50 text-red-700 rounded-lg text-sm">{error}</div>
)}
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Login ID</label>
<input
type="text"
value={loginId}
onChange={(e) => setLoginId(e.target.value)}
disabled={!!editingUser}
required
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none disabled:opacity-50"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required={!editingUser}
placeholder={editingUser ? '변경 시 입력' : ''}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">User Name</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
required
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Tenant</label>
<select
value={tenantId}
onChange={(e) => setTenantId(e.target.value)}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
>
<option value="">-- --</option>
{tenants.map((t) => (
<option key={t.tenantId} value={t.tenantId}>
{t.tenantName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--color-text-primary)] mb-1">Role</label>
<select
value={role}
onChange={(e) => setRole(e.target.value)}
className="w-full border border-[var(--color-border-strong)] bg-[var(--color-bg-surface)] text-[var(--color-text-primary)] rounded-lg px-3 py-2 focus:ring-2 focus:ring-[var(--color-primary)] focus:outline-none"
>
<option value="ADMIN">ADMIN</option>
<option value="MANAGER">MANAGER</option>
<option value="USER">USER</option>
<option value="VIEWER">VIEWER</option>
</select>
</div>
{editingUser && (
<div className="flex items-center gap-2">
<input
type="checkbox"
id="isActive"
checked={isActive}
onChange={(e) => setIsActive(e.target.checked)}
className="rounded"
/>
<label htmlFor="isActive" className="text-sm text-[var(--color-text-primary)]">
Active
</label>
</div>
)}
</div>
<div className="px-6 py-4 border-t border-[var(--color-border)] flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCloseModal}>
Cancel
</Button>
<Button type="submit">Save</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default UsersPage;