wing-ops/frontend/src/tabs/admin/components/UsersPanel.tsx

1085 lines
46 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react';
import {
fetchUsers,
fetchRoles,
fetchOrgs,
createUserApi,
updateUserApi,
changePasswordApi,
approveUserApi,
rejectUserApi,
assignRolesApi,
type UserListItem,
type RoleWithPermissions,
type OrgItem,
} from '@common/services/authApi';
import { getRoleColor, statusLabels } from './adminConstants';
const PAGE_SIZE = 15;
// ─── 포맷 헬퍼 ─────────────────────────────────────────────────
function formatDate(dateStr: string | null) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
// ─── 사용자 등록 모달 ───────────────────────────────────────────
interface RegisterModalProps {
allRoles: RoleWithPermissions[];
allOrgs: OrgItem[];
onClose: () => void;
onSuccess: () => void;
}
function RegisterModal({ allRoles, allOrgs, onClose, onSuccess }: RegisterModalProps) {
const [account, setAccount] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [rank, setRank] = useState('');
const [orgSn, setOrgSn] = useState<number | ''>(() => {
const defaultOrg = allOrgs.find((o) => o.orgNm === '기동방제과');
return defaultOrg ? defaultOrg.orgSn : '';
});
const [email, setEmail] = useState('');
const [roleSns, setRoleSns] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const toggleRole = (sn: number) => {
setRoleSns((prev) => (prev.includes(sn) ? prev.filter((s) => s !== sn) : [...prev, sn]));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!account.trim() || !password.trim() || !name.trim()) {
setError('계정, 비밀번호, 사용자명은 필수 항목입니다.');
return;
}
setSubmitting(true);
setError(null);
try {
await createUserApi({
account: account.trim(),
password,
name: name.trim(),
rank: rank.trim() || undefined,
orgSn: orgSn !== '' ? orgSn : undefined,
roleSns: roleSns.length > 0 ? roleSns : undefined,
});
onSuccess();
onClose();
} catch (err) {
setError('사용자 등록에 실패했습니다.');
console.error('사용자 등록 실패:', err);
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
{/* 폼 */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-4">
{/* 계정 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={account}
onChange={(e) => setAccount(e.target.value)}
placeholder="로그인 계정 ID"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 비밀번호 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="초기 비밀번호"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 사용자명 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
<span className="text-red-400">*</span>
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="실명"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{/* 직급 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
type="text"
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장, 주임 등"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
{/* 소속 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}
{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
{/* 이메일 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일 주소"
className="w-full px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
{/* 역할 */}
<div>
<label className="block text-[11px] font-semibold text-fg-sub font-korean mb-1.5">
</label>
<div className="bg-bg-elevated border border-stroke rounded-md p-2 space-y-1 max-h-[120px] overflow-y-auto">
{allRoles.length === 0 ? (
<p className="text-[10px] text-fg-disabled font-korean px-1 py-1"> </p>
) : (
allRoles.map((role, idx) => {
const color = getRoleColor(role.code, idx);
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={roleSns.includes(role.sn)}
onChange={() => toggleRole(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-[10px] text-fg-disabled font-mono">{role.code}</span>
</label>
);
})
)}
</div>
</div>
{/* 에러 메시지 */}
{error && <p className="text-[11px] text-red-400 font-korean">{error}</p>}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-stroke">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{submitting ? '등록 중...' : '등록'}
</button>
</div>
</form>
</div>
</div>
);
}
// ─── 사용자 상세/수정 모달 ────────────────────────────────────
interface UserDetailModalProps {
user: UserListItem;
allRoles: RoleWithPermissions[];
allOrgs: OrgItem[];
onClose: () => void;
onUpdated: () => void;
}
function UserDetailModal({ user, allOrgs, onClose, onUpdated }: UserDetailModalProps) {
const [name, setName] = useState(user.name);
const [rank, setRank] = useState(user.rank || '');
const [orgSn, setOrgSn] = useState<number | ''>(user.orgSn ?? '');
const [saving, setSaving] = useState(false);
const [newPassword, setNewPassword] = useState('');
const [resetPwLoading, setResetPwLoading] = useState(false);
const [resetPwDone, setResetPwDone] = useState(false);
const [unlockLoading, setUnlockLoading] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const handleSaveInfo = async () => {
setSaving(true);
setMessage(null);
try {
await updateUserApi(user.id, {
name: name.trim(),
rank: rank.trim() || undefined,
orgSn: orgSn !== '' ? orgSn : null,
});
setMessage({ text: '사용자 정보가 수정되었습니다.', type: 'success' });
onUpdated();
} catch {
setMessage({ text: '사용자 정보 수정에 실패했습니다.', type: 'error' });
} finally {
setSaving(false);
}
};
const handleResetPassword = async () => {
if (!newPassword.trim()) {
setMessage({ text: '새 비밀번호를 입력하세요.', type: 'error' });
return;
}
setResetPwLoading(true);
setMessage(null);
try {
await changePasswordApi(user.id, newPassword);
setMessage({ text: '비밀번호가 초기화되었습니다.', type: 'success' });
setResetPwDone(true);
setNewPassword('');
} catch {
setMessage({ text: '비밀번호 초기화에 실패했습니다.', type: 'error' });
} finally {
setResetPwLoading(false);
}
};
const handleUnlock = async () => {
setUnlockLoading(true);
setMessage(null);
try {
await updateUserApi(user.id, { status: 'ACTIVE' });
setMessage({ text: '계정 잠금이 해제되었습니다.', type: 'success' });
onUpdated();
} catch {
setMessage({ text: '잠금 해제에 실패했습니다.', type: 'error' });
} finally {
setUnlockLoading(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-bg-surface border border-stroke rounded-lg shadow-lg w-[480px] max-h-[90vh] flex flex-col">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div>
<h2 className="text-sm font-bold text-fg font-korean"> </h2>
<p className="text-[10px] text-fg-disabled font-mono mt-0.5">{user.account}</p>
</div>
<button onClick={onClose} className="text-fg-disabled hover:text-fg transition-colors">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5">
{/* 기본 정보 수정 */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">
</h3>
<div className="space-y-3">
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
</label>
<input
type="text"
value={rank}
onChange={(e) => setRank(e.target.value)}
placeholder="예: 팀장"
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
</div>
<div>
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
</label>
<select
value={orgSn}
onChange={(e) => setOrgSn(e.target.value !== '' ? Number(e.target.value) : '')}
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={org.orgSn}>
{org.orgNm}
{org.orgAbbrNm ? ` (${org.orgAbbrNm})` : ''}
</option>
))}
</select>
</div>
</div>
<button
onClick={handleSaveInfo}
disabled={saving || !name.trim()}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all disabled:opacity-50 font-korean"
>
{saving ? '저장 중...' : '정보 저장'}
</button>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-stroke" />
{/* 비밀번호 초기화 */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-3">
</h3>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="block text-[10px] text-fg-disabled font-korean mb-1">
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="새 비밀번호 입력"
className="w-full px-3 py-1.5 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-mono"
/>
</div>
<button
onClick={handleResetPassword}
disabled={resetPwLoading || !newPassword.trim()}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-yellow-400 text-yellow-400 hover:bg-[rgba(250,204,21,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{resetPwLoading ? '초기화 중...' : resetPwDone ? '초기화 완료' : '비밀번호 초기화'}
</button>
<button
onClick={handleUnlock}
disabled={unlockLoading || user.status !== 'LOCKED'}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
title={user.status !== 'LOCKED' ? '잠금 상태가 아닙니다' : ''}
>
{unlockLoading ? '해제 중...' : '패스워드잠금해제'}
</button>
</div>
<p className="text-[9px] text-fg-disabled font-korean mt-1.5">
.
</p>
</div>
{/* 구분선 */}
<div className="border-t border-stroke" />
{/* 계정 잠금 해제 */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2"> </h3>
<div className="flex items-center justify-between bg-bg-elevated border border-stroke rounded-md px-4 py-3">
<div>
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center gap-1.5 text-[11px] font-semibold font-korean ${(statusLabels[user.status] || statusLabels.INACTIVE).color}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${(statusLabels[user.status] || statusLabels.INACTIVE).dot}`}
/>
{(statusLabels[user.status] || statusLabels.INACTIVE).label}
</span>
{user.failCount > 0 && (
<span className="text-[10px] text-red-400 font-korean">
( {user.failCount})
</span>
)}
</div>
{user.status === 'LOCKED' && (
<p className="text-[9px] text-fg-disabled font-korean mt-1">
5
</p>
)}
</div>
{user.status === 'LOCKED' && (
<button
onClick={handleUnlock}
disabled={unlockLoading}
className="px-4 py-1.5 text-[11px] font-semibold rounded-md border border-green-400 text-green-400 hover:bg-[rgba(74,222,128,0.1)] transition-all disabled:opacity-50 font-korean flex-shrink-0"
>
{unlockLoading ? '해제 중...' : '잠금 해제'}
</button>
)}
</div>
</div>
{/* 기타 정보 (읽기 전용) */}
<div>
<h3 className="text-[11px] font-semibold text-fg-sub font-korean mb-2"> </h3>
<div className="grid grid-cols-2 gap-2 text-[10px] font-korean">
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">: </span>
<span className="text-fg-sub font-mono">{user.email || '-'}</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">OAuth: </span>
<span className="text-fg-sub">{user.oauthProvider || '-'}</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled"> : </span>
<span className="text-fg-sub">
{user.lastLogin ? formatDate(user.lastLogin) : '-'}
</span>
</div>
<div className="bg-bg-elevated border border-stroke rounded px-3 py-2">
<span className="text-fg-disabled">: </span>
<span className="text-fg-sub">{formatDate(user.regDtm)}</span>
</div>
</div>
</div>
{/* 메시지 */}
{message && (
<div
className={`px-3 py-2 text-[11px] rounded-md font-korean ${
message.type === 'success'
? 'text-green-400 bg-[rgba(74,222,128,0.08)] border border-[rgba(74,222,128,0.2)]'
: 'text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)]'
}`}
>
{message.text}
</div>
)}
</div>
{/* 푸터 */}
<div className="flex items-center justify-end px-6 py-3 border-t border-stroke">
<button
onClick={onClose}
className="px-4 py-2 text-xs border border-stroke text-fg-sub rounded-md hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
</div>
</div>
</div>
);
}
// ─── 사용자 관리 패널 ─────────────────────────────────────────
function UsersPanel() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('');
const [orgFilter, setOrgFilter] = useState<string>('');
const [users, setUsers] = useState<UserListItem[]>([]);
const [loading, setLoading] = useState(true);
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([]);
const [allOrgs, setAllOrgs] = useState<OrgItem[]>([]);
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null);
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([]);
const [showRegisterModal, setShowRegisterModal] = useState(false);
const [detailUser, setDetailUser] = useState<UserListItem | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const roleDropdownRef = useRef<HTMLDivElement>(null);
const loadUsers = useCallback(async () => {
setLoading(true);
try {
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined);
setUsers(data);
setCurrentPage(1);
} catch (err) {
console.error('사용자 목록 조회 실패:', err);
} finally {
setLoading(false);
}
}, [searchTerm, statusFilter]);
useEffect(() => {
loadUsers();
}, [loadUsers]);
useEffect(() => {
fetchRoles().then(setAllRoles).catch(console.error);
fetchOrgs().then(setAllOrgs).catch(console.error);
}, []);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (roleDropdownRef.current && !roleDropdownRef.current.contains(e.target as Node)) {
setRoleEditUserId(null);
}
};
if (roleEditUserId) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [roleEditUserId]);
// ─── 필터링 (org 클라이언트 사이드) ───────────────────────────
const filteredUsers = orgFilter ? users.filter((u) => String(u.orgSn) === orgFilter) : users;
// ─── 페이지네이션 ──────────────────────────────────────────────
const totalCount = filteredUsers.length;
const totalPages = Math.max(1, Math.ceil(totalCount / PAGE_SIZE));
const pagedUsers = filteredUsers.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
// ─── 액션 핸들러 ──────────────────────────────────────────────
const handleUnlock = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'ACTIVE' });
await loadUsers();
} catch (err) {
console.error('계정 잠금 해제 실패:', err);
}
};
const handleApprove = async (userId: string) => {
try {
await approveUserApi(userId);
await loadUsers();
} catch (err) {
console.error('사용자 승인 실패:', err);
}
};
const handleReject = async (userId: string) => {
try {
await rejectUserApi(userId);
await loadUsers();
} catch (err) {
console.error('사용자 거절 실패:', err);
}
};
const handleDeactivate = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'INACTIVE' });
await loadUsers();
} catch (err) {
console.error('사용자 비활성화 실패:', err);
}
};
const handleActivate = async (userId: string) => {
try {
await updateUserApi(userId, { status: 'ACTIVE' });
await loadUsers();
} catch (err) {
console.error('사용자 활성화 실패:', err);
}
};
const handleOpenRoleEdit = (user: UserListItem) => {
setRoleEditUserId(user.id);
setSelectedRoleSns(user.roleSns || []);
};
const toggleRoleSelection = (roleSn: number) => {
setSelectedRoleSns((prev) =>
prev.includes(roleSn) ? prev.filter((s) => s !== roleSn) : [...prev, roleSn],
);
};
const handleSaveRoles = async (userId: string) => {
try {
await assignRolesApi(userId, selectedRoleSns);
await loadUsers();
setRoleEditUserId(null);
} catch (err) {
console.error('역할 할당 실패:', err);
}
};
const pendingCount = users.filter((u) => u.status === 'PENDING').length;
return (
<>
<div className="flex flex-col h-full">
{/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-stroke">
<div className="flex items-center gap-3">
<div>
<h1 className="text-lg font-bold text-fg font-korean"> </h1>
<p className="text-xs text-fg-disabled mt-1 font-korean">
{filteredUsers.length}
</p>
</div>
{pendingCount > 0 && (
<span className="px-2.5 py-1 text-[10px] font-bold rounded-full bg-[rgba(250,204,21,0.15)] text-yellow-400 border border-[rgba(250,204,21,0.3)] animate-pulse font-korean">
{pendingCount}
</span>
)}
</div>
<div className="flex items-center gap-3">
{/* 소속 필터 */}
<select
value={orgFilter}
onChange={(e) => {
setOrgFilter(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
{allOrgs.map((org) => (
<option key={org.orgSn} value={String(org.orgSn)}>
{org.orgAbbrNm || org.orgNm}
</option>
))}
</select>
{/* 상태 필터 */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg focus:border-color-accent focus:outline-none font-korean"
>
<option value=""> </option>
<option value="PENDING"></option>
<option value="ACTIVE"></option>
<option value="LOCKED"></option>
<option value="INACTIVE"></option>
<option value="REJECTED"></option>
</select>
{/* 텍스트 검색 */}
<input
type="text"
placeholder="이름, 계정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-56 px-3 py-2 text-xs bg-bg-elevated border border-stroke rounded-md text-fg placeholder-fg-disabled focus:border-color-accent focus:outline-none font-korean"
/>
<button
onClick={() => setShowRegisterModal(true)}
className="px-4 py-2 text-xs font-semibold rounded-md bg-color-accent text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)] transition-all font-korean"
>
+
</button>
</div>
</div>
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32 text-fg-disabled text-sm font-korean">
...
</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-stroke bg-bg-surface">
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean w-10">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-mono">
ID
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-left text-[11px] font-semibold text-fg-disabled font-korean">
</th>
<th className="px-4 py-3 text-right text-[11px] font-semibold text-fg-disabled font-korean">
</th>
</tr>
</thead>
<tbody>
{pagedUsers.length === 0 ? (
<tr>
<td
colSpan={9}
className="px-6 py-10 text-center text-xs text-fg-disabled font-korean"
>
.
</td>
</tr>
) : (
pagedUsers.map((user, idx) => {
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE;
const rowNum = (currentPage - 1) * PAGE_SIZE + idx + 1;
return (
<tr
key={user.id}
className="border-b border-stroke hover:bg-[rgba(255,255,255,0.02)] transition-colors"
>
{/* 번호 */}
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono text-center">
{rowNum}
</td>
{/* ID(account) */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-mono">
{user.account}
</td>
{/* 사용자명 */}
<td className="px-4 py-3">
<button
onClick={() => setDetailUser(user)}
className="text-[12px] text-color-accent font-semibold font-korean hover:underline"
>
{user.name}
</button>
</td>
{/* 직급 */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
{user.rank || '-'}
</td>
{/* 소속 */}
<td className="px-4 py-3 text-[12px] text-fg-sub font-korean">
{user.orgAbbr || user.orgName || '-'}
</td>
{/* 이메일 */}
<td className="px-4 py-3 text-[11px] text-fg-disabled font-mono">
{user.email || '-'}
</td>
{/* 역할 (인라인 편집) */}
<td className="px-4 py-3">
<div className="relative">
<div
className="flex flex-wrap gap-1 cursor-pointer"
onClick={() => handleOpenRoleEdit(user)}
title="클릭하여 역할 변경"
>
{user.roles.length > 0 ? (
user.roles.map((roleCode, roleIdx) => {
const color = getRoleColor(roleCode, roleIdx);
const roleName =
allRoles.find((r) => r.code === roleCode)?.name || roleCode;
return (
<span
key={roleCode}
className="px-2 py-0.5 text-[10px] font-semibold rounded-md font-korean"
style={{
background: `${color}20`,
color: color,
border: `1px solid ${color}40`,
}}
>
{roleName}
</span>
);
})
) : (
<span className="text-[10px] text-fg-disabled font-korean">
</span>
)}
<span className="text-[10px] text-fg-disabled ml-0.5">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</span>
</div>
{roleEditUserId === user.id && (
<div
ref={roleDropdownRef}
className="absolute z-20 top-full left-0 mt-1 p-2 bg-bg-surface border border-stroke rounded-lg shadow-lg min-w-[200px]"
>
<div className="text-[10px] text-fg-disabled font-korean font-semibold mb-1.5 px-1">
</div>
{allRoles.map((role, roleIdx) => {
const color = getRoleColor(role.code, roleIdx);
return (
<label
key={role.sn}
className="flex items-center gap-2 px-2 py-1.5 hover:bg-[rgba(255,255,255,0.04)] rounded cursor-pointer"
>
<input
type="checkbox"
checked={selectedRoleSns.includes(role.sn)}
onChange={() => toggleRoleSelection(role.sn)}
style={{ accentColor: color }}
/>
<span className="text-xs font-korean" style={{ color }}>
{role.name}
</span>
<span className="text-[10px] text-fg-disabled font-mono">
{role.code}
</span>
</label>
);
})}
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-stroke">
<button
onClick={() => setRoleEditUserId(null)}
className="px-3 py-1 text-[10px] text-fg-disabled border border-stroke rounded hover:bg-bg-surface-hover font-korean"
>
</button>
<button
onClick={() => handleSaveRoles(user.id)}
disabled={selectedRoleSns.length === 0}
className="px-3 py-1 text-[10px] font-semibold rounded bg-color-accent text-bg-0 hover:shadow-[0_0_8px_rgba(6,182,212,0.3)] disabled:opacity-50 font-korean"
>
</button>
</div>
</div>
)}
</div>
</td>
{/* 승인상태 */}
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${statusInfo.color}`}
>
<span className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot}`} />
{statusInfo.label}
</span>
</td>
{/* 관리 */}
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{user.status === 'PENDING' && (
<>
<button
onClick={() => handleApprove(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
<button
onClick={() => handleReject(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-red-400 border border-red-400 rounded hover:bg-[rgba(248,113,113,0.1)] transition-all font-korean"
>
</button>
</>
)}
{user.status === 'LOCKED' && (
<button
onClick={() => handleUnlock(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-yellow-400 border border-yellow-400 rounded hover:bg-[rgba(250,204,21,0.1)] transition-all font-korean"
>
</button>
)}
{user.status === 'ACTIVE' && (
<button
onClick={() => handleDeactivate(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-fg-disabled border border-stroke rounded hover:bg-[rgba(255,255,255,0.04)] transition-all font-korean"
>
</button>
)}
{(user.status === 'INACTIVE' || user.status === 'REJECTED') && (
<button
onClick={() => handleActivate(user.id)}
className="px-2 py-1 text-[10px] font-semibold text-green-400 border border-green-400 rounded hover:bg-[rgba(74,222,128,0.1)] transition-all font-korean"
>
</button>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
)}
</div>
{/* 페이지네이션 */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between px-6 py-3 border-t border-stroke bg-bg-surface">
<span className="text-[11px] text-fg-disabled font-korean">
{(currentPage - 1) * PAGE_SIZE + 1}{Math.min(currentPage * PAGE_SIZE, totalCount)} /{' '}
{totalCount}
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | '...')[]>((acc, p, i, arr) => {
if (
i > 0 &&
typeof arr[i - 1] === 'number' &&
(p as number) - (arr[i - 1] as number) > 1
) {
acc.push('...');
}
acc.push(p);
return acc;
}, [])
.map((item, i) =>
item === '...' ? (
<span key={`ellipsis-${i}`} className="px-2 text-[11px] text-fg-disabled">
</span>
) : (
<button
key={item}
onClick={() => setCurrentPage(item as number)}
className="px-2.5 py-1 text-[11px] border rounded transition-all font-mono"
style={
currentPage === item
? {
borderColor: 'var(--color-accent)',
color: 'var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
}
: { borderColor: 'var(--border)', color: 'var(--fg-disabled)' }
}
>
{item}
</button>
),
)}
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-2.5 py-1 text-[11px] border border-stroke text-fg-disabled rounded hover:bg-[rgba(255,255,255,0.04)] disabled:opacity-40 transition-all font-korean"
>
</button>
</div>
</div>
)}
</div>
{/* 사용자 등록 모달 */}
{showRegisterModal && (
<RegisterModal
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setShowRegisterModal(false)}
onSuccess={loadUsers}
/>
)}
{/* 사용자 상세/수정 모달 */}
{detailUser && (
<UserDetailModal
user={detailUser}
allRoles={allRoles}
allOrgs={allOrgs}
onClose={() => setDetailUser(null)}
onUpdated={() => {
loadUsers();
// 최신 정보로 모달 갱신을 위해 닫지 않음
}}
/>
)}
</>
);
}
export default UsersPanel;