- frontend: ESLint 에러 86건 수정 (unused-vars, set-state-in-effect, static-components 등) - backend: simulation.ts req.params 타입 단언 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
654 lines
28 KiB
TypeScript
Executable File
654 lines
28 KiB
TypeScript
Executable File
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSubMenu } from '../../hooks/useSubMenu'
|
|
import {
|
|
fetchUsers,
|
|
fetchRoles,
|
|
updatePermissionsApi,
|
|
updateUserApi,
|
|
updateRoleDefaultApi,
|
|
approveUserApi,
|
|
rejectUserApi,
|
|
fetchRegistrationSettings,
|
|
updateRegistrationSettingsApi,
|
|
type UserListItem,
|
|
type RoleWithPermissions,
|
|
type RegistrationSettings,
|
|
} from '../../services/authApi'
|
|
|
|
const roleLabels: Record<string, { label: string; color: string }> = {
|
|
ADMIN: { label: '관리자', color: 'var(--red)' },
|
|
MANAGER: { label: '매니저', color: 'var(--orange)' },
|
|
USER: { label: '사용자', color: 'var(--cyan)' },
|
|
VIEWER: { label: '뷰어', color: 'var(--t3)' },
|
|
}
|
|
|
|
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' },
|
|
}
|
|
|
|
const mockMenus = [
|
|
{ id: 'prediction', label: '유출유 확산예측', enabled: true, order: 1 },
|
|
{ id: 'hns', label: 'HNS·대기확산', enabled: true, order: 2 },
|
|
{ id: 'rescue', label: '긴급구난', enabled: true, order: 3 },
|
|
{ id: 'reports', label: '보고자료', enabled: true, order: 4 },
|
|
{ id: 'aerial', label: '항공탐색', enabled: true, order: 5 },
|
|
{ id: 'assets', label: '방제자산 관리', enabled: true, order: 6 },
|
|
{ id: 'scat', label: 'Pre-SCAT', enabled: false, order: 7 },
|
|
{ id: 'incidents', label: '사고조회', enabled: true, order: 8 },
|
|
{ id: 'board', label: '게시판', enabled: true, order: 9 },
|
|
{ id: 'weather', label: '기상정보', enabled: true, order: 10 },
|
|
]
|
|
|
|
// ─── 사용자 관리 패널 ─────────────────────────────────────────
|
|
function UsersPanel() {
|
|
const [searchTerm, setSearchTerm] = useState('')
|
|
const [statusFilter, setStatusFilter] = useState<string>('')
|
|
const [users, setUsers] = useState<UserListItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
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])
|
|
|
|
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 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-right text-[11px] font-semibold text-text-3 font-korean">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((user) => {
|
|
const primaryRole = user.roles[0] || 'USER'
|
|
const roleInfo = roleLabels[primaryRole] || roleLabels.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">
|
|
<span
|
|
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
|
style={{
|
|
background: `${roleInfo.color}20`,
|
|
color: roleInfo.color,
|
|
border: `1px solid ${roleInfo.color}40`
|
|
}}
|
|
>
|
|
{roleInfo.label}
|
|
</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>
|
|
)}
|
|
<button className="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>
|
|
</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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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>
|
|
<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 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 => {
|
|
const info = roleLabels[role.code] || { label: role.name, color: 'var(--t3)' }
|
|
return (
|
|
<th key={role.sn} className="px-6 py-3 text-center min-w-[100px]">
|
|
<div className="text-[11px] font-semibold font-korean" style={{ color: info.color }}>
|
|
{info.label}
|
|
</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-6 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>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
|
|
function MenusPanel() {
|
|
const [menus, setMenus] = useState(mockMenus)
|
|
|
|
const toggleMenu = (id: string) => {
|
|
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
|
|
}
|
|
|
|
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 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 className="flex-1 overflow-auto px-6 py-4">
|
|
<div className="flex flex-col gap-2 max-w-[600px]">
|
|
{menus.map((menu, idx) => (
|
|
<div
|
|
key={menu.id}
|
|
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-4">
|
|
<span className="text-text-3 text-xs font-mono w-6 text-center">{idx + 1}</span>
|
|
<div>
|
|
<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>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => toggleMenu(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={() => {
|
|
if (idx === 0) return
|
|
setMenus(prev => {
|
|
const arr = [...prev]
|
|
;[arr[idx - 1], arr[idx]] = [arr[idx], arr[idx - 1]]
|
|
return arr.map((m, i) => ({ ...m, order: i + 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"
|
|
>
|
|
▲
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
if (idx === menus.length - 1) return
|
|
setMenus(prev => {
|
|
const arr = [...prev]
|
|
;[arr[idx], arr[idx + 1]] = [arr[idx + 1], arr[idx]]
|
|
return arr.map((m, i) => ({ ...m, order: i + 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"
|
|
>
|
|
▼
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 시스템 설정 패널 ────────────────────────────────────────
|
|
function SettingsPanel() {
|
|
const [settings, setSettings] = useState<RegistrationSettings | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [saving, setSaving] = useState(false)
|
|
|
|
useEffect(() => {
|
|
loadSettings()
|
|
}, [])
|
|
|
|
const loadSettings = async () => {
|
|
setLoading(true)
|
|
try {
|
|
const data = await fetchRegistrationSettings()
|
|
setSettings(data)
|
|
} 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>
|
|
|
|
{/* 현재 설정 상태 요약 */}
|
|
<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>
|
|
</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>
|
|
)
|
|
}
|