feat(admin): 메뉴 관리 드래그앤드롭 + 가이드 문서 #13
186
docs/MENU-TAB-GUIDE.md
Normal file
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 .`)
|
||||
- [ ] 관리자 메뉴 관리에서 새 메뉴 표시 확인
|
||||
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
@ -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">
|
||||
<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) => {
|
||||
const isEditing = editingId === menu.id
|
||||
return (
|
||||
<div
|
||||
{menus.map((menu, idx) => (
|
||||
<SortableMenuItem
|
||||
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}
|
||||
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}
|
||||
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>
|
||||
</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>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user