6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를 서브탭 단위로 분할하여 모듈 경계를 명확히 함. - AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등 - AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등 - ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등 - PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등 - AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등 - LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등 FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및 감사로그 서브탭 추적 훅(useFeatureTracking) 추가. .gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
351 lines
17 KiB
TypeScript
351 lines
17 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import {
|
|
fetchUsers,
|
|
fetchRoles,
|
|
updateUserApi,
|
|
approveUserApi,
|
|
rejectUserApi,
|
|
assignRolesApi,
|
|
type UserListItem,
|
|
type RoleWithPermissions,
|
|
} from '@common/services/authApi'
|
|
import { getRoleColor, statusLabels } from './adminConstants'
|
|
|
|
// ─── 사용자 관리 패널 ─────────────────────────────────────────
|
|
function UsersPanel() {
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
const [users, setUsers] = useState<UserListItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [allRoles, setAllRoles] = useState<RoleWithPermissions[]>([])
|
|
const [roleEditUserId, setRoleEditUserId] = useState<string | null>(null)
|
|
const [selectedRoleSns, setSelectedRoleSns] = useState<number[]>([])
|
|
const roleDropdownRef = useRef<HTMLDivElement>(null)
|
|
|
|
const loadUsers = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchUsers(searchTerm || undefined, statusFilter || undefined)
|
|
setUsers(data)
|
|
} catch (err) {
|
|
console.error('사용자 목록 조회 실패:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [searchTerm, statusFilter])
|
|
|
|
useEffect(() => {
|
|
loadUsers()
|
|
}, [loadUsers])
|
|
|
|
useEffect(() => {
|
|
fetchRoles().then(setAllRoles).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])
|
|
|
|
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 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',
|
|
})
|
|
}
|
|
|
|
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-border">
|
|
<div className="flex items-center gap-3">
|
|
<div>
|
|
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 관리</h1>
|
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {users.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={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan 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-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean"
|
|
/>
|
|
<button className="px-4 py-2 text-xs font-semibold rounded-md bg-primary-cyan 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-text-3 text-sm font-korean">불러오는 중...</div>
|
|
) : (
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-border bg-bg-1">
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">이름</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">계정</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">소속</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">역할</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">인증</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">상태</th>
|
|
<th className="px-6 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">최근 로그인</th>
|
|
<th className="px-6 py-3 text-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((user) => {
|
|
const statusInfo = statusLabels[user.status] || statusLabels.INACTIVE
|
|
return (
|
|
<tr key={user.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
|
<td className="px-6 py-3 text-[12px] text-text-1 font-semibold font-korean">{user.name}</td>
|
|
<td className="px-6 py-3 text-[12px] text-text-2 font-mono">{user.account}</td>
|
|
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.orgAbbr || '-'}</td>
|
|
<td className="px-6 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, idx) => {
|
|
const color = getRoleColor(roleCode, idx)
|
|
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-text-3 font-korean">역할 없음</span>
|
|
)}
|
|
<span className="text-[10px] text-text-3 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-1 border border-border rounded-lg shadow-lg min-w-[200px]"
|
|
>
|
|
<div className="text-[10px] text-text-3 font-korean font-semibold mb-1.5 px-1">역할 선택</div>
|
|
{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={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-text-3 font-mono">{role.code}</span>
|
|
</label>
|
|
)
|
|
})}
|
|
<div className="flex justify-end gap-2 mt-2 pt-2 border-t border-border">
|
|
<button
|
|
onClick={() => setRoleEditUserId(null)}
|
|
className="px-3 py-1 text-[10px] text-text-3 border border-border rounded hover:bg-bg-hover font-korean"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={() => handleSaveRoles(user.id)}
|
|
disabled={selectedRoleSns.length === 0}
|
|
className="px-3 py-1 text-[10px] font-semibold rounded bg-primary-cyan 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-6 py-3">
|
|
{user.oauthProvider ? (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-mono"
|
|
style={{ background: 'rgba(66,133,244,0.15)', color: '#4285F4', border: '1px solid rgba(66,133,244,0.3)' }}
|
|
title={user.email || undefined}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 48 48"><path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/><path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/><path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 019.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 000 24c0 3.77.9 7.35 2.56 10.54l7.97-5.95z"/><path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.95C6.51 42.62 14.62 48 24 48z"/></svg>
|
|
Google
|
|
</span>
|
|
) : (
|
|
<span
|
|
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
|
style={{ background: 'rgba(148,163,184,0.15)', color: 'var(--t3)', border: '1px solid rgba(148,163,184,0.2)' }}
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>
|
|
ID/PW
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 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-6 py-3 text-[11px] text-text-3 font-mono">{formatDate(user.lastLogin)}</td>
|
|
<td className="px-6 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-text-3 border border-border 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default UsersPanel
|