wing-ops/frontend/src/tabs/admin/components/MenusPanel.tsx
htlee c727afd1ba refactor(frontend): 대형 View 서브탭 단위 분할 + FEATURE_ID 체계 도입
6개 대형 View(AerialView, AssetsView, ReportsView, PreScatView, AdminView, LeftPanel)를
서브탭 단위로 분할하여 모듈 경계를 명확히 함.

- AerialView (2,526줄 → 8파일): MediaManagement, OilAreaAnalysis, RealtimeDrone 등
- AssetsView (2,047줄 → 8파일): AssetManagement, AssetMap, ShipInsurance 등
- ReportsView (1,596줄 → 5파일): TemplateFormEditor, ReportGenerator 등
- PreScatView (1,390줄 → 7파일): ScatLeftPanel, ScatMap, ScatPopup 등
- AdminView (1,306줄 → 7파일): UsersPanel, PermissionsPanel, MenusPanel 등
- LeftPanel (1,237줄 → 5파일): PredictionInputSection, InfoLayerSection, OilBoomSection 등

FEATURE_ID 레지스트리(common/constants/featureIds.ts) 및
감사로그 서브탭 추적 훅(useFeatureTracking) 추가.

.gitignore의 scat/ → /scat/ 수정 (scat 탭 파일 추적 누락 수정)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 16:19:22 +09:00

199 lines
6.7 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragOverlay,
type DragEndEvent,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import {
fetchMenuConfig,
updateMenuConfigApi,
type MenuConfigItem,
} from '@common/services/authApi'
import { useMenuStore } from '@common/store/menuStore'
import SortableMenuItem from './SortableMenuItem'
// ─── 메뉴 관리 패널 ─────────────────────────────────────────
function MenusPanel() {
const [menus, setMenus] = useState<MenuConfigItem[]>([])
const [originalMenus, setOriginalMenus] = useState<MenuConfigItem[]>([])
const [loading, setLoading] = useState(true)
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 {
const config = await fetchMenuConfig()
setMenus(config)
setOriginalMenus(config)
} catch (err) {
console.error('메뉴 설정 조회 실패:', err)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadMenus()
}, [loadMenus])
useEffect(() => {
if (!emojiPickerId) return
const handler = (e: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(e.target as Node)) {
setEmojiPickerId(null)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [emojiPickerId])
const toggleMenu = (id: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, enabled: !m.enabled } : m))
}
const updateMenuField = (id: string, field: 'label' | 'icon', value: string) => {
setMenus(prev => prev.map(m => m.id === id ? { ...m, [field]: value } : m))
}
const handleEmojiSelect = (emoji: { native: string }) => {
if (emojiPickerId) {
updateMenuField(emojiPickerId, 'icon', emoji.native)
setEmojiPickerId(null)
}
}
const moveMenu = (idx: number, direction: -1 | 1) => {
const targetIdx = idx + direction
if (targetIdx < 0 || targetIdx >= menus.length) return
setMenus(prev => {
const arr = [...prev]
;[arr[idx], arr[targetIdx]] = [arr[targetIdx], arr[idx]]
return arr.map((m, i) => ({ ...m, order: i + 1 }))
})
}
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 {
const updated = await updateMenuConfigApi(menus)
setMenus(updated)
setOriginalMenus(updated)
setMenuConfig(updated)
} catch (err) {
console.error('메뉴 설정 저장 실패:', err)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-text-3 text-sm font-korean"> ...</div>
</div>
)
}
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">
<div>
<h1 className="text-lg font-bold text-text-1 font-korean"> </h1>
<p className="text-xs text-text-3 mt-1 font-korean"> , , , </p>
</div>
<button
onClick={handleSave}
disabled={!hasChanges || saving}
className={`px-4 py-2 text-xs font-semibold rounded-md transition-all font-korean ${
hasChanges && !saving
? 'bg-primary-cyan text-bg-0 hover:shadow-[0_0_12px_rgba(6,182,212,0.3)]'
: 'bg-bg-3 text-text-3 cursor-not-allowed'
}`}
>
{saving ? '저장 중...' : '변경사항 저장'}
</button>
</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) => (
<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>
) : null}
</DragOverlay>
</DndContext>
</div>
</div>
)
}
export default MenusPanel