1085 lines
46 KiB
TypeScript
1085 lines
46 KiB
TypeScript
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;
|