From 660591446b26d22b374c0cee1dfe25c568d6247d Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 02:46:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=EB=A9=94=EB=89=B4=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EB=93=9C=EB=9E=98=EA=B7=B8=EC=95=A4=EB=93=9C?= =?UTF-8?q?=EB=A1=AD=20=EC=88=9C=EC=84=9C=20=EB=B3=80=EA=B2=BD=20+=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=20=ED=83=AD=20=EC=B6=94=EA=B0=80=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EB=AC=B8=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @dnd-kit/core, @dnd-kit/sortable로 드래그앤드롭 순서 변경 지원 - SortableMenuItem 컴포넌트 분리, 드래그 핸들(grip) + DragOverlay 프리뷰 - 기존 UP/DOWN 버튼 유지 (드래그와 병행 사용) - docs/MENU-TAB-GUIDE.md: 새 메뉴 탭 추가 시 수정 파일 및 절차 가이드 Co-Authored-By: Claude Opus 4.6 --- docs/MENU-TAB-GUIDE.md | 186 +++++++++++ frontend/package-lock.json | 62 ++++ frontend/package.json | 3 + frontend/src/components/views/AdminView.tsx | 335 +++++++++++++------- 4 files changed, 480 insertions(+), 106 deletions(-) create mode 100644 docs/MENU-TAB-GUIDE.md 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} + +
) -- 2.45.2