feat(admin): 메뉴 관리 드래그앤드롭 + 가이드 문서 #14

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-02-28 02:49:18 +09:00
4개의 변경된 파일480개의 추가작업 그리고 106개의 파일을 삭제
Showing only changes of commit 9200d7fc5a - Show all commits

186
docs/MENU-TAB-GUIDE.md Normal file
파일 보기

@ -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 (
<div className="flex flex-1 overflow-hidden bg-bg-0">
<div className="flex-1 flex flex-col overflow-hidden p-6">
<h1 className="text-lg font-bold text-text-1 font-korean">실시간 모니터링</h1>
{/* 뷰 콘텐츠 */}
</div>
</div>
)
}
```
기존 뷰 컴포넌트(`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 <MonitoringView />
// ...
}
}
```
## 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 <MonitoringView />
case 'admin':
return <AdminView />
}
}
```
### 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 .`)
- [ ] 관리자 메뉴 관리에서 새 메뉴 표시 확인

파일 보기

@ -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",

파일 보기

@ -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",

파일 보기

@ -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<HTMLDivElement | null>
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 (
<div
ref={setNodeRef}
style={style}
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-3 flex-1 min-w-0">
<button
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing w-6 h-7 flex items-center justify-center text-text-3 hover:text-text-1 transition-all shrink-0"
title="드래그하여 순서 변경"
>
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="2" r="1.5" /><circle cx="9" cy="2" r="1.5" />
<circle cx="3" cy="8" r="1.5" /><circle cx="9" cy="8" r="1.5" />
<circle cx="3" cy="14" r="1.5" /><circle cx="9" cy="14" r="1.5" />
</svg>
</button>
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
{isEditing ? (
<>
<div className="relative shrink-0">
<button
onClick={() => onEmojiPickerToggle(emojiPickerId === menu.id ? null : menu.id)}
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
title="아이콘 변경"
>
{menu.icon}
</button>
{emojiPickerId === menu.id && (
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
<EmojiPicker
data={data}
onEmojiSelect={onEmojiSelect}
theme="dark"
locale="kr"
previewPosition="none"
skinTonePosition="search"
perLine={8}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={menu.label}
onChange={(e) => 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"
/>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
</div>
<button
onClick={onEditEnd}
className="shrink-0 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>
</>
) : (
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<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>
<button
onClick={() => onEditStart(menu.id)}
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
title="라벨/아이콘 편집"
>
</button>
</>
)}
</div>
<div className="flex items-center gap-3 ml-3 shrink-0">
<button
onClick={() => onToggle(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={() => onMove(idx, -1)}
disabled={idx === 0}
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 disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => onMove(idx, 1)}
disabled={idx === totalCount - 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 disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
)
}
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() {
const [menus, setMenus] = useState<MenuConfigItem[]>([])
@ -727,11 +899,17 @@ function MenusPanel() {
const [saving, setSaving] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
const [activeId, setActiveId] = useState<string | null>(null)
const emojiPickerRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
@ -828,113 +1020,44 @@ function MenusPanel() {
</div>
<div className="flex-1 overflow-auto px-6 py-4">
<div className="flex flex-col gap-2 max-w-[700px]">
{menus.map((menu, idx) => {
const isEditing = editingId === menu.id
return (
<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-3 flex-1 min-w-0">
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
{isEditing ? (
<>
<div className="relative shrink-0">
<button
onClick={() => setEmojiPickerId(emojiPickerId === menu.id ? null : menu.id)}
className="w-10 h-10 text-[20px] bg-bg-2 border border-border rounded flex items-center justify-center hover:border-primary-cyan transition-all"
title="아이콘 변경"
>
{menu.icon}
</button>
{emojiPickerId === menu.id && (
<div ref={emojiPickerRef} className="absolute top-12 left-0 z-[300]">
<EmojiPicker
data={data}
onEmojiSelect={handleEmojiSelect}
theme="dark"
locale="kr"
previewPosition="none"
skinTonePosition="search"
perLine={8}
/>
</div>
)}
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={menu.label}
onChange={(e) => 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"
/>
<div className="text-[10px] text-text-3 font-mono mt-0.5">{menu.id}</div>
</div>
<button
onClick={() => { setEditingId(null); setEmojiPickerId(null) }}
className="shrink-0 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>
</>
) : (
<>
<span className="text-[16px] shrink-0">{menu.icon}</span>
<div className="flex-1 min-w-0">
<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>
<button
onClick={() => setEditingId(menu.id)}
className="shrink-0 w-7 h-7 rounded border border-border bg-bg-2 text-text-3 text-[11px] flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all"
title="라벨/아이콘 편집"
>
</button>
</>
)}
</div>
<div className="flex items-center gap-3 ml-3 shrink-0">
<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={() => moveMenu(idx, -1)}
disabled={idx === 0}
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 disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => moveMenu(idx, 1)}
disabled={idx === menus.length - 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 disabled:opacity-30 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={(event) => setActiveId(event.active.id as string)}
onDragEnd={handleDragEnd}
>
<SortableContext items={menus.map(m => m.id)} strategy={verticalListSortingStrategy}>
<div className="flex flex-col gap-2 max-w-[700px]">
{menus.map((menu, idx) => (
<SortableMenuItem
key={menu.id}
menu={menu}
idx={idx}
totalCount={menus.length}
isEditing={editingId === menu.id}
emojiPickerId={emojiPickerId}
emojiPickerRef={emojiPickerRef}
onToggle={toggleMenu}
onMove={moveMenu}
onEditStart={setEditingId}
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
onEmojiPickerToggle={setEmojiPickerId}
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
onEmojiSelect={handleEmojiSelect}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{activeMenu ? (
<div className="flex items-center gap-3 px-4 py-3 rounded-md border border-primary-cyan bg-bg-1 shadow-lg opacity-90 max-w-[700px]">
<span className="text-text-3 text-xs"></span>
<span className="text-[16px]">{activeMenu.icon}</span>
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
</div>
)
})}
</div>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)