diff --git a/docs/MENU-TAB-GUIDE.md b/docs/MENU-TAB-GUIDE.md
new file mode 100644
index 0000000..a9d68eb
--- /dev/null
+++ b/docs/MENU-TAB-GUIDE.md
@@ -0,0 +1,186 @@
+# WING 메뉴 탭 추가 가이드
+
+새로운 메뉴 탭을 추가할 때 필요한 절차를 설명합니다.
+
+## 메뉴 시스템 구조
+
+```
+DB: AUTH_SETTING (menu.config JSON)
+ ↕ GET/PUT /api/menus
+Backend: settingsService.ts (DEFAULT_MENU_CONFIG, VALID_MENU_IDS)
+ ↕ API
+Frontend: menuStore.ts → TopBar.tsx (탭 렌더링)
+ → App.tsx (renderView 라우팅)
+```
+
+- **DB**가 메뉴 정의의 단일 소스 (id, label, icon, enabled, order)
+- **TopBar**는 `enabled && hasPermission` 조건으로 탭을 필터링하고 `order` 순 정렬
+- **App.tsx**의 `renderView`가 탭 ID에 따라 뷰 컴포넌트를 매핑
+- **admin** 탭은 메뉴 관리 대상에서 제외 (TopBar에서 별도 아이콘 버튼으로 접근)
+
+## 수정 파일 요약
+
+| 순서 | 파일 | 작업 | 필수 |
+|------|------|------|------|
+| 1 | `frontend/src/components/views/XxxView.tsx` | 뷰 컴포넌트 생성 | O |
+| 2 | `frontend/src/App.tsx` | MainTab 타입 + import + renderView | O |
+| 3 | `backend/src/settings/settingsService.ts` | DEFAULT_MENU_CONFIG에 항목 추가 | O |
+| 4 | `database/auth_init.sql` | menu.config 초기 JSON에 추가 | O |
+| 5 | 관리자 UI | 메뉴 관리에서 활성화 | O |
+
+## Step 1: 뷰 컴포넌트 생성
+
+`frontend/src/components/views/` 에 새 뷰 컴포넌트를 생성합니다.
+
+```tsx
+// frontend/src/components/views/MonitoringView.tsx
+
+export function MonitoringView() {
+ return (
+
+
+
실시간 모니터링
+ {/* 뷰 콘텐츠 */}
+
+
+ )
+}
+```
+
+기존 뷰 컴포넌트(`OilSpillView`, `WeatherView` 등)의 레이아웃 패턴을 참고하세요.
+
+## Step 2: App.tsx 탭 등록
+
+3가지를 수정합니다.
+
+### 2-1. MainTab 타입에 ID 추가
+
+```tsx
+// frontend/src/App.tsx (line 20)
+
+// Before
+export type MainTab = 'prediction' | 'hns' | ... | 'admin'
+
+// After
+export type MainTab = 'prediction' | 'hns' | ... | 'monitoring' | 'admin'
+```
+
+### 2-2. 뷰 컴포넌트 import
+
+```tsx
+import { MonitoringView } from './components/views/MonitoringView'
+```
+
+### 2-3. renderView switch에 case 추가
+
+```tsx
+const renderView = () => {
+ switch (activeMainTab) {
+ // ... 기존 case들 ...
+ case 'monitoring':
+ return
+ // ...
+ }
+}
+```
+
+## Step 3: 백엔드 메뉴 설정 등록
+
+`backend/src/settings/settingsService.ts`의 `DEFAULT_MENU_CONFIG` 배열에 항목을 추가합니다.
+
+```typescript
+const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
+ // ... 기존 10개 메뉴 ...
+ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
+]
+```
+
+`VALID_MENU_IDS`는 `DEFAULT_MENU_CONFIG`에서 자동 파생되므로 별도 수정 불필요합니다.
+
+```typescript
+const VALID_MENU_IDS = DEFAULT_MENU_CONFIG.map(m => m.id) // 자동 포함됨
+```
+
+> **주의**: `updateMenuConfig()`은 `VALID_MENU_IDS.length` 개수 전체가 포함되어야 저장을 허용합니다.
+> 기존 운영 DB에 새 메뉴가 없는 상태에서도 `getMenuConfig()`의 fallback이 DEFAULT_MENU_CONFIG을 반환하므로 정상 동작합니다.
+
+## Step 4: DB 초기 데이터 업데이트
+
+`database/auth_init.sql`의 `menu.config` 초기 JSON에 새 항목을 추가합니다.
+
+```sql
+INSERT INTO AUTH_SETTING (SETTING_KEY, SETTING_VAL, SETTING_DC, MDFCN_DTM) VALUES
+('menu.config', '[
+ {"id":"prediction","label":"유출유 확산예측","icon":"🛢️","enabled":true,"order":1},
+ ...기존 메뉴들...
+ {"id":"monitoring","label":"실시간 모니터링","icon":"📡","enabled":true,"order":11}
+]', '메뉴 구성 설정', NOW())
+ON CONFLICT (SETTING_KEY) DO NOTHING;
+```
+
+> **참고**: 이 SQL은 신규 설치 시에만 적용됩니다. 기존 운영 DB는 관리자 UI에서 메뉴를 관리합니다.
+
+## Step 5: 관리자 메뉴 관리에서 활성화
+
+코드 배포 후:
+1. 관리자 계정으로 로그인
+2. 관리자 패널(⚙️) → 메뉴 관리 탭
+3. 새 메뉴가 목록에 표시됨
+4. 활성/비활성 토글, 순서, 라벨, 아이콘을 설정
+5. "변경사항 저장" 클릭
+
+> 기존 DB에 새 메뉴 ID가 없으면 `getMenuConfig()`가 DEFAULT_MENU_CONFIG fallback을 사용하여 새 메뉴가 자동으로 목록에 나타납니다.
+
+## 실전 예시: "모니터링" 탭 추가
+
+### 1. 뷰 컴포넌트 생성
+
+```bash
+# frontend/src/components/views/MonitoringView.tsx 파일 생성
+```
+
+### 2. App.tsx 수정 (3곳)
+
+```diff
++ import { MonitoringView } from './components/views/MonitoringView'
+
+- export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'admin'
++ export type MainTab = 'prediction' | 'hns' | 'rescue' | 'reports' | 'aerial' | 'assets' | 'scat' | 'incidents' | 'board' | 'weather' | 'monitoring' | 'admin'
+
+ const renderView = () => {
+ switch (activeMainTab) {
+ // ...
++ case 'monitoring':
++ return
+ case 'admin':
+ return
+ }
+ }
+```
+
+### 3. settingsService.ts 수정
+
+```diff
+ const DEFAULT_MENU_CONFIG: MenuConfigItem[] = [
+ // ... 기존 메뉴들 ...
+ { id: 'incidents', label: '통합조회', icon: '🔍', enabled: true, order: 10 },
++ { id: 'monitoring', label: '실시간 모니터링', icon: '📡', enabled: true, order: 11 },
+ ]
+```
+
+### 4. auth_init.sql 수정
+
+menu.config JSON에 새 항목 추가 (신규 설치용)
+
+### 5. 배포 후 관리자 UI에서 활성화
+
+## 체크리스트
+
+- [ ] 뷰 컴포넌트 생성 (`frontend/src/components/views/`)
+- [ ] `MainTab` 타입 업데이트 (`App.tsx`)
+- [ ] import 및 renderView switch case 추가 (`App.tsx`)
+- [ ] `DEFAULT_MENU_CONFIG`에 추가 (`settingsService.ts`)
+- [ ] `menu.config` 초기 JSON 업데이트 (`auth_init.sql`)
+- [ ] TypeScript 컴파일 통과 (`cd frontend && npx tsc --noEmit`)
+- [ ] ESLint 통과 (`cd frontend && npx eslint .`)
+- [ ] 관리자 메뉴 관리에서 새 메뉴 표시 확인
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 1f62c2b..e5e0d34 100755
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@react-oauth/google": "^0.13.4",
@@ -337,6 +340,59 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/@emoji-mart/data": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
@@ -4478,6 +4534,12 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9bfc380..c659318 100755
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@react-oauth/google": "^0.13.4",
diff --git a/frontend/src/components/views/AdminView.tsx b/frontend/src/components/views/AdminView.tsx
index 5ed51d7..a0d7bc6 100755
--- a/frontend/src/components/views/AdminView.tsx
+++ b/frontend/src/components/views/AdminView.tsx
@@ -2,6 +2,24 @@ import { useState, useEffect, useCallback, useRef } from 'react'
import { useSubMenu } from '../../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,
@@ -719,6 +737,160 @@ function PermissionsPanel() {
)
}
+// ─── 메뉴 항목 (Sortable) ────────────────────────────────────
+interface SortableMenuItemProps {
+ menu: MenuConfigItem
+ idx: number
+ totalCount: number
+ isEditing: boolean
+ emojiPickerId: string | null
+ emojiPickerRef: React.RefObject
+ 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 (
+
+
+
+
{idx + 1}
+ {isEditing ? (
+ <>
+
+
+ {emojiPickerId === menu.id && (
+
+
+
+ )}
+
+
+
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"
+ />
+
{menu.id}
+
+
+ >
+ ) : (
+ <>
+
{menu.icon}
+
+
+ {menu.label}
+
+
{menu.id}
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+ )
+}
+
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() {
const [menus, setMenus] = useState([])
@@ -727,11 +899,17 @@ function MenusPanel() {
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState(null)
const [emojiPickerId, setEmojiPickerId] = useState(null)
+ const [activeId, setActiveId] = useState(null)
const emojiPickerRef = useRef(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 {
@@ -785,6 +963,18 @@ function MenusPanel() {
})
}
+ 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 {
@@ -807,6 +997,8 @@ function MenusPanel() {
)
}
+ const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
+
return (
@@ -828,113 +1020,44 @@ function MenusPanel() {
-
- {menus.map((menu, idx) => {
- const isEditing = editingId === menu.id
- return (
-
-
-
{idx + 1}
- {isEditing ? (
- <>
-
-
- {emojiPickerId === menu.id && (
-
-
-
- )}
-
-
-
updateMenuField(menu.id, 'label', 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"
- />
-
{menu.id}
-
-
- >
- ) : (
- <>
-
{menu.icon}
-
-
- {menu.label}
-
-
{menu.id}
-
-
- >
- )}
-
-
-
-
-
-
-
-
+
setActiveId(event.active.id as string)}
+ onDragEnd={handleDragEnd}
+ >
+ m.id)} strategy={verticalListSortingStrategy}>
+
+ {menus.map((menu, idx) => (
+ { setEditingId(null); setEmojiPickerId(null) }}
+ onEmojiPickerToggle={setEmojiPickerId}
+ onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
+ onEmojiSelect={handleEmojiSelect}
+ />
+ ))}
+
+
+
+ {activeMenu ? (
+
+ ⠿
+ {activeMenu.icon}
+ {activeMenu.label}
- )
- })}
-
+ ) : null}
+
+
)