wing-ops/frontend/src/components/views/AdminView.tsx
htlee fb556fad9e chore: 프로젝트 초기 구성
- 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>
2026-02-27 11:06:21 +09:00

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>
)
}