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>
199 lines
6.7 KiB
TypeScript
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
|