diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index a9c8b08..839d5f4 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-24", + "applied_date": "2026-03-25", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/backend/src/monitor/monitorRouter.ts b/backend/src/monitor/monitorRouter.ts new file mode 100644 index 0000000..700be1e --- /dev/null +++ b/backend/src/monitor/monitorRouter.ts @@ -0,0 +1,20 @@ +import { Router } from 'express' +import { requireAuth } from '../auth/authMiddleware.js' +import { getNumericalDataStatus } from './monitorService.js' + +const router = Router() + +router.use(requireAuth) + +// GET /api/monitor/numerical — 수치예측자료 다운로드 상태 조회 +router.get('/numerical', async (_req, res) => { + try { + const data = await getNumericalDataStatus() + res.json(data) + } catch (err) { + console.error('[monitor] 수치예측자료 상태 조회 오류:', err) + res.status(500).json({ error: '수치예측자료 상태를 조회할 수 없습니다.' }) + } +}) + +export default router diff --git a/backend/src/monitor/monitorService.ts b/backend/src/monitor/monitorService.ts new file mode 100644 index 0000000..29adff8 --- /dev/null +++ b/backend/src/monitor/monitorService.ts @@ -0,0 +1,121 @@ +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; // 데이터 기준일 (YYYY-MM-DD) + lastDownloadedAt: string | null; // 마지막 실행 완료 시각 (ISO) + nextScheduledAt: string | null; // Quartz 다음 예정 시각 (ISO) + durationSec: number | null; // 소요 시간 (초) + consecutiveFailures: number; // 연속 실패 횟수 +} + +// ============================================================ +// Mock 데이터 (Spring Batch/Quartz DB 연동 전) +// DB 연동 준비 완료 후 getMockNumericalDataStatus → getActualNumericalDataStatus 교체 +// ============================================================ +const MOCK_DATA: NumericalDataStatus[] = [ + { + modelName: 'HYCOM', + jobName: 'downloadHycomJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:12:34', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 342, + consecutiveFailures: 0, + }, + { + modelName: 'GFS', + jobName: 'downloadGfsJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T06:48:11', + nextScheduledAt: '2026-03-25T12:00:00', + durationSec: 518, + consecutiveFailures: 0, + }, + { + modelName: 'WW3', + jobName: 'downloadWw3Job', + lastStatus: 'FAILED', + lastDataDate: '2026-03-24', + lastDownloadedAt: '2026-03-25T07:03:55', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: null, + consecutiveFailures: 2, + }, + { + modelName: 'KOAST POS_WIND', + jobName: 'downloadKoastWindJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:21:05', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 127, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_HYDR', + jobName: 'downloadKoastHydrJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:35:48', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 183, + consecutiveFailures: 0, + }, + { + modelName: 'KOAST POS_WAVE', + jobName: 'downloadKoastWaveJob', + lastStatus: 'COMPLETED', + lastDataDate: '2026-03-25', + lastDownloadedAt: '2026-03-25T07:52:19', + nextScheduledAt: '2026-03-25T13:00:00', + durationSec: 156, + consecutiveFailures: 0, + }, +]; + +export async function getNumericalDataStatus(): Promise { + // TODO: Spring Batch + Quartz DB 테이블 생성 후 아래 실제 쿼리로 교체 + // + // import { wingDb } from '../db/wingDb.js' + // + // -- 각 Job의 최신 실행 결과 조회 (BATCH_JOB_EXECUTION) + // SELECT + // ji.JOB_NAME, + // je.START_TIME, je.END_TIME, + // je.STATUS, je.EXIT_CODE, je.EXIT_MESSAGE, + // jep.STRING_VAL AS data_date, + // EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME))::INT AS duration_sec + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // LEFT JOIN BATCH_JOB_EXECUTION_PARAMS jep + // ON je.JOB_EXECUTION_ID = jep.JOB_EXECUTION_ID + // AND jep.KEY_NAME = 'data_date' + // WHERE je.JOB_EXECUTION_ID IN ( + // SELECT MAX(je2.JOB_EXECUTION_ID) + // FROM BATCH_JOB_EXECUTION je2 + // GROUP BY je2.JOB_INSTANCE_ID + // ) + // ORDER BY je.START_TIME DESC; + // + // -- Quartz 다음 실행 예정 시각 (NEXT_FIRE_TIME은 epoch milliseconds) + // SELECT JOB_NAME, to_timestamp(NEXT_FIRE_TIME / 1000) AS next_fire_time + // FROM QRTZ_TRIGGERS; + // + // -- 연속 실패 횟수 집계 (최근 실행부터 COMPLETED 전까지 카운트) + // SELECT ji.JOB_NAME, COUNT(*) AS consecutive_failures + // FROM BATCH_JOB_EXECUTION je + // JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + // WHERE je.STATUS = 'FAILED' + // AND je.JOB_EXECUTION_ID > ( + // SELECT COALESCE(MAX(je2.JOB_EXECUTION_ID), 0) + // FROM BATCH_JOB_EXECUTION je2 + // WHERE je2.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID + // AND je2.STATUS = 'COMPLETED' + // ) + // GROUP BY ji.JOB_NAME; + + return MOCK_DATA; +} diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index a2111b1..6c0e562 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -46,7 +46,7 @@ router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'R } }); -// GET /api/prediction/analyses/:acdntSn/trajectory — 최신 OpenDrift 결과 조회 +// GET /api/prediction/analyses/:acdntSn/trajectory — 예측 결과 조회 (predRunSn으로 특정 실행 지정 가능) router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { try { const acdntSn = parseInt(req.params.acdntSn as string, 10); @@ -54,7 +54,8 @@ router.get('/analyses/:acdntSn/trajectory', requireAuth, requirePermission('pred res.status(400).json({ error: '유효하지 않은 사고 번호' }); return; } - const result = await getAnalysisTrajectory(acdntSn); + const predRunSn = req.query.predRunSn ? parseInt(req.query.predRunSn as string, 10) : undefined; + const result = await getAnalysisTrajectory(acdntSn, predRunSn); if (!result) { res.json({ trajectory: null, summary: null }); return; diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 833304e..649c92d 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -19,6 +19,8 @@ interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } interface PredictionDetail { @@ -142,21 +144,26 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { +export async function getAnalysisTrajectory(acdntSn: number, predRunSn?: number): Promise { // 완료된 모든 모델(OPENDRIFT, POSEIDON) 결과 조회 - const sql = ` - SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC - WHERE ACDNT_SN = $1 - AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') - AND EXEC_STTS_CD = 'COMPLETED' - ORDER BY CMPL_DTM DESC - `; - const { rows } = await wingPool.query(sql, [acdntSn]); + // predRunSn이 있으면 해당 실행의 결과만, 없으면 최신 결과 + const sql = predRunSn != null + ? ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND PRED_RUN_SN = $2 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + ` + : ` + SELECT ALGO_CD, RSLT_DATA, CMPL_DTM FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + AND ALGO_CD IN ('OPENDRIFT', 'POSEIDON') + AND EXEC_STTS_CD = 'COMPLETED' + ORDER BY CMPL_DTM DESC + `; + const params = predRunSn != null ? [acdntSn, predRunSn] : [acdntSn]; + const { rows } = await wingPool.query(sql, params); if (rows.length === 0) return null; // 모든 모델의 파티클을 하나의 배열로 병합 diff --git a/backend/src/routes/simulation.ts b/backend/src/routes/simulation.ts index 2eea5bd..8c4aaf4 100755 --- a/backend/src/routes/simulation.ts +++ b/backend/src/routes/simulation.ts @@ -210,14 +210,21 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation] SPIL_DATA INSERT 실패:', dbErr) } } @@ -545,30 +552,47 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { if (resolvedAcdntSn && !resolvedSpilDataSn) { try { const spilRes = await wingPool.query( - `SELECT SPIL_DATA_SN FROM wing.SPIL_DATA WHERE ACDNT_SN = $1 ORDER BY SPIL_DATA_SN DESC LIMIT 1`, - [resolvedAcdntSn] + `INSERT INTO wing.SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, SPIL_TP_CD, FCST_HR, REG_DTM) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + RETURNING SPIL_DATA_SN`, + [ + resolvedAcdntSn, + OIL_DB_CODE_MAP[matTy as string] ?? 'BUNKER_C', + matVol ?? 0, + UNIT_MAP[spillUnit as string] ?? 'KL', + SPIL_TYPE_MAP[spillTypeCd as string] ?? 'CONTINUOUS', + runTime, + ] ) - if (spilRes.rows.length > 0) { - resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number - } + resolvedSpilDataSn = spilRes.rows[0].spil_data_sn as number } catch (dbErr) { - console.error('[simulation/run-model] SPIL_DATA 조회 실패:', dbErr) + console.error('[simulation/run-model] SPIL_DATA INSERT 실패:', dbErr) } } const odMatTy = matTy !== undefined ? (OIL_TYPE_MAP[matTy as string] ?? (matTy as string)) : undefined const execNmBase = `EXPC_${Date.now()}` + // 이번 예측 실행을 식별하는 그룹 SN 생성 + let predRunSn: number + try { + const runSnRes = await wingPool.query("SELECT nextval('wing.PRED_RUN_SN_SEQ') AS pred_run_sn") + predRunSn = runSnRes.rows[0].pred_run_sn as number + } catch (dbErr) { + console.error('[simulation/run-model] PRED_RUN_SN_SEQ 조회 실패:', dbErr) + return res.status(500).json({ error: '실행 SN 생성 실패' }) + } + // KOSPS: PRED_EXEC INSERT(PENDING)만 수행 const execSns: Array<{ model: string; execSn: number }> = [] if (requestedModels.includes('KOSPS')) { try { const kospsExecNm = `${execNmBase}_KOSPS` const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn] ) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) } catch (dbErr) { @@ -602,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { let predExecSn: number try { const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn] ) predExecSn = insertRes.rows[0].pred_exec_sn as number } catch (dbErr) { @@ -713,6 +737,7 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { res.json({ success: true, acdntSn: resolvedAcdntSn, + predRunSn, execSns: [...execSns, ...modelResults.map(({ model, execSn }) => ({ model, execSn }))], results: modelResults, }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 8e48bdb..cf7e6a1 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -23,6 +23,7 @@ import predictionRouter from './prediction/predictionRouter.js' import aerialRouter from './aerial/aerialRouter.js' import rescueRouter from './rescue/rescueRouter.js' import mapBaseRouter from './map-base/mapBaseRouter.js' +import monitorRouter from './monitor/monitorRouter.js' import { sanitizeBody, sanitizeQuery, @@ -170,6 +171,7 @@ app.use('/api/prediction', predictionRouter) app.use('/api/aerial', aerialRouter) app.use('/api/rescue', rescueRouter) app.use('/api/map-base', mapBaseRouter) +app.use('/api/monitor', monitorRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/028_pred_run_sn.sql b/database/migration/028_pred_run_sn.sql new file mode 100644 index 0000000..9ce7c67 --- /dev/null +++ b/database/migration/028_pred_run_sn.sql @@ -0,0 +1,25 @@ +-- Migration 028: PRED_EXEC에 실행 그룹 식별자(PRED_RUN_SN) 추가 +-- 같은 시점에 여러 모델로 실행된 PRED_EXEC 레코드를 하나의 "예측 실행"으로 묶는다. +-- 목록 화면에서 사고당 예측 실행 횟수만큼 행을 표시하기 위한 기반 구조. + +-- 1. 컬럼 추가 +ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS PRED_RUN_SN INTEGER; + +-- 2. 기존 데이터 마이그레이션 +-- 같은 ACDNT_SN + 시작 시각 60초 이내의 레코드를 동일 실행 그룹으로 묶는다. +-- MIN(PRED_EXEC_SN)을 그룹 대표 키로 사용한다. +UPDATE wing.PRED_EXEC pe1 +SET PRED_RUN_SN = ( + SELECT MIN(pe2.PRED_EXEC_SN) + FROM wing.PRED_EXEC pe2 + WHERE pe2.ACDNT_SN = pe1.ACDNT_SN + AND ABS(EXTRACT(EPOCH FROM ( + COALESCE(pe2.BGNG_DTM, NOW()) - COALESCE(pe1.BGNG_DTM, NOW()) + ))) < 60 +) +WHERE pe1.PRED_RUN_SN IS NULL; + +-- 3. 시퀀스 생성 (신규 실행용 — 기존 최대값보다 충분히 높은 값에서 시작) +CREATE SEQUENCE IF NOT EXISTS wing.PRED_RUN_SN_SEQ + START WITH 10000 + INCREMENT BY 1; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d7d2fa3..33e25d4 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,16 @@ ## [Unreleased] +### 추가 +- 예측: 실행 이력 선택 기능 (predRunSn 기반 특정 예측 결과 조회) +- DB: PRED_RUN_SN 마이그레이션 추가 (028_pred_run_sn) +- 관리자: 수치예측자료 연계 모니터링 패널 추가 (HYCOM·GFS·WW3·KOAST POS_WIND/HYDR/WAVE) + +### 변경 +- 보고서: 기능 개선 (TemplateEditPage, ReportGenerator, hwpxExport 등) + +## [2026-03-24] + ### 추가 - Stitch MCP 기반 디자인 시스템 카탈로그 페이지 (/design) - react-router-dom 도입, BrowserRouter 래핑 @@ -15,39 +25,28 @@ - 확산예측: 예측 실행 시 기상정보(풍속·풍향·기압·파고·수온·기온·염분 등) ACDNT_WEATHER 테이블에 자동 저장 - DB: ACDNT_WEATHER 테이블에 구조화된 기상 수치 컬럼 19개 추가 (025 마이그레이션) - DB: 민감자원 데이터 마이그레이션 (026_sensitive_resources) +- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation) - 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate) - 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel) -- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation) +- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel) +- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가 ### 변경 - 디자인: 색상 팔레트 컨텐츠 개선 + base.css 확장 - SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환 - 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거) - 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동) +- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능) ### 문서 - Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md) -## [2026-03-20.3] - -### 추가 -- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선) -- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가) -- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가 -- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가 - -### 문서 -- PREDICTION-GUIDE.md 삭제 - -## [2026-03-20.2] - -### 변경 -- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정 - ## [2026-03-20] ### 추가 - 관리자: 지도 베이스 관리 패널, 레이어 패널 추가 및 보고서 기능 개선 +- 관리자: 권한 트리 확장 (게시판관리·기준정보·연계관리 섹션 추가) +- 관리자: 유처리제 제한구역 패널, 민감자원 레이어 패널 추가 - 항공 방제: WingAI (AI 탐지/분석) 서브탭 추가 - 항공 방제: UP42 위성 패스 조회 + 궤도 지도 표시 - 항공 방제: 위성 요청 취소 기능 추가 @@ -55,6 +54,8 @@ - 항공 방제: 위성 히스토리 지도에 캘린더 + 날짜별 촬영 리스트 + 영상 오버레이 - 항공 방제: 완료 촬영 클릭 시 VWorld 위성 영상 오버레이 표시 - 항공 방제: 위성 요청 목록 더보기 → 페이징 처리로 변경 +- 보고서: 기능 강화 (HWPX 내보내기, 확산 지도 패널, 보고서 생성기 개선) +- 기상: 날씨 스냅샷 스토어, 유틸리티 모듈 추가 - 사고관리: UI 개선 + 오염물 배출규정 기능 추가 - Pre-SCAT 해안조사 UI 개선 - 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화) @@ -65,10 +66,14 @@ - 항공 방제: 촬영 히스토리 지도 리스트 위치 좌하단으로 이동 ### 변경 +- prediction/scat 파이프라인 제거 + SCAT/사고관리 UI 수정 - 기상: 지역별 기상정보 패널 글자 사이즈 조정 + 시각화 개선 - SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제) - WeatherRightPanel 중복 코드 정리 +### 문서 +- PREDICTION-GUIDE.md 삭제 + ## [2026-03-18] ### 추가 @@ -102,8 +107,6 @@ - KOSPS/앙상블 준비중 팝업 + 기본 모델 POSEIDON 변경 - 오염분석 원 분석 기능 — 중심점/반경 입력으로 원형 오염 면적 계산 - 오일펜스 배치 가이드 UI 개선 -- 거리·면적 측정 도구 (TopBar 퀵메뉴 + deck.gl 시각화) -- Pre-SCAT 관할서 필터링 + 해안조사 데이터 파이프라인 구축 - 다각형/원 오염분석 + 범례 최소화 + Convex Hull 면적 계산 ### 수정 @@ -114,7 +117,6 @@ - 오염분석 UI 개선 — HTML 디자인 참고 반영 - 범례 UI 개선 — HTML 참고 디자인 반영 - 드론 아이콘 쿼드콥터 + 함정 MarineTraffic 삼각형 스타일 -- SCAT 사진을 로컬에서 서버 프록시로 전환 (scat-photos 1,127개 삭제) ### 기타 - 프론트엔드 포트 변경(5174) + CORS 허용 diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index df9a5d1..200f2a9 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -17,6 +17,8 @@ import DispersingZonePanel from './DispersingZonePanel'; import MonitorRealtimePanel from './MonitorRealtimePanel'; import MonitorVesselPanel from './MonitorVesselPanel'; import CollectHrPanel from './CollectHrPanel'; +import MonitorForecastPanel from './MonitorForecastPanel'; +import VesselMaterialsPanel from './VesselMaterialsPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record JSX.Element> = { @@ -35,9 +37,11 @@ const PANEL_MAP: Record JSX.Element> = { 'env-ecology': () => , 'social-economy': () => , 'dispersant-zone': () => , + 'vessel-materials': () => , 'monitor-realtime': () => , 'monitor-vessel': () => , 'collect-hr': () => , + 'monitor-forecast': () => , }; export function AdminView() { diff --git a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx index 4708041..adce38e 100644 --- a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx +++ b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx @@ -16,6 +16,7 @@ function CleanupEquipPanel() { const [searchTerm, setSearchTerm] = useState(''); const [regionFilter, setRegionFilter] = useState('전체'); const [typeFilter, setTypeFilter] = useState('전체'); + const [equipFilter, setEquipFilter] = useState('전체'); const [currentPage, setCurrentPage] = useState(1); const load = () => { @@ -40,12 +41,21 @@ function CleanupEquipPanel() { return Array.from(set).sort(); }, [organizations]); + const EQUIP_FIELDS: Record = { + '방제선': 'vessel', + '유회수기': 'skimmer', + '이송펌프': 'pump', + '방제차량': 'vehicle', + '살포장치': 'sprayer', + }; + const filtered = useMemo(() => organizations .filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter)) .filter(o => typeFilter === '전체' || o.type === typeFilter) + .filter(o => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0) .filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)), - [organizations, regionFilter, typeFilter, searchTerm] + [organizations, regionFilter, typeFilter, equipFilter, searchTerm] ); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); @@ -96,6 +106,18 @@ function CleanupEquipPanel() { ))} + - 번호 + 번호 유형 관할청 기관명 주소 - 방제선 - 유회수기 - 이송펌프 - 방제차량 - 살포장치 + 방제선 + 유회수기 + 이송펌프 + 방제차량 + 살포장치 총자산 @@ -161,20 +183,20 @@ function CleanupEquipPanel() { {org.address} - - {org.vessel > 0 ? {org.vessel} : } + + {org.vessel > 0 ? org.vessel : } - - {org.skimmer > 0 ? {org.skimmer} : } + + {org.skimmer > 0 ? org.skimmer : } - - {org.pump > 0 ? {org.pump} : } + + {org.pump > 0 ? org.pump : } - - {org.vehicle > 0 ? {org.vehicle} : } + + {org.vehicle > 0 ? org.vehicle : } - - {org.sprayer > 0 ? {org.sprayer} : } + + {org.sprayer > 0 ? org.sprayer : } {org.totalAssets.toLocaleString()} @@ -186,6 +208,33 @@ function CleanupEquipPanel() { )} + {/* 합계 */} + {!loading && filtered.length > 0 && ( +
+ + 합계 ({filtered.length}개 기관) + + {[ + { label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척' }, + { label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대' }, + { label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대' }, + { label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대' }, + { label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대' }, + { label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '' }, + ].map((t) => { + const isActive = t.label === equipFilter || t.label === '총자산'; + return ( +
+ {t.label} + + {t.value.toLocaleString()}{t.unit} + +
+ ); + })} +
+ )} + {/* 페이지네이션 */} {!loading && filtered.length > 0 && (
diff --git a/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx new file mode 100644 index 0000000..47d4841 --- /dev/null +++ b/frontend/src/tabs/admin/components/MonitorForecastPanel.tsx @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + getNumericalDataStatus, + type NumericalDataStatus, +} from '../services/monitorApi'; + +type TabId = 'all' | 'ocean' | 'koast'; + +const TABS: { id: TabId; label: string }[] = [ + { id: 'all', label: '전체' }, + { id: 'ocean', label: '기상·해양 모델' }, + { id: 'koast', label: 'KOAST' }, +]; + +const OCEAN_MODELS = ['HYCOM', 'GFS', 'WW3']; +const KOAST_MODELS = ['KOAST POS_WIND', 'KOAST POS_HYDR', 'KOAST POS_WAVE']; + +function filterByTab(rows: NumericalDataStatus[], tab: TabId): NumericalDataStatus[] { + if (tab === 'ocean') return rows.filter((r) => OCEAN_MODELS.includes(r.modelName)); + if (tab === 'koast') return rows.filter((r) => KOAST_MODELS.includes(r.modelName)); + return rows; +} + +function formatDuration(sec: number | null): string { + if (sec == null) return '-'; + const m = Math.floor(sec / 60); + const s = sec % 60; + return m > 0 ? `${m}분 ${s}초` : `${s}초`; +} + +function formatDatetime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${mm}-${dd} ${hh}:${min}`; +} + +function formatTime(iso: string | null): string { + if (!iso) return '-'; + const d = new Date(iso); + const hh = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${hh}:${min}`; +} + +function StatusCell({ row }: { row: NumericalDataStatus }) { + if (row.lastStatus === 'COMPLETED') { + return 정상; + } + if (row.lastStatus === 'FAILED') { + return ( + + 오류{row.consecutiveFailures > 0 ? ` (${row.consecutiveFailures}회 연속)` : ''} + + ); + } + if (row.lastStatus === 'STARTED') { + return ( + + + 실행 중 + + ); + } + return -; +} + +function StatusBadge({ + loading, + errorCount, + total, +}: { + loading: boolean; + errorCount: number; + total: number; +}) { + if (loading) { + return ( + + + 조회 중... + + ); + } + if (errorCount === total && total > 0) { + return ( + + + 연계 오류 + + ); + } + if (errorCount > 0) { + return ( + + + 일부 오류 ({errorCount}/{total}) + + ); + } + return ( + + + 정상 + + ); +} + +const TABLE_HEADERS = [ + '모델명', + '데이터 기준일', + '마지막 다운로드', + '상태', + '소요 시간', + '다음 예정', +]; + +function ForecastTable({ + rows, + loading, +}: { + rows: NumericalDataStatus[]; + loading: boolean; +}) { + return ( +
+ + + + {TABLE_HEADERS.map((h) => ( + + ))} + + + + {loading && rows.length === 0 + ? Array.from({ length: 6 }).map((_, i) => ( + + {TABLE_HEADERS.map((_, j) => ( + + ))} + + )) + : rows.map((row) => ( + + + + + + + + + ))} + +
+ {h} +
+
+
+ {row.modelName} + {row.lastDataDate ?? '-'}{formatDatetime(row.lastDownloadedAt)} + + {formatDuration(row.durationSec)}{formatTime(row.nextScheduledAt)}
+
+ ); +} + +export default function MonitorForecastPanel() { + const [activeTab, setActiveTab] = useState('all'); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [lastUpdate, setLastUpdate] = useState(null); + + const fetchData = useCallback(async () => { + setLoading(true); + try { + const data = await getNumericalDataStatus(); + setRows(data); + setLastUpdate(new Date()); + } catch { + // 오류 시 기존 데이터 유지 + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + const visibleRows = filterByTab(rows, activeTab); + const errorCount = visibleRows.filter( + (r) => r.lastStatus === 'FAILED' + ).length; + const totalCount = visibleRows.length; + + return ( +
+ {/* 헤더 */} +
+

수치예측자료 모니터링

+
+ {lastUpdate && ( + + 갱신:{' '} + {lastUpdate.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + })} + + )} + +
+
+ + {/* 탭 */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* 상태 표시줄 */} +
+ + {!loading && totalCount > 0 && ( + 모델 {totalCount}개 + )} +
+ + {/* 테이블 */} +
+ +
+
+ ); +} diff --git a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx b/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx new file mode 100644 index 0000000..f9fb9dd --- /dev/null +++ b/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useMemo } from 'react'; +import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; +import { typeTagCls } from '@tabs/assets/components/assetTypes'; + +const PAGE_SIZE = 20; + +const regionShort = (j: string) => + j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' : + j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' : + j.includes('제주') ? '제주청' : j; + +function VesselMaterialsPanel() { + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [regionFilter, setRegionFilter] = useState('전체'); + const [typeFilter, setTypeFilter] = useState('전체'); + const [currentPage, setCurrentPage] = useState(1); + + const load = () => { + setLoading(true); + fetchOrganizations() + .then(setOrganizations) + .catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + let cancelled = false; + fetchOrganizations() + .then(data => { if (!cancelled) setOrganizations(data); }) + .catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err)) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); + + const typeOptions = useMemo(() => { + const set = new Set(organizations.map(o => o.type)); + return Array.from(set).sort(); + }, [organizations]); + + const filtered = useMemo(() => + organizations + .filter(o => o.vessel > 0) + .filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter)) + .filter(o => typeFilter === '전체' || o.type === typeFilter) + .filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)), + [organizations, regionFilter, typeFilter, searchTerm] + ); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(currentPage, totalPages); + const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); + + const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent) => { + setter(e.target.value); + setCurrentPage(1); + }; + + const pageNumbers = (() => { + const range: number[] = []; + const start = Math.max(1, safePage - 2); + const end = Math.min(totalPages, safePage + 2); + for (let i = start; i <= end; i++) range.push(i); + return range; + })(); + + return ( +
+ {/* 헤더 */} +
+
+

방제선 보유자재 현황

+

총 {filtered.length}개 기관 (방제선 보유)

+
+
+ + + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> + +
+
+ + {/* 테이블 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + + + + + + + + + + + + + + + + + {paged.length === 0 ? ( + + + + ) : paged.map((org, idx) => ( + + + + + + + + + + + + + + ))} + +
번호유형관할청기관명주소방제선유회수기이송펌프방제차량살포장치총자산
+ 조회된 기관이 없습니다. +
+ {(safePage - 1) * PAGE_SIZE + idx + 1} + + + {org.type} + + + {regionShort(org.jurisdiction)} + + {org.name} + + {org.address} + + {org.vessel > 0 ? org.vessel : } + + {org.skimmer > 0 ? org.skimmer : } + + {org.pump > 0 ? org.pump : } + + {org.vehicle > 0 ? org.vehicle : } + + {org.sprayer > 0 ? org.sprayer : } + + {org.totalAssets.toLocaleString()} +
+ )} +
+ + {/* 합계 */} + {!loading && filtered.length > 0 && ( +
+ + 합계 ({filtered.length}개 기관) + + {[ + { label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척', active: true }, + { label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대', active: false }, + { label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대', active: false }, + { label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대', active: false }, + { label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대', active: false }, + { label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '', active: true }, + ].map((t) => ( +
+ {t.label} + + {t.value.toLocaleString()}{t.unit} + +
+ ))} +
+ )} + + {/* 페이지네이션 */} + {!loading && filtered.length > 0 && ( +
+ + {(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개 + +
+ + {pageNumbers.map(p => ( + + ))} + +
+
+ )} +
+ ); +} + +export default VesselMaterialsPanel; diff --git a/frontend/src/tabs/admin/services/monitorApi.ts b/frontend/src/tabs/admin/services/monitorApi.ts new file mode 100644 index 0000000..3ad9c23 --- /dev/null +++ b/frontend/src/tabs/admin/services/monitorApi.ts @@ -0,0 +1,17 @@ +import { api } from '@common/services/api'; + +export interface NumericalDataStatus { + modelName: string; + jobName: string; + lastStatus: 'COMPLETED' | 'FAILED' | 'STARTED' | 'UNKNOWN'; + lastDataDate: string | null; + lastDownloadedAt: string | null; + nextScheduledAt: string | null; + durationSec: number | null; + consecutiveFailures: number; +} + +export async function getNumericalDataStatus(): Promise { + const res = await api.get('/monitor/numerical'); + return res.data; +} diff --git a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx index 2341958..5fb41c3 100755 --- a/frontend/src/tabs/prediction/components/AnalysisListTable.tsx +++ b/frontend/src/tabs/prediction/components/AnalysisListTable.tsx @@ -153,6 +153,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis 번호 사고명 사고일시 + 예측 실행 예측시간 유종 유출량 @@ -167,7 +168,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis {currentAnalyses.map((analysis) => ( {analysis.acdntSn} @@ -188,6 +189,7 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} + {analysis.runDtm ? new Date(analysis.runDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} {analysis.duration} {analysis.oilType} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index d8d5196..cc84007 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -519,7 +519,7 @@ export function OilSpillView() { analysis.opendriftStatus === 'completed' || analysis.poseidonStatus === 'completed'; if (hasCompletedModel) { try { - const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn) + const { trajectory, summary, centerPoints: cp, windDataByModel: wdByModel, hydrDataByModel: hdByModel, summaryByModel: sbModel, stepSummariesByModel: stepSbModel } = await fetchAnalysisTrajectory(analysis.acdntSn, analysis.predRunSn ?? undefined) if (trajectory && trajectory.length > 0) { setOilTrajectory(trajectory) if (summary) setSimulationSummary(summary) @@ -1264,7 +1264,13 @@ export function OilSpillView() { {activeSubTab === 'analysis' && ( setRecalcModalOpen(true)} + onOpenRecalc={() => { + if (!selectedAnalysis) { + alert('선택된 사고가 없습니다.\n분석 목록에서 사고를 선택해주세요.'); + return; + } + setRecalcModalOpen(true); + }} onOpenReport={handleOpenReport} detail={analysisDetail} summary={stepSummariesByModel[windHydrModel]?.[currentStep] ?? summaryByModel[windHydrModel] ?? simulationSummary} @@ -1312,6 +1318,7 @@ export function OilSpillView() { setRecalcModalOpen(false)} + incidentName={selectedAnalysis?.acdntNm || incidentName} oilType={oilType} spillAmount={spillAmount} spillType={spillType} diff --git a/frontend/src/tabs/prediction/components/RecalcModal.tsx b/frontend/src/tabs/prediction/components/RecalcModal.tsx index 080079d..0b81783 100755 --- a/frontend/src/tabs/prediction/components/RecalcModal.tsx +++ b/frontend/src/tabs/prediction/components/RecalcModal.tsx @@ -4,6 +4,7 @@ import type { PredictionModel } from './OilSpillView' interface RecalcModalProps { isOpen: boolean onClose: () => void + incidentName: string oilType: string spillAmount: number spillType: string @@ -24,11 +25,14 @@ type RecalcPhase = 'editing' | 'running' | 'done' const OIL_TYPES = ['벙커C유', '원유(중질)', '원유(경질)', '디젤유(경유)', '휘발유', '등유', '윤활유', 'HFO 380', 'HFO 180'] const SPILL_TYPES = ['연속', '순간', '점진적'] -const PREDICTION_TIMES = [12, 24, 48, 72, 96, 120] +const PREDICTION_TIMES = [6, 12, 24, 48, 72, 96, 120] +const snapToValidTime = (t: number): number => + PREDICTION_TIMES.includes(t) ? t : (PREDICTION_TIMES.find(h => h >= t) ?? PREDICTION_TIMES[0]) export function RecalcModal({ isOpen, onClose, + incidentName, oilType: initOilType, spillAmount: initSpillAmount, spillType: initSpillType, @@ -43,7 +47,7 @@ export function RecalcModal({ const [spillAmount, setSpillAmount] = useState(initSpillAmount) const [spillUnit, setSpillUnit] = useState<'kl' | 'ton' | 'bbl'>('kl') const [spillType, setSpillType] = useState(initSpillType) - const [predictionTime, setPredictionTime] = useState(initPredictionTime) + const [predictionTime, setPredictionTime] = useState(() => snapToValidTime(initPredictionTime)) const [lat, setLat] = useState(initCoord.lat) const [lon, setLon] = useState(initCoord.lon) const [models, setModels] = useState>(new Set(initModels)) @@ -56,7 +60,7 @@ export function RecalcModal({ setOilType(initOilType) setSpillAmount(initSpillAmount) setSpillType(initSpillType) - setPredictionTime(initPredictionTime) + setPredictionTime(snapToValidTime(initPredictionTime)) setLat(initCoord.lat) setLon(initCoord.lon) setModels(new Set(initModels)) @@ -163,7 +167,7 @@ export function RecalcModal({ 현재 분석 정보
- + @@ -265,30 +269,26 @@ export function RecalcModal({
{([ - { model: 'KOSPS' as PredictionModel, color: '#3b82f6' }, - { model: 'POSEIDON' as PredictionModel, color: '#22c55e' }, - { model: 'OpenDrift' as PredictionModel, color: '#f97316' }, - ]).map(({ model, color }) => ( + { model: 'KOSPS' as PredictionModel, color: 'var(--cyan)', ready: false }, + { model: 'POSEIDON' as PredictionModel, color: 'var(--red)', ready: true }, + { model: 'OpenDrift' as PredictionModel, color: 'var(--blue)', ready: true }, + ]).map(({ model, color, ready }) => ( ))} -
diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index 2094c32..3f93d4d 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -63,10 +63,11 @@ export function RightPanel({ const [insuranceExpanded, setInsuranceExpanded] = useState(false) const weatheringStatus = useMemo(() => { - if (!summary) return null; + const zero = { surface: 0, evaporation: 0, dispersion: 0, boom: 0, beached: 0 }; + if (!summary) return zero; const total = summary.remainingVolume + summary.evaporationVolume + summary.dispersionVolume + summary.beachedVolume + boomBlockedVolume; - if (total <= 0) return null; + if (total <= 0) return zero; const pct = (v: number) => Math.round((v / total) * 100); return { surface: pct(summary.remainingVolume), @@ -288,17 +289,13 @@ export function RightPanel({ {/* 유출유 풍화 상태 */}
- {weatheringStatus ? ( - <> - - - - - - - ) : ( -

시뮬레이션 실행 후 표시됩니다

- )} + <> + + + + + +
diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 0d1c12b..025c2a1 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -19,6 +19,8 @@ export interface PredictionAnalysis { analyst: string; officeName: string; acdntSttsCd: string; + predRunSn: number | null; + runDtm: string | null; } export interface PredictionDetail { @@ -216,8 +218,11 @@ export interface TrajectoryResponse { stepSummariesByModel?: Record; } -export const fetchAnalysisTrajectory = async (acdntSn: number): Promise => { - const response = await api.get(`/prediction/analyses/${acdntSn}/trajectory`); +export const fetchAnalysisTrajectory = async (acdntSn: number, predRunSn?: number): Promise => { + const response = await api.get( + `/prediction/analyses/${acdntSn}/trajectory`, + predRunSn != null ? { params: { predRunSn } } : undefined, + ); return response.data; }; diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index fe73419..3f25e03 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -276,14 +276,18 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
3. 유출유 확산예측
- {data.step3MapImage - ? 확산예측 3시간 지도 - :
확산예측 3시간 지도
- } - {data.step6MapImage - ? 확산예측 6시간 지도 - :
확산예측 6시간 지도
- } +
+ {data.step3MapImage + ? 확산예측 3시간 지도 + :
확산예측 3시간 지도
+ } +
+
+ {data.step6MapImage + ? 확산예측 6시간 지도 + :
확산예측 6시간 지도
+ } +
시간별 상세정보
@@ -433,9 +437,18 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi // aquaculture (어장) const aquacultureRows = geojson.features .filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장')) + .sort((a, b) => { + const aNo = String((a.properties as Record)['lcns_no'] ?? ''); + const bNo = String((b.properties as Record)['lcns_no'] ?? ''); + return aNo.localeCompare(bNo, 'ko', { numeric: true }); + }) .map(f => { const p = f.properties as Record - return { type: String(p['fids_knd'] ?? ''), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } + const lcnsNo = String(p['lcns_no'] ?? ''); + const fidsKnd = String(p['fids_knd'] ?? ''); + const farmKnd = String(p['farm_knd'] ?? ''); + const parts = [lcnsNo, fidsKnd, farmKnd].filter(Boolean); + return { type: parts.join('_'), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } }) // beaches (해수욕장) @@ -643,7 +656,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi // 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더 if (!editing) { if (data.sensitiveMapImage) { - return 민감자원 분포 지도 + return 민감자원 분포 지도 } return
민감자원 분포(10km 내) 지도
} @@ -657,7 +670,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi if (data.sensitiveMapImage) { return (
- 민감자원 분포 지도 + 민감자원 분포 지도
-
양식장 분포
+
양식장 분포
{data.aquaculture.map((a, i) => ( @@ -936,7 +949,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo if (!editing) { if (data.sensitivityMapImage) { - return 통합민감도 평가 지도 + return 통합민감도 평가 지도 } return
통합민감도 평가 지도
} @@ -946,7 +959,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo if (data.sensitivityMapImage) { return (
- 통합민감도 평가 지도 + 통합민감도 평가 지도
구분면적(ha)사고지점과의 거리(km)
+ + + + + + {section.fields.map((field, fIdx) => { + // ── Read-only auto-calculated keys ── + if (field.key.startsWith('__')) { + return ( + + + + ); + } + + // ── Textarea (no label = full-width, or labeled textarea) ── + if (field.type === 'textarea' || !field.label) { + return ( + + {field.label && ( + + )} +
+ + {section.title} — 자동 계산 데이터 (확인 전용) + +
+ {field.label} + +