- frontend: React 19 + Vite 7 + Leaflet + Tailwind + Zustand - backend: Express + better-sqlite3 + TypeScript - database: PostgreSQL 초기화 스크립트 - .gitignore: 대용량 참고자료(scat, 참고용) 및 바이너리 파일 제외 - .env.example: API 키 템플릿 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
312 lines
14 KiB
TypeScript
Executable File
312 lines
14 KiB
TypeScript
Executable File
import { useState } from 'react'
|
|
import { useSubMenu } from '../../hooks/useSubMenu'
|
|
|
|
// Mock data
|
|
const mockUsers = [
|
|
{ id: 1, name: '김해양', email: 'kim@kosg.go.kr', dept: '방제기술과', role: 'admin', status: 'active', lastLogin: '2026-02-16 09:30' },
|
|
{ id: 2, name: '이방제', email: 'lee@kosg.go.kr', dept: '해양환경과', role: 'manager', status: 'active', lastLogin: '2026-02-15 14:20' },
|
|
{ id: 3, name: '박구난', email: 'park@kosg.go.kr', dept: '긴급대응팀', role: 'user', status: 'active', lastLogin: '2026-02-16 08:15' },
|
|
{ id: 4, name: '최분석', email: 'choi@kosg.go.kr', dept: '방제기술과', role: 'user', status: 'active', lastLogin: '2026-02-14 16:45' },
|
|
{ id: 5, name: '정예측', email: 'jung@kosg.go.kr', dept: '해양환경과', role: 'user', status: 'inactive', lastLogin: '2026-01-20 11:00' },
|
|
{ id: 6, name: '한기상', email: 'han@kosg.go.kr', dept: '기상관측팀', role: 'viewer', status: 'active', lastLogin: '2026-02-16 07:50' },
|
|
]
|
|
|
|
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 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 filtered = mockUsers.filter(u =>
|
|
u.name.includes(searchTerm) || u.email.includes(searchTerm) || u.dept.includes(searchTerm)
|
|
)
|
|
|
|
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">총 {mockUsers.length}명</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<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">
|
|
<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>
|
|
{filtered.map((user) => (
|
|
<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.email}</td>
|
|
<td className="px-6 py-3 text-[12px] text-text-2 font-korean">{user.dept}</td>
|
|
<td className="px-6 py-3">
|
|
<span
|
|
className="px-2 py-1 text-[10px] font-semibold rounded-md font-korean"
|
|
style={{
|
|
background: `${roleLabels[user.role].color}20`,
|
|
color: roleLabels[user.role].color,
|
|
border: `1px solid ${roleLabels[user.role].color}40`
|
|
}}
|
|
>
|
|
{roleLabels[user.role].label}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<span className={`inline-flex items-center gap-1.5 text-[10px] font-semibold font-korean ${
|
|
user.status === 'active' ? 'text-green-400' : 'text-text-3'
|
|
}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
user.status === 'active' ? 'bg-green-400' : 'bg-text-3'
|
|
}`} />
|
|
{user.status === 'active' ? '활성' : '비활성'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-[11px] text-text-3 font-mono">{user.lastLogin}</td>
|
|
<td className="px-6 py-3 text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<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>
|
|
<button className="px-2 py-1 text-[10px] font-semibold text-status-red border border-status-red rounded hover:bg-[rgba(239,68,68,0.1)] transition-all font-korean">
|
|
삭제
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PermissionsPanel() {
|
|
const roles = ['admin', 'manager', 'user', 'viewer']
|
|
const permissions = [
|
|
{ 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: 'incidents', label: '사고조회', desc: '사고 정보 등록 및 조회' },
|
|
{ id: 'admin', label: '관리자 설정', desc: '시스템 관리 기능 접근' },
|
|
]
|
|
|
|
const [matrix, setMatrix] = useState<Record<string, Record<string, boolean>>>(() => {
|
|
const m: Record<string, Record<string, boolean>> = {}
|
|
permissions.forEach(p => {
|
|
m[p.id] = {
|
|
admin: true,
|
|
manager: p.id !== 'admin',
|
|
user: !['admin', 'assets'].includes(p.id),
|
|
viewer: !['admin', 'assets', 'reports'].includes(p.id),
|
|
}
|
|
})
|
|
return m
|
|
})
|
|
|
|
const toggle = (permId: string, role: string) => {
|
|
setMatrix(prev => ({
|
|
...prev,
|
|
[permId]: { ...prev[permId], [role]: !prev[permId][role] }
|
|
}))
|
|
}
|
|
|
|
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">
|
|
<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 => (
|
|
<th key={role} className="px-6 py-3 text-center text-[11px] font-semibold font-korean min-w-[100px]" style={{ color: roleLabels[role].color }}>
|
|
{roleLabels[role].label}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{permissions.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} className="px-6 py-3 text-center">
|
|
<button
|
|
onClick={() => toggle(perm.id, role)}
|
|
className={`w-8 h-8 rounded-md border text-sm transition-all ${
|
|
matrix[perm.id]?.[role]
|
|
? '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'
|
|
}`}
|
|
>
|
|
{matrix[perm.id]?.[role] ? '✓' : '—'}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
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 />}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|