249 lines
8.1 KiB
TypeScript
Executable File
249 lines
8.1 KiB
TypeScript
Executable File
import { useEffect, useSyncExternalStore } from 'react'
|
|
import type { MainTab } from '../types/navigation'
|
|
import { useAuthStore } from '@common/store/authStore'
|
|
import { API_BASE_URL } from '@common/services/api'
|
|
|
|
interface SubMenuItem {
|
|
id: string
|
|
label: string
|
|
icon: string
|
|
}
|
|
|
|
// 메인 탭별 서브 메뉴 설정
|
|
const subMenuConfigs: Record<MainTab, SubMenuItem[] | null> = {
|
|
hns: [
|
|
{ id: 'analysis', label: '대기확산 분석', icon: '🧪' },
|
|
{ id: 'list', label: '분석 목록', icon: '📋' },
|
|
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
|
|
{ id: 'manual', label: 'HNS 대응매뉴얼', icon: '📖' },
|
|
{ id: 'theory', label: '확산모델 이론', icon: '📐' },
|
|
{ id: 'substance', label: 'HNS 물질정보', icon: '🧬' }
|
|
],
|
|
prediction: [
|
|
{ id: 'analysis', label: '유출유 확산분석', icon: '🔬' },
|
|
{ id: 'list', label: '분석 목록', icon: '📋' },
|
|
{ id: 'theory', label: '유출유확산모델 이론', icon: '📐' },
|
|
{ id: 'boom-theory', label: '오일펜스 배치 알고리즘 이론', icon: '🛡️' }
|
|
],
|
|
rescue: [
|
|
{ id: 'rescue', label: '긴급구난예측', icon: '🚨' },
|
|
{ id: 'list', label: '긴급구난 목록', icon: '📋' },
|
|
{ id: 'scenario', label: '시나리오 관리', icon: '📊' },
|
|
{ id: 'theory', label: '긴급구난모델 이론', icon: '📚' }
|
|
],
|
|
reports: [
|
|
{ id: 'report-list', label: '보고서 목록', icon: '📋' },
|
|
{ id: 'template', label: '표준보고서 템플릿', icon: '📝' },
|
|
{ id: 'generate', label: '보고서 생성', icon: '🔄' }
|
|
],
|
|
aerial: [
|
|
{ id: 'media', label: '영상사진관리', icon: '📷' },
|
|
{ id: 'analysis', label: '영상사진합성', icon: '🧩' },
|
|
{ id: 'realtime', label: '실시간드론', icon: '🛸' },
|
|
{ id: 'satellite', label: '위성영상', icon: '🛰' },
|
|
{ id: 'cctv', label: 'CCTV 조회', icon: '📹' },
|
|
{ id: 'spectral', label: 'AI 탐지/분석', icon: '🤖' },
|
|
{ id: 'sensor', label: '오염/선박3D분석', icon: '🔍' },
|
|
{ id: 'theory', label: '항공탐색 이론', icon: '📐' }
|
|
],
|
|
assets: null,
|
|
scat: [
|
|
{ id: 'survey', label: '해안오염 조사 평가', icon: '📋' },
|
|
{ id: 'distribution', label: '해양오염분포도', icon: '🗺' },
|
|
{ id: 'pre-scat', label: 'Pre-SCAT', icon: '🔍' }
|
|
],
|
|
incidents: null,
|
|
board: [
|
|
{ id: 'all', label: '전체', icon: '📋' },
|
|
{ id: 'notice', label: '공지사항', icon: '📢' },
|
|
{ id: 'data', label: '자료실', icon: '📂' },
|
|
{ id: 'qna', label: 'Q&A', icon: '❓' },
|
|
{ id: 'manual', label: '해경매뉴얼', icon: '📘' }
|
|
],
|
|
weather: null,
|
|
admin: null // 관리자 화면은 자체 사이드바 사용 (AdminSidebar.tsx)
|
|
}
|
|
|
|
// 전역 상태 관리 (간단한 방식)
|
|
const subMenuState: Record<MainTab, string> = {
|
|
hns: 'analysis',
|
|
prediction: 'analysis',
|
|
rescue: 'rescue',
|
|
reports: 'report-list',
|
|
aerial: 'media',
|
|
assets: '',
|
|
scat: 'survey',
|
|
incidents: '',
|
|
board: 'all',
|
|
weather: '',
|
|
admin: 'users'
|
|
}
|
|
|
|
const listeners: Set<() => void> = new Set()
|
|
|
|
function setSubTab(mainTab: MainTab, subTab: string) {
|
|
subMenuState[mainTab] = subTab
|
|
listeners.forEach(listener => listener())
|
|
}
|
|
|
|
function subscribe(listener: () => void) {
|
|
listeners.add(listener)
|
|
return () => { listeners.delete(listener) }
|
|
}
|
|
|
|
export function useSubMenu(mainTab: MainTab) {
|
|
const activeSubTab = useSyncExternalStore(subscribe, () => subMenuState[mainTab])
|
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
|
const hasPermission = useAuthStore((s) => s.hasPermission)
|
|
|
|
const setActiveSubTab = (subTab: string) => {
|
|
setSubTab(mainTab, subTab)
|
|
}
|
|
|
|
// 권한 기반 서브메뉴 필터링
|
|
const rawConfig = subMenuConfigs[mainTab]
|
|
const filteredConfig = rawConfig?.filter(item =>
|
|
hasPermission(`${mainTab}:${item.id}`)
|
|
) ?? null
|
|
|
|
// 서브탭 전환 시 자동 감사 로그 (N-depth 지원: 콜론 구분 경로)
|
|
useEffect(() => {
|
|
if (!isAuthenticated || !activeSubTab) return
|
|
const resourcePath = `${mainTab}:${activeSubTab}`
|
|
const blob = new Blob(
|
|
[JSON.stringify({ action: 'SUBTAB_VIEW', detail: resourcePath })],
|
|
{ type: 'text/plain' },
|
|
)
|
|
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
|
}, [mainTab, activeSubTab, isAuthenticated])
|
|
|
|
return {
|
|
activeSubTab,
|
|
setActiveSubTab,
|
|
subMenuConfig: filteredConfig,
|
|
}
|
|
}
|
|
|
|
// ─── 글로벌 메인탭 전환 (크로스 뷰 네비게이션) ─────────────
|
|
type MainTabListener = (tab: MainTab) => void
|
|
let mainTabListener: MainTabListener | null = null
|
|
|
|
/** App.tsx에서 호출하여 글로벌 탭 전환 리스너 등록 */
|
|
export function registerMainTabSwitcher(fn: MainTabListener) {
|
|
mainTabListener = fn
|
|
}
|
|
|
|
/** 어느 컴포넌트에서든 메인탭 + 서브탭을 한번에 전환 */
|
|
export function navigateToTab(mainTab: MainTab, subTab?: string) {
|
|
if (subTab) setSubTab(mainTab, subTab)
|
|
if (mainTabListener) mainTabListener(mainTab)
|
|
}
|
|
|
|
// ─── 보고서 생성 카테고리 힌트 ──────────────────────────
|
|
/** 보고서 생성 탭으로 이동 시 초기 카테고리 (0=유출유, 1=HNS, 2=긴급구난) */
|
|
let _reportGenCategory: number | null = null
|
|
|
|
export function setReportGenCategory(cat: number | null) { _reportGenCategory = cat }
|
|
export function consumeReportGenCategory(): number | null {
|
|
const v = _reportGenCategory
|
|
_reportGenCategory = null
|
|
return v
|
|
}
|
|
|
|
// ─── HNS 보고서 실 데이터 전달 ──────────────────────────
|
|
export interface HnsReportPayload {
|
|
mapImageDataUrl: string | null;
|
|
substance: { name: string; un?: string; cas?: string; class?: string; toxicity: string };
|
|
hazard: { aegl3: string; aegl2: string; aegl1: string };
|
|
atm: { model: string; maxDistance: string };
|
|
weather: { windDir: string; windSpeed: string; stability: string; temperature: string };
|
|
maxConcentration: string;
|
|
aeglAreas: { aegl1: string; aegl2: string; aegl3: string };
|
|
}
|
|
|
|
let _hnsReportPayload: HnsReportPayload | null = null;
|
|
export function setHnsReportPayload(d: HnsReportPayload | null) { _hnsReportPayload = d; }
|
|
export function consumeHnsReportPayload(): HnsReportPayload | null {
|
|
const v = _hnsReportPayload;
|
|
_hnsReportPayload = null;
|
|
return v;
|
|
}
|
|
|
|
// ─── 유출유 예측 보고서 실 데이터 전달 ──────────────────────────
|
|
export interface OilReportMapParticle {
|
|
lat: number;
|
|
lon: number;
|
|
time: number;
|
|
particle?: number;
|
|
stranded?: 0 | 1;
|
|
}
|
|
|
|
export interface OilReportPayload {
|
|
incident: {
|
|
name: string;
|
|
occurTime: string;
|
|
location: string;
|
|
lat: number | null;
|
|
lon: number | null;
|
|
pollutant: string;
|
|
spillAmount: string;
|
|
shipName: string;
|
|
};
|
|
pollution: {
|
|
spillAmount: string;
|
|
weathered: string;
|
|
seaRemain: string;
|
|
pollutionArea: string;
|
|
coastAttach: string;
|
|
coastLength: string;
|
|
oilType: string;
|
|
};
|
|
weather: {
|
|
windDir: string;
|
|
windSpeed: string;
|
|
waveHeight: string;
|
|
temp: string;
|
|
pressure?: string;
|
|
visibility?: string;
|
|
salinity?: string;
|
|
waveMaxHeight?: string;
|
|
wavePeriod?: string;
|
|
currentDir?: string;
|
|
currentSpeed?: string;
|
|
} | null;
|
|
spread: {
|
|
kosps: string;
|
|
openDrift: string;
|
|
poseidon: string;
|
|
};
|
|
coastal: {
|
|
firstTime: string | null;
|
|
};
|
|
spreadSteps?: Array<{
|
|
elapsed: string;
|
|
weathered: string;
|
|
seaRemain: string;
|
|
coastAttach: string;
|
|
area: string;
|
|
}>;
|
|
hasSimulation: boolean;
|
|
mapData: {
|
|
center: [number, number];
|
|
zoom: number;
|
|
trajectory: OilReportMapParticle[];
|
|
currentStep: number;
|
|
centerPoints: { lat: number; lon: number; time: number }[];
|
|
simulationStartTime: string;
|
|
} | null;
|
|
}
|
|
|
|
let _oilReportPayload: OilReportPayload | null = null;
|
|
export function setOilReportPayload(d: OilReportPayload | null) { _oilReportPayload = d; }
|
|
export function consumeOilReportPayload(): OilReportPayload | null {
|
|
const v = _oilReportPayload;
|
|
_oilReportPayload = null;
|
|
return v;
|
|
}
|
|
|
|
export { subMenuState }
|