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",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
@ -337,6 +340,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@emoji-mart/data": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emoji-mart/data/-/data-1.2.1.tgz",
|
||||||
@ -4478,6 +4534,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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/data": "^1.2.1",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@react-oauth/google": "^0.13.4",
|
"@react-oauth/google": "^0.13.4",
|
||||||
|
|||||||
@ -2,6 +2,24 @@ import { useState, useEffect, useCallback, useRef } from 'react'
|
|||||||
import { useSubMenu } from '../../hooks/useSubMenu'
|
import { useSubMenu } from '../../hooks/useSubMenu'
|
||||||
import data from '@emoji-mart/data'
|
import data from '@emoji-mart/data'
|
||||||
import EmojiPicker from '@emoji-mart/react'
|
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 {
|
import {
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
fetchRoles,
|
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() {
|
function MenusPanel() {
|
||||||
const [menus, setMenus] = useState<MenuConfigItem[]>([])
|
const [menus, setMenus] = useState<MenuConfigItem[]>([])
|
||||||
@ -727,11 +899,17 @@ function MenusPanel() {
|
|||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [editingId, setEditingId] = useState<string | null>(null)
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
|
const [emojiPickerId, setEmojiPickerId] = useState<string | null>(null)
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||||
const { setMenuConfig } = useMenuStore()
|
const { setMenuConfig } = useMenuStore()
|
||||||
|
|
||||||
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
|
const hasChanges = JSON.stringify(menus) !== JSON.stringify(originalMenus)
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||||
|
)
|
||||||
|
|
||||||
const loadMenus = useCallback(async () => {
|
const loadMenus = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
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 () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@ -807,6 +997,8 @@ function MenusPanel() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeMenu = activeId ? menus.find(m => m.id === activeId) : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
@ -828,113 +1020,44 @@ function MenusPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto px-6 py-4">
|
<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]">
|
<div className="flex flex-col gap-2 max-w-[700px]">
|
||||||
{menus.map((menu, idx) => {
|
{menus.map((menu, idx) => (
|
||||||
const isEditing = editingId === menu.id
|
<SortableMenuItem
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={menu.id}
|
key={menu.id}
|
||||||
className={`flex items-center justify-between px-4 py-3 rounded-md border transition-all ${
|
menu={menu}
|
||||||
menu.enabled
|
idx={idx}
|
||||||
? 'bg-bg-1 border-border'
|
totalCount={menus.length}
|
||||||
: 'bg-bg-0 border-border opacity-50'
|
isEditing={editingId === menu.id}
|
||||||
}`}
|
emojiPickerId={emojiPickerId}
|
||||||
>
|
emojiPickerRef={emojiPickerRef}
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
onToggle={toggleMenu}
|
||||||
<span className="text-text-3 text-xs font-mono w-6 text-center shrink-0">{idx + 1}</span>
|
onMove={moveMenu}
|
||||||
{isEditing ? (
|
onEditStart={setEditingId}
|
||||||
<>
|
onEditEnd={() => { setEditingId(null); setEmojiPickerId(null) }}
|
||||||
<div className="relative shrink-0">
|
onEmojiPickerToggle={setEmojiPickerId}
|
||||||
<button
|
onLabelChange={(id, value) => updateMenuField(id, 'label', value)}
|
||||||
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}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
theme="dark"
|
|
||||||
locale="kr"
|
|
||||||
previewPosition="none"
|
|
||||||
skinTonePosition="search"
|
|
||||||
perLine={8}
|
|
||||||
/>
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</SortableContext>
|
||||||
</div>
|
<DragOverlay>
|
||||||
<div className="flex-1 min-w-0">
|
{activeMenu ? (
|
||||||
<input
|
<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]">
|
||||||
type="text"
|
<span className="text-text-3 text-xs">⠿</span>
|
||||||
value={menu.label}
|
<span className="text-[16px]">{activeMenu.icon}</span>
|
||||||
onChange={(e) => updateMenuField(menu.id, 'label', e.target.value)}
|
<span className="text-[13px] font-semibold text-text-1 font-korean">{activeMenu.label}</span>
|
||||||
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>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user