- 11개 탭 디렉토리 생성: tabs/{prediction,hns,rescue,weather,incidents,aerial,board,reports,assets,scat,admin}/
- 51개 컴포넌트를 역할 기반(views/, analysis/, layout/) → 탭 기반(tabs/) 구조로 이동
- weather 탭에 전용 hooks/, services/ 포함
- incidents 탭에 전용 services/ 포함
- 공통 지도 컴포넌트(MapView, BacktrackReplay)를 common/components/map/으로 이동
- 각 탭에 index.ts 생성하여 View 컴포넌트 re-export
- App.tsx import를 @tabs/ alias 사용으로 변경
- 전체 import 경로 수정 (탭 내부 상대경로, 외부 @common/ alias)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1307 lines
57 KiB
TypeScript
Executable File
1307 lines
57 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||
import data from '@emoji-mart/data'
|
||
import EmojiPicker from '@emoji-mart/react'
|
||
import {
|
||
DndContext,
|
||
closestCenter,
|
||
KeyboardSensor,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
DragOverlay,
|
||
type DragEndEvent,
|
||
} from '@dnd-kit/core'
|
||
import {
|
||
arrayMove,
|
||
SortableContext,
|
||
sortableKeyboardCoordinates,
|
||
useSortable,
|
||
verticalListSortingStrategy,
|
||
} from '@dnd-kit/sortable'
|
||
import { CSS } from '@dnd-kit/utilities'
|
||
import {
|
||
fetchUsers,
|
||
fetchRoles,
|
||
updatePermissionsApi,
|
||
updateUserApi,
|
||
updateRoleDefaultApi,
|
||
approveUserApi,
|
||
rejectUserApi,
|
||
assignRolesApi,
|
||
createRoleApi,
|
||
updateRoleApi,
|
||
deleteRoleApi,
|
||
fetchRegistrationSettings,
|
||
updateRegistrationSettingsApi,
|
||
fetchOAuthSettings,
|
||
updateOAuthSettingsApi,
|
||
fetchMenuConfig,
|
||
updateMenuConfigApi,
|
||
type UserListItem,
|
||
type RoleWithPermissions,
|
||
type RegistrationSettings,
|
||
type OAuthSettings,
|
||
type MenuConfigItem,
|
||
} from '@common/services/authApi'
|
||
import { useMenuStore } from '@common/store/menuStore'
|
||
|
||
const DEFAULT_ROLE_COLORS: Record<string, string> = {
|
||
ADMIN: 'var(--red)',
|
||
MANAGER: 'var(--orange)',
|
||
USER: 'var(--cyan)',
|
||
VIEWER: 'var(--t3)',
|
||
}
|
||
|
||
const CUSTOM_ROLE_COLORS = [
|
||
'#a78bfa', '#34d399', '#f472b6', '#fbbf24', '#60a5fa', '#2dd4bf',
|
||
]
|
||
|
||
function getRoleColor(code: string, index: number): string {
|
||
return DEFAULT_ROLE_COLORS[code] || CUSTOM_ROLE_COLORS[index % CUSTOM_ROLE_COLORS.length]
|
||
}
|
||
|
||
const statusLabels: Record<string, { label: string; color: string; dot: string }> = {
|
||
PENDING: { label: '승인대기', color: 'text-yellow-400', dot: 'bg-yellow-400' },
|
||
ACTIVE: { label: '활성', color: 'text-green-400', dot: 'bg-green-400' },
|
||
LOCKED: { label: '잠김', color: 'text-red-400', dot: 'bg-red-400' },
|
||
INACTIVE: { label: '비활성', color: 'text-text-3', dot: 'bg-text-3' },
|
||
REJECTED: { label: '거절됨', color: 'text-red-300', dot: 'bg-red-300' },
|
||
}
|
||
|
||
|
||
// ─── 사용자 관리 패널 ─────────────────────────────────────────
|
||
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>
|
||
)
|
||
}
|
||
|
||
// ─── 권한 관리 패널 ─────────────────────────────────────────
|
||
const PERM_RESOURCES = [
|
||
{ id: 'prediction', label: '유출유 확산예측', desc: '확산 예측 실행 및 결과 조회' },
|
||
{ id: 'hns', label: 'HNS·대기확산', desc: '대기확산 분석 실행 및 조회' },
|
||
{ id: 'rescue', label: '긴급구난', desc: '구난 예측 실행 및 조회' },
|
||
{ id: 'reports', label: '보고자료', desc: '보고자료 생성 및 관리' },
|
||
{ id: 'aerial', label: '항공탐색', desc: '항공탐색 계획 및 결과 조회' },
|
||
{ id: 'assets', label: '방제자산 관리', desc: '방제자산 등록 및 관리' },
|
||
{ id: 'scat', label: '해안평가', desc: '해안 SCAT 조사 접근' },
|
||
{ id: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
|
||
{ id: 'board', label: '게시판', desc: '게시판 접근' },
|
||
{ id: 'weather', label: '기상정보', desc: '기상 정보 조회' },
|
||
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
|
||
]
|
||
|
||
function PermissionsPanel() {
|
||
const [roles, setRoles] = useState<RoleWithPermissions[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [dirty, setDirty] = useState(false)
|
||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||
const [newRoleCode, setNewRoleCode] = useState('')
|
||
const [newRoleName, setNewRoleName] = useState('')
|
||
const [newRoleDesc, setNewRoleDesc] = useState('')
|
||
const [creating, setCreating] = useState(false)
|
||
const [createError, setCreateError] = useState('')
|
||
const [editingRoleSn, setEditingRoleSn] = useState<number | null>(null)
|
||
const [editRoleName, setEditRoleName] = useState('')
|
||
|
||
useEffect(() => {
|
||
loadRoles()
|
||
}, [])
|
||
|
||
const loadRoles = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const data = await fetchRoles()
|
||
setRoles(data)
|
||
setDirty(false)
|
||
} catch (err) {
|
||
console.error('역할 목록 조회 실패:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const getPermGranted = (roleSn: number, resourceCode: string): boolean => {
|
||
const role = roles.find(r => r.sn === roleSn)
|
||
if (!role) return false
|
||
const perm = role.permissions.find(p => p.resourceCode === resourceCode)
|
||
return perm?.granted ?? false
|
||
}
|
||
|
||
const togglePerm = (roleSn: number, resourceCode: string) => {
|
||
setRoles(prev => prev.map(role => {
|
||
if (role.sn !== roleSn) return role
|
||
const perms = role.permissions.map(p =>
|
||
p.resourceCode === resourceCode ? { ...p, granted: !p.granted } : p
|
||
)
|
||
if (!perms.find(p => p.resourceCode === resourceCode)) {
|
||
perms.push({ sn: 0, resourceCode, granted: true })
|
||
}
|
||
return { ...role, permissions: perms }
|
||
}))
|
||
setDirty(true)
|
||
}
|
||
|
||
const toggleDefault = async (roleSn: number) => {
|
||
const role = roles.find(r => r.sn === roleSn)
|
||
if (!role) return
|
||
const newValue = !role.isDefault
|
||
try {
|
||
await updateRoleDefaultApi(roleSn, newValue)
|
||
setRoles(prev => prev.map(r =>
|
||
r.sn === roleSn ? { ...r, isDefault: newValue } : r
|
||
))
|
||
} catch (err) {
|
||
console.error('기본 역할 변경 실패:', err)
|
||
}
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
for (const role of roles) {
|
||
const permissions = PERM_RESOURCES.map(r => ({
|
||
resourceCode: r.id,
|
||
granted: getPermGranted(role.sn, r.id),
|
||
}))
|
||
await updatePermissionsApi(role.sn, permissions)
|
||
}
|
||
setDirty(false)
|
||
} catch (err) {
|
||
console.error('권한 저장 실패:', err)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const handleCreateRole = async () => {
|
||
setCreating(true)
|
||
setCreateError('')
|
||
try {
|
||
await createRoleApi({ code: newRoleCode, name: newRoleName, description: newRoleDesc || undefined })
|
||
await loadRoles()
|
||
setShowCreateForm(false)
|
||
setNewRoleCode('')
|
||
setNewRoleName('')
|
||
setNewRoleDesc('')
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : '역할 생성에 실패했습니다.'
|
||
setCreateError(message)
|
||
} finally {
|
||
setCreating(false)
|
||
}
|
||
}
|
||
|
||
const handleDeleteRole = async (roleSn: number, roleName: string) => {
|
||
if (!window.confirm(`"${roleName}" 역할을 삭제하시겠습니까?\n이 역할을 가진 모든 사용자에서 해당 역할이 제거됩니다.`)) {
|
||
return
|
||
}
|
||
try {
|
||
await deleteRoleApi(roleSn)
|
||
await loadRoles()
|
||
} catch (err) {
|
||
console.error('역할 삭제 실패:', err)
|
||
}
|
||
}
|
||
|
||
const handleStartEditName = (role: RoleWithPermissions) => {
|
||
setEditingRoleSn(role.sn)
|
||
setEditRoleName(role.name)
|
||
}
|
||
|
||
const handleSaveRoleName = async (roleSn: number) => {
|
||
if (!editRoleName.trim()) return
|
||
try {
|
||
await updateRoleApi(roleSn, { name: editRoleName.trim() })
|
||
setRoles(prev => prev.map(r =>
|
||
r.sn === roleSn ? { ...r, name: editRoleName.trim() } : r
|
||
))
|
||
setEditingRoleSn(null)
|
||
} catch (err) {
|
||
console.error('역할 이름 수정 실패:', err)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||
<div>
|
||
<h1 className="text-lg font-bold text-text-1 font-korean">사용자 권한 관리</h1>
|
||
<p className="text-xs text-text-3 mt-1 font-korean">역할별 메뉴 접근 권한을 설정합니다</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => { setShowCreateForm(true); setCreateError('') }}
|
||
className="px-4 py-2 text-xs font-semibold rounded-md border border-primary-cyan text-primary-cyan hover:bg-[rgba(6,182,212,0.08)] transition-all font-korean"
|
||
>
|
||
+ 역할 추가
|
||
</button>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!dirty || saving}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||
dirty ? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]' : 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{saving ? '저장 중...' : '변경사항 저장'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto">
|
||
<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 min-w-[200px]">기능</th>
|
||
{roles.map((role, idx) => {
|
||
const color = getRoleColor(role.code, idx)
|
||
return (
|
||
<th key={role.sn} className="px-4 py-3 text-center min-w-[100px]">
|
||
<div className="flex items-center justify-center gap-1">
|
||
{editingRoleSn === role.sn ? (
|
||
<input
|
||
type="text"
|
||
value={editRoleName}
|
||
onChange={(e) => setEditRoleName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleSaveRoleName(role.sn)
|
||
if (e.key === 'Escape') setEditingRoleSn(null)
|
||
}}
|
||
onBlur={() => handleSaveRoleName(role.sn)}
|
||
autoFocus
|
||
className="w-20 px-1 py-0.5 text-[11px] font-semibold bg-bg-2 border border-primary-cyan rounded text-center text-text-1 focus:outline-none font-korean"
|
||
/>
|
||
) : (
|
||
<span
|
||
className="text-[11px] font-semibold font-korean cursor-pointer hover:underline"
|
||
style={{ color }}
|
||
onClick={() => handleStartEditName(role)}
|
||
title="클릭하여 이름 수정"
|
||
>
|
||
{role.name}
|
||
</span>
|
||
)}
|
||
{role.code !== 'ADMIN' && (
|
||
<button
|
||
onClick={() => handleDeleteRole(role.sn, role.name)}
|
||
className="w-4 h-4 flex items-center justify-center text-text-3 hover:text-red-400 transition-colors"
|
||
title="역할 삭제"
|
||
>
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="text-[9px] text-text-3 font-mono mt-0.5">{role.code}</div>
|
||
<button
|
||
onClick={() => toggleDefault(role.sn)}
|
||
className={`mt-1 px-1.5 py-0.5 text-[9px] rounded transition-all font-korean ${
|
||
role.isDefault
|
||
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan border border-primary-cyan'
|
||
: 'text-text-3 border border-transparent hover:border-border'
|
||
}`}
|
||
title="신규 사용자에게 자동 할당되는 기본 역할"
|
||
>
|
||
{role.isDefault ? '기본역할' : '기본역할 설정'}
|
||
</button>
|
||
</th>
|
||
)
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{PERM_RESOURCES.map((perm) => (
|
||
<tr key={perm.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||
<td className="px-6 py-3">
|
||
<div className="text-[12px] text-text-1 font-semibold font-korean">{perm.label}</div>
|
||
<div className="text-[10px] text-text-3 font-korean mt-0.5">{perm.desc}</div>
|
||
</td>
|
||
{roles.map(role => (
|
||
<td key={role.sn} className="px-4 py-3 text-center">
|
||
<button
|
||
onClick={() => togglePerm(role.sn, perm.id)}
|
||
className={`w-8 h-8 rounded-md border text-sm transition-all ${
|
||
getPermGranted(role.sn, perm.id)
|
||
? 'bg-[rgba(6,182,212,0.15)] border-primary-cyan text-primary-cyan'
|
||
: 'bg-bg-2 border-border text-text-3 hover:border-text-3'
|
||
}`}
|
||
>
|
||
{getPermGranted(role.sn, perm.id) ? '✓' : '—'}
|
||
</button>
|
||
</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 역할 생성 모달 */}
|
||
{showCreateForm && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||
<div className="w-[400px] bg-bg-1 rounded-lg border border-border shadow-2xl">
|
||
<div className="px-5 py-4 border-b border-border">
|
||
<h3 className="text-sm font-bold text-text-1 font-korean">새 역할 추가</h3>
|
||
</div>
|
||
<div className="px-5 py-4 flex flex-col gap-3">
|
||
<div>
|
||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 코드</label>
|
||
<input
|
||
type="text"
|
||
value={newRoleCode}
|
||
onChange={(e) => setNewRoleCode(e.target.value.toUpperCase().replace(/[^A-Z0-9_]/g, ''))}
|
||
placeholder="CUSTOM_ROLE"
|
||
className="w-full 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-mono"
|
||
/>
|
||
<p className="text-[10px] text-text-3 mt-1 font-korean">영문 대문자, 숫자, 언더스코어만 허용 (생성 후 변경 불가)</p>
|
||
</div>
|
||
<div>
|
||
<label className="text-[11px] text-text-3 font-korean block mb-1">역할 이름</label>
|
||
<input
|
||
type="text"
|
||
value={newRoleName}
|
||
onChange={(e) => setNewRoleName(e.target.value)}
|
||
placeholder="사용자 정의 역할"
|
||
className="w-full 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"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-[11px] text-text-3 font-korean block mb-1">설명 (선택)</label>
|
||
<input
|
||
type="text"
|
||
value={newRoleDesc}
|
||
onChange={(e) => setNewRoleDesc(e.target.value)}
|
||
placeholder="역할에 대한 설명"
|
||
className="w-full 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"
|
||
/>
|
||
</div>
|
||
{createError && (
|
||
<div className="px-3 py-2 text-[11px] text-red-400 bg-[rgba(239,68,68,0.08)] border border-[rgba(239,68,68,0.2)] rounded-md font-korean">
|
||
{createError}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="px-5 py-3 border-t border-border flex justify-end gap-2">
|
||
<button
|
||
onClick={() => setShowCreateForm(false)}
|
||
className="px-4 py-2 text-xs text-text-3 border border-border rounded-md hover:bg-bg-hover font-korean"
|
||
>
|
||
취소
|
||
</button>
|
||
<button
|
||
onClick={handleCreateRole}
|
||
disabled={!newRoleCode || !newRoleName || creating}
|
||
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 disabled:opacity-50"
|
||
>
|
||
{creating ? '생성 중...' : '생성'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 메뉴 항목 (Sortable) ────────────────────────────────────
|
||
interface SortableMenuItemProps {
|
||
menu: MenuConfigItem
|
||
idx: number
|
||
totalCount: number
|
||
isEditing: boolean
|
||
emojiPickerId: string | null
|
||
emojiPickerRef: React.RefObject<HTMLDivElement | null>
|
||
onToggle: (id: string) => void
|
||
onMove: (idx: number, direction: -1 | 1) => void
|
||
onEditStart: (id: string) => void
|
||
onEditEnd: () => void
|
||
onEmojiPickerToggle: (id: string | null) => void
|
||
onLabelChange: (id: string, value: string) => void
|
||
onEmojiSelect: (emoji: { native: string }) => void
|
||
}
|
||
|
||
function SortableMenuItem({
|
||
menu, idx, totalCount, isEditing, emojiPickerId, emojiPickerRef,
|
||
onToggle, onMove, onEditStart, onEditEnd, onEmojiPickerToggle, onLabelChange, onEmojiSelect,
|
||
}: SortableMenuItemProps) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id: menu.id })
|
||
|
||
const style = {
|
||
transform: CSS.Transform.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.4 : 1,
|
||
zIndex: isDragging ? 50 : undefined,
|
||
}
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={style}
|
||
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
|
||
menu.enabled
|
||
? 'bg-bg-1 border-border'
|
||
: 'bg-bg-0 border-border opacity-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<button
|
||
{...attributes}
|
||
{...listeners}
|
||
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
|
||
title="드래그하여 순서 변경"
|
||
>
|
||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
|
||
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
|
||
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
|
||
</svg>
|
||
</button>
|
||
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
||
{isEditing ? (
|
||
<>
|
||
<div className="relative shrink-0">
|
||
<button
|
||
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
|
||
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
|
||
title="아이콘 변경"
|
||
>
|
||
{menu.icon}
|
||
</button>
|
||
{emojiPickerId === menu.id && (
|
||
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
|
||
<EmojiPicker
|
||
data={data}
|
||
onEmojiSelect={onEmojiSelect}
|
||
theme="dark"
|
||
locale="kr"
|
||
previewPosition="none"
|
||
skinTonePosition="search"
|
||
perLine={8}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<input
|
||
type="text"
|
||
value={menu.label}
|
||
onChange={(e) => onLabelChange(menu.id, e.target.value)}
|
||
className="w-full h-8 text-[13px] font-semibold font-korean bg-bg-2 border border-border rounded px-2 text-text-1 focus:border-primary-cyan focus:outline-none"
|
||
/>
|
||
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
|
||
</div>
|
||
<button
|
||
onClick={onEditEnd}
|
||
className="shrink-0 px-2 py-1 text-[10px] font-semibold text-primary-cyan border border-primary-cyan rounded hover:bg-[rgba(6,182,212,0.1)] transition-all font-korean"
|
||
>
|
||
완료
|
||
</button>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="text-[16px] shrink-0">{menu.icon}</span>
|
||
<div className="flex-1 min-w-0">
|
||
<div className={`text-[13px] font-semibold font-korean ${menu.enabled ? 'text-text-1' : 'text-text-3'}`}>
|
||
{menu.label}
|
||
</div>
|
||
<div className="text-[10px] text-text-3 font-mono">{menu.id}</div>
|
||
</div>
|
||
<button
|
||
onClick={() => onEditStart(menu.id)}
|
||
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
|
||
title="라벨/아이콘 편집"
|
||
>
|
||
✏️
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center gap-3 ml-3 shrink-0">
|
||
<button
|
||
onClick={() => onToggle(menu.id)}
|
||
className={`relative w-10 h-5 rounded-full transition-all ${
|
||
menu.enabled ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-all ${
|
||
menu.enabled ? 'left-[22px]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
<div className="flex gap-1">
|
||
<button
|
||
onClick={() => onMove(idx, -1)}
|
||
disabled={idx === 0}
|
||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
▲
|
||
</button>
|
||
<button
|
||
onClick={() => onMove(idx, 1)}
|
||
disabled={idx === totalCount - 1}
|
||
className="w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-xs flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
||
>
|
||
▼
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
|
||
function MenusPanel() {
|
||
const [menus, setMenus] = useState<MenuConfigItem[]>([])
|
||
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [editingId, setEditingId] = useState<string | null>(null)
|
||
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
|
||
const [activeId, setActiveId] = useState<string | null>(null)
|
||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||
const { setMenuConfig } = useMenuStore()
|
||
|
||
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
|
||
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||
)
|
||
|
||
const loadMenus = useCallback(async () => {
|
||
setLoading(true)
|
||
try {
|
||
const config = await fetchMenuConfig()
|
||
setMenus(config)
|
||
setOriginalMenus(config)
|
||
} catch (err) {
|
||
console.error('메뉴 설정 조회 실패:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
loadMenus()
|
||
}, [loadMenus])
|
||
|
||
useEffect(() => {
|
||
if (!emojiPickerId) return
|
||
const handler = (e: MouseEvent) => {
|
||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
|
||
setEmojiPickerId(null)
|
||
}
|
||
}
|
||
document.addEventListener('mousedown', handler)
|
||
return () => document.removeEventListener('mousedown', handler)
|
||
}, [emojiPickerId])
|
||
|
||
const toggleMenu = (id: string) => {
|
||
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
|
||
}
|
||
|
||
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
|
||
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
|
||
}
|
||
|
||
const handleEmojiSelect = (emoji: { native: string }) => {
|
||
if (emojiPickerId) {
|
||
updateMenuField(emojiPickerId, 'icon', emoji.native)
|
||
setEmojiPickerId(null)
|
||
}
|
||
}
|
||
|
||
const moveMenu = (idx: number, direction: -1 | 1) => {
|
||
const targetIdx = idx + direction
|
||
if (targetIdx < 0 || targetIdx >= menus.length) return
|
||
setMenus(prev => {
|
||
const arr = [...prev]
|
||
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
|
||
return arr.map((m, i) => ({ ...m, order: i + 1 }))
|
||
})
|
||
}
|
||
|
||
const handleDragEnd = (event: DragEndEvent) => {
|
||
const { active, over } = event
|
||
setActiveId(null)
|
||
if (!over || active.id === over.id) return
|
||
setMenus(prev => {
|
||
const oldIndex = prev.findIndex(m => m.id === active.id)
|
||
const newIndex = prev.findIndex(m => m.id === over.id)
|
||
const reordered = arrayMove(prev, oldIndex, newIndex)
|
||
return reordered.map((m, i) => ({ ...m, order: i + 1 }))
|
||
})
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true)
|
||
try {
|
||
const updated = await updateMenuConfigApi(menus)
|
||
setMenus(updated)
|
||
setOriginalMenus(updated)
|
||
setMenuConfig(updated)
|
||
} catch (err) {
|
||
console.error('메뉴 설정 저장 실패:', err)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-full">
|
||
<div className="text-text-3 text-sm font-korean">메뉴 설정을 불러오는 중...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||
<div>
|
||
<h1 className="text-lg font-bold text-text-1 font-korean">메뉴 관리</h1>
|
||
<p className="text-xs text-text-3 mt-1 font-korean">메뉴 표시 여부, 순서, 라벨, 아이콘을 관리합니다</p>
|
||
</div>
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={!hasChanges || saving}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
|
||
hasChanges && !saving
|
||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{saving ? '저장 중...' : '변경사항 저장'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto px-6 py-4">
|
||
<DndContext
|
||
sensors={sensors}
|
||
collisionDetection={closestCenter}
|
||
onDragStart={(event) => setActiveId(event.active.id as string)}
|
||
onDragEnd={handleDragEnd}
|
||
>
|
||
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
|
||
<div className="flex flex-col gap-2 max-w-[700px]">
|
||
{menus.map((menu, idx) => (
|
||
<SortableMenuItem
|
||
key={menu.id}
|
||
menu={menu}
|
||
idx={idx}
|
||
totalCount={menus.length}
|
||
isEditing={editingId === menu.id}
|
||
emojiPickerId={emojiPickerId}
|
||
emojiPickerRef={emojiPickerRef}
|
||
onToggle={toggleMenu}
|
||
onMove={moveMenu}
|
||
onEditStart={setEditingId}
|
||
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
|
||
onEmojiPickerToggle={setEmojiPickerId}
|
||
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
|
||
onEmojiSelect={handleEmojiSelect}
|
||
/>
|
||
))}
|
||
</div>
|
||
</SortableContext>
|
||
<DragOverlay>
|
||
{activeMenu ? (
|
||
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
|
||
<span className="text-text-3 text-xs">⠿</span>
|
||
<span className="text-[16px]">{activeMenu.icon}</span>
|
||
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
|
||
</div>
|
||
) : null}
|
||
</DragOverlay>
|
||
</DndContext>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── 시스템 설정 패널 ────────────────────────────────────────
|
||
function SettingsPanel() {
|
||
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
|
||
const [oauthSettings, setOauthSettings] = useState<OAuthSettings | null>(null)
|
||
const [oauthDomainInput, setOauthDomainInput] = useState('')
|
||
const [loading, setLoading] = useState(true)
|
||
const [saving, setSaving] = useState(false)
|
||
const [savingOAuth, setSavingOAuth] = useState(false)
|
||
|
||
useEffect(() => {
|
||
loadSettings()
|
||
}, [])
|
||
|
||
const loadSettings = async () => {
|
||
setLoading(true)
|
||
try {
|
||
const [regData, oauthData] = await Promise.all([
|
||
fetchRegistrationSettings(),
|
||
fetchOAuthSettings(),
|
||
])
|
||
setSettings(regData)
|
||
setOauthSettings(oauthData)
|
||
setOauthDomainInput(oauthData.autoApproveDomains)
|
||
} catch (err) {
|
||
console.error('설정 조회 실패:', err)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const handleToggle = async (key: keyof RegistrationSettings) => {
|
||
if (!settings) return
|
||
const newValue = !settings[key]
|
||
setSaving(true)
|
||
try {
|
||
const updated = await updateRegistrationSettingsApi({ [key]: newValue })
|
||
setSettings(updated)
|
||
} catch (err) {
|
||
console.error('설정 변경 실패:', err)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
if (loading) {
|
||
return <div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">불러오는 중...</div>
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<div className="px-6 py-4 border-b border-border">
|
||
<h1 className="text-lg font-bold text-text-1 font-korean">시스템 설정</h1>
|
||
<p className="text-xs text-text-3 mt-1 font-korean">사용자 등록 및 권한 관련 시스템 설정을 관리합니다</p>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto px-6 py-6">
|
||
<div className="max-w-[640px] flex flex-col gap-6">
|
||
{/* 사용자 등록 설정 */}
|
||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||
<div className="px-5 py-3 border-b border-border">
|
||
<h2 className="text-sm font-bold text-text-1 font-korean">사용자 등록 설정</h2>
|
||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">신규 사용자 등록 시 적용되는 정책을 설정합니다</p>
|
||
</div>
|
||
|
||
<div className="divide-y divide-border">
|
||
{/* 자동 승인 */}
|
||
<div className="px-5 py-4 flex items-center justify-between">
|
||
<div className="flex-1 mr-4">
|
||
<div className="text-[13px] font-semibold text-text-1 font-korean">자동 승인</div>
|
||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||
활성화하면 신규 사용자가 등록 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||
비활성화하면 관리자 승인 전까지 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 대기합니다.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => handleToggle('autoApprove')}
|
||
disabled={saving}
|
||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||
settings?.autoApprove ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||
} ${saving ? 'opacity-50' : ''}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||
settings?.autoApprove ? 'left-[26px]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 기본 역할 자동 할당 */}
|
||
<div className="px-5 py-4 flex items-center justify-between">
|
||
<div className="flex-1 mr-4">
|
||
<div className="text-[13px] font-semibold text-text-1 font-korean">기본 역할 자동 할당</div>
|
||
<p className="text-[11px] text-text-3 mt-1 font-korean leading-relaxed">
|
||
활성화하면 신규 사용자에게 <span className="text-primary-cyan font-semibold">기본 역할</span>이 자동으로 할당됩니다.
|
||
기본 역할은 권한 관리 탭에서 설정할 수 있습니다.
|
||
</p>
|
||
</div>
|
||
<button
|
||
onClick={() => handleToggle('defaultRole')}
|
||
disabled={saving}
|
||
className={`relative w-12 h-6 rounded-full transition-all flex-shrink-0 ${
|
||
settings?.defaultRole ? 'bg-primary-cyan' : 'bg-bg-3 border border-border'
|
||
} ${saving ? 'opacity-50' : ''}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all ${
|
||
settings?.defaultRole ? 'left-[26px]' : 'left-0.5'
|
||
}`}
|
||
/>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* OAuth 설정 */}
|
||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||
<div className="px-5 py-3 border-b border-border">
|
||
<h2 className="text-sm font-bold text-text-1 font-korean">Google OAuth 설정</h2>
|
||
<p className="text-[11px] text-text-3 mt-0.5 font-korean">Google 계정 로그인 시 자동 승인할 이메일 도메인을 설정합니다</p>
|
||
</div>
|
||
<div className="px-5 py-4">
|
||
<div className="flex-1 mr-4 mb-3">
|
||
<div className="text-[13px] font-semibold text-text-1 font-korean mb-1">자동 승인 도메인</div>
|
||
<p className="text-[11px] text-text-3 font-korean leading-relaxed mb-3">
|
||
지정된 도메인의 Google 계정은 가입 즉시 <span className="text-green-400 font-semibold">ACTIVE</span> 상태가 됩니다.
|
||
미지정 도메인은 <span className="text-yellow-400 font-semibold">PENDING</span> 상태로 관리자 승인이 필요합니다.
|
||
여러 도메인은 쉼표(,)로 구분합니다.
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={oauthDomainInput}
|
||
onChange={(e) => setOauthDomainInput(e.target.value)}
|
||
placeholder="gcsc.co.kr, example.com"
|
||
className="flex-1 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-mono"
|
||
/>
|
||
<button
|
||
onClick={async () => {
|
||
setSavingOAuth(true)
|
||
try {
|
||
const updated = await updateOAuthSettingsApi({ autoApproveDomains: oauthDomainInput.trim() })
|
||
setOauthSettings(updated)
|
||
setOauthDomainInput(updated.autoApproveDomains)
|
||
} catch (err) {
|
||
console.error('OAuth 설정 변경 실패:', err)
|
||
} finally {
|
||
setSavingOAuth(false)
|
||
}
|
||
}}
|
||
disabled={savingOAuth || oauthDomainInput.trim() === (oauthSettings?.autoApproveDomains || '')}
|
||
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean whitespace-nowrap ${
|
||
oauthDomainInput.trim() !== (oauthSettings?.autoApproveDomains || '')
|
||
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
|
||
: 'bg-bg-3 text-text-3 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{savingOAuth ? '저장 중...' : '저장'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{oauthSettings?.autoApproveDomains && (
|
||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||
{oauthSettings.autoApproveDomains.split(',').map(d => d.trim()).filter(Boolean).map(domain => (
|
||
<span
|
||
key={domain}
|
||
className="inline-flex items-center gap-1 px-2 py-1 text-[10px] font-mono rounded-md"
|
||
style={{ background: 'rgba(6,182,212,0.1)', color: 'var(--cyan)', border: '1px solid rgba(6,182,212,0.25)' }}
|
||
>
|
||
@{domain}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 현재 설정 상태 요약 */}
|
||
<div className="rounded-lg border border-border bg-bg-1 overflow-hidden">
|
||
<div className="px-5 py-3 border-b border-border">
|
||
<h2 className="text-sm font-bold text-text-1 font-korean">설정 상태 요약</h2>
|
||
</div>
|
||
<div className="px-5 py-4">
|
||
<div className="flex flex-col gap-3 text-[12px] font-korean">
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full ${settings?.autoApprove ? 'bg-green-400' : 'bg-yellow-400'}`} />
|
||
<span className="text-text-2">
|
||
신규 사용자 등록 시{' '}
|
||
{settings?.autoApprove ? (
|
||
<span className="text-green-400 font-semibold">즉시 활성화</span>
|
||
) : (
|
||
<span className="text-yellow-400 font-semibold">관리자 승인 필요</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full ${settings?.defaultRole ? 'bg-green-400' : 'bg-text-3'}`} />
|
||
<span className="text-text-2">
|
||
기본 역할 자동 할당{' '}
|
||
{settings?.defaultRole ? (
|
||
<span className="text-green-400 font-semibold">활성</span>
|
||
) : (
|
||
<span className="text-text-3 font-semibold">비활성</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className={`w-2 h-2 rounded-full ${oauthSettings?.autoApproveDomains ? 'bg-blue-400' : 'bg-text-3'}`} />
|
||
<span className="text-text-2">
|
||
Google OAuth 자동 승인 도메인{' '}
|
||
{oauthSettings?.autoApproveDomains ? (
|
||
<span className="text-blue-400 font-semibold font-mono">{oauthSettings.autoApproveDomains}</span>
|
||
) : (
|
||
<span className="text-text-3 font-semibold">미설정</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ─── AdminView ────────────────────────────────────────────
|
||
export function AdminView() {
|
||
const { activeSubTab } = useSubMenu('admin')
|
||
|
||
return (
|
||
<div className="flex flex-1 overflow-hidden bg-bg-0">
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{activeSubTab === 'users' && <UsersPanel />}
|
||
{activeSubTab === 'permissions' && <PermissionsPanel />}
|
||
{activeSubTab === 'menus' && <MenusPanel />}
|
||
{activeSubTab === 'settings' && <SettingsPanel />}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|