From 46c7307ab9d0fe2a411da0dee4e8181c6a8473f0 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 22:20:37 +0900 Subject: [PATCH] =?UTF-8?q?feat(incidents):=20=EC=82=AC=EA=B3=A0=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=ED=83=AD=20mock=20=E2=86=92=20DB/API=20=EC=A0=84?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: ACDNT, SPIL_DATA, PRED_EXEC, ACDNT_WEATHER, ACDNT_MEDIA 5개 테이블 생성 - 시드: 사고 12건, 유출정보 12건, 예측실행 18건, 기상 6건, 미디어 6건 - 백엔드: incidentsService + incidentsRouter (사고 목록/상세/예측/기상/미디어 5개 API) - 프론트: IncidentsView, IncidentTable, IncidentsLeftPanel, MediaModal mock → API 전환 - mockIncidents, WEATHER_DATA, MEDIA_DATA 3개 mock 완전 제거 - SECTION_DATA, MOCK_SENSITIVE, mockVessels는 별도 도메인으로 유지 Co-Authored-By: Claude Opus 4.6 --- backend/src/incidents/incidentsRouter.ts | 117 +++++++ backend/src/incidents/incidentsService.ts | 302 ++++++++++++++++++ backend/src/server.ts | 2 + database/migration/009_incidents.sql | 230 +++++++++++++ .../incidents/components/IncidentTable.tsx | 273 ++-------------- .../components/IncidentsLeftPanel.tsx | 123 ++----- .../incidents/components/IncidentsView.tsx | 62 +--- .../tabs/incidents/components/MediaModal.tsx | 154 ++++----- .../tabs/incidents/services/incidentsApi.ts | 172 ++++++++++ 9 files changed, 957 insertions(+), 478 deletions(-) create mode 100644 backend/src/incidents/incidentsRouter.ts create mode 100644 backend/src/incidents/incidentsService.ts create mode 100644 database/migration/009_incidents.sql create mode 100644 frontend/src/tabs/incidents/services/incidentsApi.ts diff --git a/backend/src/incidents/incidentsRouter.ts b/backend/src/incidents/incidentsRouter.ts new file mode 100644 index 0000000..a27c82d --- /dev/null +++ b/backend/src/incidents/incidentsRouter.ts @@ -0,0 +1,117 @@ +import { Router } from 'express'; +import { requireAuth } from '../auth/authMiddleware.js'; +import { + listIncidents, + getIncident, + listIncidentPredictions, + getIncidentWeather, + getIncidentMedia, +} from './incidentsService.js'; + +const router = Router(); + +// ============================================================ +// GET /api/incidents — 사고 목록 +// ============================================================ +router.get('/', requireAuth, async (req, res) => { + try { + const { status, region, search, startDate, endDate } = req.query as { + status?: string; + region?: string; + search?: string; + startDate?: string; + endDate?: string; + }; + const incidents = await listIncidents({ status, region, search, startDate, endDate }); + res.json(incidents); + } catch (err) { + console.error('[incidents] 사고 목록 조회 오류:', err); + res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// ============================================================ +// GET /api/incidents/:sn — 사고 상세 +// ============================================================ +router.get('/:sn', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const incident = await getIncident(sn); + if (!incident) { + res.status(404).json({ error: '사고를 찾을 수 없습니다.' }); + return; + } + res.json(incident); + } catch (err) { + console.error('[incidents] 사고 상세 조회 오류:', err); + res.status(500).json({ error: '사고 상세 조회 중 오류가 발생했습니다.' }); + } +}); + +// ============================================================ +// GET /api/incidents/:sn/predictions — 예측 실행 목록 +// ============================================================ +router.get('/:sn/predictions', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const predictions = await listIncidentPredictions(sn); + res.json(predictions); + } catch (err) { + console.error('[incidents] 예측 목록 조회 오류:', err); + res.status(500).json({ error: '예측 목록 조회 중 오류가 발생했습니다.' }); + } +}); + +// ============================================================ +// GET /api/incidents/:sn/weather — 기상정보 +// ============================================================ +router.get('/:sn/weather', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const weather = await getIncidentWeather(sn); + if (!weather) { + res.json({ message: '기상정보가 없습니다.' }); + return; + } + res.json(weather); + } catch (err) { + console.error('[incidents] 기상정보 조회 오류:', err); + res.status(500).json({ error: '기상정보 조회 중 오류가 발생했습니다.' }); + } +}); + +// ============================================================ +// GET /api/incidents/:sn/media — 미디어 정보 +// ============================================================ +router.get('/:sn/media', requireAuth, async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (isNaN(sn)) { + res.status(400).json({ error: '유효하지 않은 사고 번호입니다.' }); + return; + } + const media = await getIncidentMedia(sn); + if (!media) { + res.json({ message: '미디어 정보가 없습니다.' }); + return; + } + res.json(media); + } catch (err) { + console.error('[incidents] 미디어 정보 조회 오류:', err); + res.status(500).json({ error: '미디어 정보 조회 중 오류가 발생했습니다.' }); + } +}); + +export default router; diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts new file mode 100644 index 0000000..8a23355 --- /dev/null +++ b/backend/src/incidents/incidentsService.ts @@ -0,0 +1,302 @@ +import { wingPool } from '../db/wingDb.js'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface IncidentListItem { + acdntSn: number; + acdntCd: string; + acdntNm: string; + acdntTpCd: string; + acdntSttsCd: string; + lat: number; + lng: number; + locDc: string; + occrnDtm: string; + regionNm: string; + officeNm: string; + svrtCd: string | null; + vesselTp: string | null; + phaseCd: string; + analystNm: string | null; + oilTpCd: string | null; + spilQty: number | null; + spilUnitCd: string | null; + fcstHr: number | null; + mediaCnt: number; +} + +interface PredExecItem { + predExecSn: number; + algoCd: string; + execSttsCd: string; + bgngDtm: string | null; + cmplDtm: string | null; + reqdSec: number | null; +} + +interface WeatherInfo { + locNm: string; + obsDtm: string; + icon: string; + temp: string; + weatherDc: string; + wind: string; + wave: string; + humid: string; + vis: string; + sst: string; + tide: string; + highTide: string; + lowTide: string; + forecast: Array<{ hour: string; icon: string; temp: string }>; + impactDc: string; +} + +interface MediaInfo { + photoCnt: number; + videoCnt: number; + satCnt: number; + cctvCnt: number; + photoMeta: Record | null; + droneMeta: Record | null; + satMeta: Record | null; + cctvMeta: Record | null; +} + +interface IncidentDetail extends IncidentListItem { + predictions: PredExecItem[]; + weather: WeatherInfo | null; + media: MediaInfo | null; +} + +// ============================================================ +// 사고 목록 조회 +// ============================================================ +export async function listIncidents(filters: { + status?: string; + region?: string; + search?: string; + startDate?: string; + endDate?: string; +}): Promise { + const conditions: string[] = ["a.USE_YN = 'Y'"]; + const params: unknown[] = []; + let idx = 1; + + if (filters.status) { + conditions.push(`a.ACDNT_STTS_CD = $${idx++}`); + params.push(filters.status); + } + if (filters.region) { + conditions.push(`a.REGION_NM LIKE '%' || $${idx++} || '%'`); + params.push(filters.region); + } + if (filters.search) { + conditions.push(`a.ACDNT_NM LIKE '%' || $${idx++} || '%'`); + params.push(filters.search); + } + if (filters.startDate) { + conditions.push(`a.OCCRN_DTM >= $${idx++}`); + params.push(filters.startDate); + } + if (filters.endDate) { + conditions.push(`a.OCCRN_DTM <= $${idx++}`); + params.push(filters.endDate); + } + + const sql = ` + SELECT a.ACDNT_SN, a.ACDNT_CD, a.ACDNT_NM, a.ACDNT_TP_CD, a.ACDNT_STTS_CD, + a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, + a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, + s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, + COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) + + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + FROM wing.ACDNT a + LEFT JOIN LATERAL ( + SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR + FROM wing.SPIL_DATA + WHERE ACDNT_SN = a.ACDNT_SN + ORDER BY SPIL_DATA_SN + LIMIT 1 + ) s ON TRUE + LEFT JOIN wing.ACDNT_MEDIA m ON m.ACDNT_SN = a.ACDNT_SN + WHERE ${conditions.join(' AND ')} + ORDER BY a.OCCRN_DTM DESC + `; + + const { rows } = await wingPool.query(sql, params); + + return rows.map((r: Record) => ({ + acdntSn: r.acdnt_sn as number, + acdntCd: r.acdnt_cd as string, + acdntNm: r.acdnt_nm as string, + acdntTpCd: r.acdnt_tp_cd as string, + acdntSttsCd: r.acdnt_stts_cd as string, + lat: parseFloat(r.lat as string), + lng: parseFloat(r.lng as string), + locDc: r.loc_dc as string, + occrnDtm: (r.occrn_dtm as Date).toISOString(), + regionNm: r.region_nm as string, + officeNm: r.office_nm as string, + svrtCd: (r.svrt_cd as string) ?? null, + vesselTp: (r.vessel_tp as string) ?? null, + phaseCd: r.phase_cd as string, + analystNm: (r.analyst_nm as string) ?? null, + oilTpCd: (r.oil_tp_cd as string) ?? null, + spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, + spilUnitCd: (r.spil_unit_cd as string) ?? null, + fcstHr: (r.fcst_hr as number) ?? null, + mediaCnt: Number(r.media_cnt), + })); +} + +// ============================================================ +// 사고 상세 조회 +// ============================================================ +export async function getIncident(acdntSn: number): Promise { + // 기본 정보 + 첫 유출건 + const baseSql = ` + SELECT a.ACDNT_SN, a.ACDNT_CD, a.ACDNT_NM, a.ACDNT_TP_CD, a.ACDNT_STTS_CD, + a.LAT, a.LNG, a.LOC_DC, a.OCCRN_DTM, a.REGION_NM, a.OFFICE_NM, + a.SVRT_CD, a.VESSEL_TP, a.PHASE_CD, a.ANALYST_NM, + s.OIL_TP_CD, s.SPIL_QTY, s.SPIL_UNIT_CD, s.FCST_HR, + COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) + + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + FROM wing.ACDNT a + LEFT JOIN LATERAL ( + SELECT OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR + FROM wing.SPIL_DATA + WHERE ACDNT_SN = a.ACDNT_SN + ORDER BY SPIL_DATA_SN + LIMIT 1 + ) s ON TRUE + LEFT JOIN wing.ACDNT_MEDIA m ON m.ACDNT_SN = a.ACDNT_SN + WHERE a.ACDNT_SN = $1 AND a.USE_YN = 'Y' + `; + + const { rows: baseRows } = await wingPool.query(baseSql, [acdntSn]); + if (baseRows.length === 0) return null; + + const r = baseRows[0] as Record; + + const predictions = await listIncidentPredictions(acdntSn); + const weather = await getIncidentWeather(acdntSn); + const media = await getIncidentMedia(acdntSn); + + return { + acdntSn: r.acdnt_sn as number, + acdntCd: r.acdnt_cd as string, + acdntNm: r.acdnt_nm as string, + acdntTpCd: r.acdnt_tp_cd as string, + acdntSttsCd: r.acdnt_stts_cd as string, + lat: parseFloat(r.lat as string), + lng: parseFloat(r.lng as string), + locDc: r.loc_dc as string, + occrnDtm: (r.occrn_dtm as Date).toISOString(), + regionNm: r.region_nm as string, + officeNm: r.office_nm as string, + svrtCd: (r.svrt_cd as string) ?? null, + vesselTp: (r.vessel_tp as string) ?? null, + phaseCd: r.phase_cd as string, + analystNm: (r.analyst_nm as string) ?? null, + oilTpCd: (r.oil_tp_cd as string) ?? null, + spilQty: r.spil_qty != null ? parseFloat(r.spil_qty as string) : null, + spilUnitCd: (r.spil_unit_cd as string) ?? null, + fcstHr: (r.fcst_hr as number) ?? null, + mediaCnt: Number(r.media_cnt), + predictions, + weather, + media, + }; +} + +// ============================================================ +// 예측 실행 목록 조회 +// ============================================================ +export async function listIncidentPredictions(acdntSn: number): Promise { + const sql = ` + SELECT PRED_EXEC_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC + FROM wing.PRED_EXEC + WHERE ACDNT_SN = $1 + ORDER BY ALGO_CD + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + + return rows.map((r: Record) => ({ + predExecSn: r.pred_exec_sn as number, + algoCd: r.algo_cd as string, + execSttsCd: r.exec_stts_cd as string, + bgngDtm: r.bgng_dtm ? (r.bgng_dtm as Date).toISOString() : null, + cmplDtm: r.cmpl_dtm ? (r.cmpl_dtm as Date).toISOString() : null, + reqdSec: (r.reqd_sec as number) ?? null, + })); +} + +// ============================================================ +// 기상정보 조회 (최신 1건) +// ============================================================ +export async function getIncidentWeather(acdntSn: number): Promise { + const sql = ` + SELECT LOC_NM, OBS_DTM, ICON, TEMP, WEATHER_DC, WIND, WAVE, + HUMID, VIS, SST, TIDE, HIGH_TIDE, LOW_TIDE, FORECAST, IMPACT_DC + FROM wing.ACDNT_WEATHER + WHERE ACDNT_SN = $1 + ORDER BY OBS_DTM DESC + LIMIT 1 + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + if (rows.length === 0) return null; + + const r = rows[0] as Record; + + return { + locNm: r.loc_nm as string, + obsDtm: (r.obs_dtm as Date).toISOString(), + icon: r.icon as string, + temp: r.temp as string, + weatherDc: r.weather_dc as string, + wind: r.wind as string, + wave: r.wave as string, + humid: r.humid as string, + vis: r.vis as string, + sst: r.sst as string, + tide: r.tide as string, + highTide: r.high_tide as string, + lowTide: r.low_tide as string, + forecast: (r.forecast as Array<{ hour: string; icon: string; temp: string }>) ?? [], + impactDc: r.impact_dc as string, + }; +} + +// ============================================================ +// 미디어 정보 조회 +// ============================================================ +export async function getIncidentMedia(acdntSn: number): Promise { + const sql = ` + SELECT PHOTO_CNT, VIDEO_CNT, SAT_CNT, CCTV_CNT, + PHOTO_META, DRONE_META, SAT_META, CCTV_META + FROM wing.ACDNT_MEDIA + WHERE ACDNT_SN = $1 + LIMIT 1 + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + if (rows.length === 0) return null; + + const r = rows[0] as Record; + + return { + photoCnt: (r.photo_cnt as number) ?? 0, + videoCnt: (r.video_cnt as number) ?? 0, + satCnt: (r.sat_cnt as number) ?? 0, + cctvCnt: (r.cctv_cnt as number) ?? 0, + photoMeta: (r.photo_meta as Record) ?? null, + droneMeta: (r.drone_meta as Record) ?? null, + satMeta: (r.sat_meta as Record) ?? null, + cctvMeta: (r.cctv_meta as Record) ?? null, + }; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index f32fe0d..8474690 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -17,6 +17,7 @@ import boardRouter from './board/boardRouter.js' import hnsRouter from './hns/hnsRouter.js' import reportsRouter from './reports/reportsRouter.js' import assetsRouter from './assets/assetsRouter.js' +import incidentsRouter from './incidents/incidentsRouter.js' import { sanitizeBody, sanitizeQuery, @@ -145,6 +146,7 @@ app.use('/api/simulation', simulationLimiter, simulationRouter) app.use('/api/hns', hnsRouter) app.use('/api/reports', reportsRouter) app.use('/api/assets', assetsRouter) +app.use('/api/incidents', incidentsRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/009_incidents.sql b/database/migration/009_incidents.sql new file mode 100644 index 0000000..85a36de --- /dev/null +++ b/database/migration/009_incidents.sql @@ -0,0 +1,230 @@ +-- ============================================================ +-- 009_incidents.sql — 사고관리(Incidents) 탭 테이블 + 초기 데이터 +-- ============================================================ + +-- 1. 사고 (ACDNT) +CREATE TABLE IF NOT EXISTS ACDNT ( + ACDNT_SN SERIAL NOT NULL, + ACDNT_CD VARCHAR(20) NOT NULL, + ACDNT_NM VARCHAR(200) NOT NULL, + ACDNT_TP_CD VARCHAR(50) NOT NULL, + ACDNT_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', + LAT NUMERIC(9,6), + LNG NUMERIC(10,6), + LOC_DC VARCHAR(200), + OCCRN_DTM TIMESTAMPTZ NOT NULL, + RPT_DTM TIMESTAMPTZ, + REGION_NM VARCHAR(20), + OFFICE_NM VARCHAR(30), + SVRT_CD VARCHAR(10), + VESSEL_TP VARCHAR(30), + PHASE_CD VARCHAR(20) DEFAULT 'RESPONSE', + ANALYST_NM VARCHAR(50), + RGTR_ID UUID, + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ, + CONSTRAINT PK_ACDNT PRIMARY KEY (ACDNT_SN), + CONSTRAINT UK_ACDNT_CD UNIQUE (ACDNT_CD), + CONSTRAINT CK_ACDNT_STTS CHECK (ACDNT_STTS_CD IN ('ACTIVE','INVESTIGATING','CLOSED')), + CONSTRAINT CK_ACDNT_SVRT CHECK (SVRT_CD IS NULL OR SVRT_CD IN ('DANGER','ALERT','CAUTION','INTEREST')), + CONSTRAINT CK_ACDNT_PHASE CHECK (PHASE_CD IS NULL OR PHASE_CD IN ('RESPONSE','STANDBY','CLOSED')) +); + +CREATE INDEX IF NOT EXISTS IDX_ACDNT_STTS ON ACDNT(ACDNT_STTS_CD); +CREATE INDEX IF NOT EXISTS IDX_ACDNT_OCCRN ON ACDNT(OCCRN_DTM DESC); +CREATE INDEX IF NOT EXISTS IDX_ACDNT_REGION ON ACDNT(REGION_NM); + +-- 2. 유출정보 (SPIL_DATA) +CREATE TABLE IF NOT EXISTS SPIL_DATA ( + SPIL_DATA_SN SERIAL NOT NULL, + ACDNT_SN INTEGER NOT NULL, + OIL_TP_CD VARCHAR(50) NOT NULL, + SPIL_QTY NUMERIC(12,2), + SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', + SPIL_TP_CD VARCHAR(20), + FCST_HR INTEGER, + REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN), + CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS IDX_SPIL_ACDNT ON SPIL_DATA(ACDNT_SN); + +-- 3. 예측실행 (PRED_EXEC) +CREATE TABLE IF NOT EXISTS PRED_EXEC ( + PRED_EXEC_SN SERIAL NOT NULL, + ACDNT_SN INTEGER NOT NULL, + ALGO_CD VARCHAR(20) NOT NULL, + EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', + BGNG_DTM TIMESTAMPTZ, + CMPL_DTM TIMESTAMPTZ, + REQD_SEC INTEGER, + RSLT_DATA JSONB, + ERR_MSG TEXT, + CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN), + CONSTRAINT FK_PRED_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED')) +); + +CREATE INDEX IF NOT EXISTS IDX_PRED_ACDNT ON PRED_EXEC(ACDNT_SN); + +-- 4. 사고별 기상정보 스냅샷 (ACDNT_WEATHER) +CREATE TABLE IF NOT EXISTS ACDNT_WEATHER ( + WEATHER_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER NOT NULL REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + LOC_NM VARCHAR(100), + OBS_DTM TIMESTAMPTZ, + ICON VARCHAR(10), + TEMP VARCHAR(20), + WEATHER_DC VARCHAR(50), + WIND VARCHAR(30), + WAVE VARCHAR(20), + HUMID VARCHAR(20), + VIS VARCHAR(20), + SST VARCHAR(20), + TIDE VARCHAR(30), + HIGH_TIDE VARCHAR(30), + LOW_TIDE VARCHAR(30), + FORECAST JSONB, + IMPACT_DC TEXT, + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS IDX_WEATHER_ACDNT ON ACDNT_WEATHER(ACDNT_SN); + +-- 5. 사고별 미디어 메타데이터 (ACDNT_MEDIA) +CREATE TABLE IF NOT EXISTS ACDNT_MEDIA ( + MEDIA_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER NOT NULL REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + PHOTO_CNT SMALLINT DEFAULT 0, + VIDEO_CNT SMALLINT DEFAULT 0, + SAT_CNT SMALLINT DEFAULT 0, + CCTV_CNT SMALLINT DEFAULT 0, + PHOTO_META JSONB, + DRONE_META JSONB, + SAT_META JSONB, + CCTV_META JSONB, + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS IDX_MEDIA_ACDNT ON ACDNT_MEDIA(ACDNT_SN); + +-- ============================================================ +-- 초기 데이터: IncidentsView mockIncidents (6건) +-- ============================================================ +INSERT INTO ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD, LAT, LNG, LOC_DC, OCCRN_DTM, REGION_NM, OFFICE_NM, SVRT_CD, VESSEL_TP, PHASE_CD, ANALYST_NM) +VALUES + ('INC-2026-0001', '여수항 유류유출', '충돌/좌초', 'ACTIVE', 34.74, 127.68, '여수항 인근 해역', '2026-02-18 15:01+09', '남해청', '여수서', 'DANGER', '유조선', 'RESPONSE', '남해청, 방재과'), + ('INC-2026-0002', '군산항 인근 오염', '원인미상', 'INVESTIGATING', 35.97, 126.72, '군산항 인근 해역', '2026-02-18 13:01+09', '서해청', '군산서', 'CAUTION', NULL, 'RESPONSE', '서해청, 군산지'), + ('INC-2026-0003', '통영 해역 기름오염', '화물/하역', 'ACTIVE', 34.85, 128.43, '통영 해역', '2026-02-18 13:31+09', '남해청', '통영서', 'ALERT', '화물선', 'RESPONSE', '남해청, 통영지'), + ('INC-2026-0004', '동해항 유출사고', '충돌/좌초', 'CLOSED', 37.49, 129.11, '동해항', '2026-02-15 13:30+09', '동해청', '동해서', 'INTEREST', '유조선', 'CLOSED', '동해청, 포항지'), + ('INC-2026-0005', '사곡해수욕장 해양오염', '원인미상', 'INVESTIGATING', 34.32, 126.76, '사곡해수욕장', '2026-02-12 09:20+09', '남해청', '완도서', 'CAUTION', NULL, 'STANDBY', '남해청, 완도지'), + ('INC-2026-0006', '제주항 부두 유출', '항만/배관', 'CLOSED', 33.51, 126.53, '제주항 부두', '2026-02-10 11:00+09', '제주청', '제주서', 'INTEREST', NULL, 'CLOSED', '제주청, 제주지'); + +-- IncidentTable mock 추가 (중복 안 되는 4건만) +INSERT INTO ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD, LAT, LNG, LOC_DC, OCCRN_DTM, REGION_NM, OFFICE_NM, SVRT_CD, VESSEL_TP, PHASE_CD, ANALYST_NM) +VALUES + ('INC-2025-0004', '인천항 기름선 파손', '충돌/좌초', 'ACTIVE', 37.45, 126.60, '인천항', '2025-02-05 11:40+09', '중부청', '인천서', 'ALERT', '유조선', 'RESPONSE', '중부청, 인천지'), + ('INC-2025-0006', '포항 영일만 탱커', '충돌/좌초', 'ACTIVE', 36.02, 129.38, '포항 영일만', '2025-01-25 16:00+09', '동해청', '포항서', 'DANGER', '유조선', 'RESPONSE', '동해청, 포항지'), + ('INC-2025-0007', '목포 벙커링 유출', '화물/하역', 'ACTIVE', 34.79, 126.38, '목포항', '2025-01-20 13:10+09', '서해청', '목포서', 'CAUTION', '예인선', 'RESPONSE', '서해청, 목포지'), + ('INC-2025-0008', '부산 감천항 충돌', '충돌/좌초', 'ACTIVE', 35.08, 129.01, '부산 감천항', '2025-01-15 22:10+09', '남해청', '부산서', 'CAUTION', '화물선', 'RESPONSE', '남해청, 부산지'), + ('INC-2025-0009', '태안 해역 유출', '충돌/좌초', 'CLOSED', 36.77, 126.13, '태안 해역', '2025-01-12 04:45+09', '중부청', '태안서', 'DANGER', '유조선', 'RESPONSE', '중부청, 태안지'), + ('INC-2025-0010', '울산항 윤활유 유출', '화물/하역', 'CLOSED', 35.50, 129.39, '울산항', '2025-01-08 10:30+09', '남해청', '울산서', 'INTEREST', '화물선', 'STANDBY', '남해청, 울산지'); + +-- ============================================================ +-- 유출정보 (IncidentTable 기준) +-- ============================================================ +INSERT INTO SPIL_DATA (ACDNT_SN, OIL_TP_CD, SPIL_QTY, SPIL_UNIT_CD, FCST_HR) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), 'BUNKER_C', 350.0, 'KL', 72), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0002'), 'UNKNOWN', NULL, 'KL', NULL), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), 'DIESEL', 120.0, 'KL', 48), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0004'), 'HEAVY_FUEL_OIL', NULL, 'KL', 72), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0005'), 'UNKNOWN', NULL, 'KL', NULL), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0006'), 'DIESEL', NULL, 'KL', NULL), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0004'), 'BUNKER_C', 85.0, 'KL', 48), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0006'), 'CRUDE_OIL', 220.0, 'KL', 72), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0007'), 'BUNKER_C', 95.0, 'KL', 48), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0008'), 'BUNKER_C', 28.0, 'KL', 12), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0009'), 'CRUDE_OIL', 1200.0, 'KL', 72), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0010'), 'LUBE_OIL', 12.5, 'KL', 24); + +-- ============================================================ +-- 예측실행 (IncidentTable 각 사고별 KOSPS/POSEIDON/OpenDrift 3건씩) +-- ============================================================ +-- INC-2026-0001 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), 'KOSPS', 'COMPLETED', '2026-02-18 15:10+09', '2026-02-18 15:25+09', 900), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), 'POSEIDON', 'COMPLETED', '2026-02-18 15:10+09', '2026-02-18 15:30+09', 1200), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), 'OPENDRIFT', 'COMPLETED', '2026-02-18 15:10+09', '2026-02-18 15:28+09', 1080); +-- INC-2026-0003 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), 'KOSPS', 'COMPLETED', '2026-02-18 14:00+09', '2026-02-18 14:18+09', 1080), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), 'POSEIDON', 'RUNNING', '2026-02-18 14:00+09', NULL, NULL), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), 'OPENDRIFT', 'COMPLETED', '2026-02-18 14:00+09', '2026-02-18 14:20+09', 1200); +-- INC-2025-0004 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0004'), 'KOSPS', 'COMPLETED', '2025-02-05 12:00+09', '2025-02-05 12:15+09', 900), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0004'), 'POSEIDON', 'COMPLETED', '2025-02-05 12:00+09', '2025-02-05 12:20+09', 1200), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0004'), 'OPENDRIFT', 'COMPLETED', '2025-02-05 12:00+09', '2025-02-05 12:18+09', 1080); +-- INC-2025-0006 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0006'), 'KOSPS', 'COMPLETED', '2025-01-25 16:30+09', '2025-01-25 16:45+09', 900), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0006'), 'POSEIDON', 'COMPLETED', '2025-01-25 16:30+09', '2025-01-25 16:50+09', 1200), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0006'), 'OPENDRIFT', 'COMPLETED', '2025-01-25 16:30+09', '2025-01-25 16:48+09', 1080); +-- INC-2025-0009 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0009'), 'KOSPS', 'COMPLETED', '2025-01-12 05:00+09', '2025-01-12 05:15+09', 900), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0009'), 'POSEIDON', 'COMPLETED', '2025-01-12 05:00+09', '2025-01-12 05:20+09', 1200), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0009'), 'OPENDRIFT', 'COMPLETED', '2025-01-12 05:00+09', '2025-01-12 05:18+09', 1080); +-- INC-2025-0010 +INSERT INTO PRED_EXEC (ACDNT_SN, ALGO_CD, EXEC_STTS_CD, BGNG_DTM, CMPL_DTM, REQD_SEC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0010'), 'KOSPS', 'COMPLETED', '2025-01-08 11:00+09', '2025-01-08 11:12+09', 720), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0010'), 'POSEIDON', 'FAILED', '2025-01-08 11:00+09', '2025-01-08 11:05+09', 300), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2025-0010'), 'OPENDRIFT', 'COMPLETED', '2025-01-08 11:00+09', '2025-01-08 11:15+09', 900); + +-- ============================================================ +-- 기상정보 (WEATHER_DATA 6건) +-- ============================================================ +INSERT INTO ACDNT_WEATHER (ACDNT_SN, LOC_NM, OBS_DTM, ICON, TEMP, WEATHER_DC, WIND, WAVE, HUMID, VIS, SST, TIDE, HIGH_TIDE, LOW_TIDE, FORECAST, IMPACT_DC) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), '여수항 사고해역', '2026-02-18 15:00+09', '⛅', '7.2°C', '구름 많음', 'SW 5.2m/s', '1.5m', '68%', '10km', '12.3°C', 'NE 0.8kn', '06:24 · 1.82m', '12:51 · 0.34m', '[{"hour":"18시","icon":"🌥","temp":"6°"},{"hour":"21시","icon":"🌧","temp":"5°"},{"hour":"00시","icon":"🌧","temp":"4°"},{"hour":"03시","icon":"🌧","temp":"3°"},{"hour":"06시","icon":"⛅","temp":"4°"},{"hour":"09시","icon":"🌤","temp":"6°"},{"hour":"12시","icon":"☀","temp":"9°"}]', '풍속 5m/s 이상 — 오일붐 전개 주의 필요. 21시 이후 강우 예보 — 해안방제 일정 조정 권고.'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0002'), '군산항 인근', '2026-02-18 13:00+09', '🌥', '4.8°C', '흐림', 'NW 6.1m/s', '2.0m', '72%', '8km', '9.5°C', 'W 0.5kn', '05:48 · 2.10m', '12:15 · 0.28m', '[{"hour":"16시","icon":"🌥","temp":"5°"},{"hour":"19시","icon":"🌧","temp":"3°"},{"hour":"22시","icon":"🌧","temp":"2°"},{"hour":"01시","icon":"🌨","temp":"0°"},{"hour":"04시","icon":"🌨","temp":"-1°"},{"hour":"07시","icon":"⛅","temp":"1°"},{"hour":"10시","icon":"🌤","temp":"5°"}]', '풍속 6m/s — 방제선 운항 주의. 야간 강설 예보 — 갑판 작업 제한 가능.'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), '통영 해역', '2026-02-18 13:30+09', '🌤', '8.5°C', '대체로 맑음', 'SE 3.8m/s', '1.0m', '62%', '15km', '13.1°C', 'E 0.6kn', '06:05 · 1.75m', '12:32 · 0.41m', '[{"hour":"16시","icon":"🌤","temp":"8°"},{"hour":"19시","icon":"🌥","temp":"6°"},{"hour":"22시","icon":"☁","temp":"5°"},{"hour":"01시","icon":"🌥","temp":"4°"},{"hour":"04시","icon":"🌥","temp":"3°"},{"hour":"07시","icon":"⛅","temp":"5°"},{"hour":"10시","icon":"☀","temp":"10°"}]', '양호한 기상 조건. 방제 작업에 적합. 파고 1.0m 이하로 해상 작업 가능.'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0004'), '동해항', '2026-02-15 13:30+09', '🌊', '2.1°C', '강풍주의보', 'NE 9.7m/s', '3.2m', '78%', '6km', '8.2°C', 'S 1.2kn', '07:12 · 1.95m', '13:40 · 0.22m', '[{"hour":"16시","icon":"🌊","temp":"2°"},{"hour":"19시","icon":"🌊","temp":"1°"},{"hour":"22시","icon":"🌨","temp":"0°"},{"hour":"01시","icon":"🌨","temp":"-2°"},{"hour":"04시","icon":"❄","temp":"-3°"},{"hour":"07시","icon":"🌥","temp":"-1°"},{"hour":"10시","icon":"⛅","temp":"3°"}]', '풍속 9.7m/s — 해상 방제 작업 중단 권고. 파고 3m 이상 — 선박 접안 불가.'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0005'), '사곡해수욕장', '2026-02-12 09:20+09', '☀', '9.8°C', '맑음', 'S 2.4m/s', '0.8m', '55%', '20km', '14.0°C', 'SW 0.3kn', '05:55 · 1.68m', '12:20 · 0.38m', '[{"hour":"12시","icon":"☀","temp":"12°"},{"hour":"15시","icon":"🌤","temp":"11°"},{"hour":"18시","icon":"🌥","temp":"8°"},{"hour":"21시","icon":"☁","temp":"6°"},{"hour":"00시","icon":"🌥","temp":"5°"},{"hour":"03시","icon":"🌥","temp":"4°"},{"hour":"06시","icon":"🌤","temp":"7°"}]', '기상 양호 — 해안 방제 작업 최적 조건. 파고 낮아 수거 작업 유리.'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0006'), '제주항 부두', '2026-02-10 11:00+09', '🌧', '10.3°C', '비', 'W 7.5m/s', '2.5m', '85%', '4km', '15.2°C', 'NW 0.9kn', '06:38 · 2.05m', '13:05 · 0.30m', '[{"hour":"14시","icon":"🌧","temp":"10°"},{"hour":"17시","icon":"🌧","temp":"9°"},{"hour":"20시","icon":"🌥","temp":"8°"},{"hour":"23시","icon":"☁","temp":"7°"},{"hour":"02시","icon":"🌥","temp":"6°"},{"hour":"05시","icon":"⛅","temp":"7°"},{"hour":"08시","icon":"🌤","temp":"10°"}]', '강우 중 — 유흡착재 효율 저하. 풍속 7.5m/s — 오일붐 전개 주의 필요. 시정 4km — 선박 이동 주의.'); + +-- ============================================================ +-- 미디어 메타데이터 (MEDIA_DATA 6건) +-- ============================================================ +INSERT INTO ACDNT_MEDIA (ACDNT_SN, PHOTO_CNT, VIDEO_CNT, SAT_CNT, CCTV_CNT, PHOTO_META, DRONE_META, SAT_META, CCTV_META) VALUES + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0001'), 12, 3, 2, 4, + '{"title":"유출 초기 해상 촬영","date":"2026-02-18 15:30","by":"방제정 촬영","stage":"초기대응 단계","thumbCount":6}', + '{"title":"방제작업 항공촬영","device":"DJI Matrice 300","alt":"150m","duration":"08:12","stage":"방제작업 단계","videoCount":3}', + '{"title":"Sentinel-1 SAR","sensor":"Sentinel-1 / KOMPSAT-5","date":"2026-02-18 18:00 UTC","resolution":"10m","detection":"OIL SLICK DETECTED","thumbCount":2}', + '{"title":"여수항 방파제 #3","live":true,"ptz":"PTZ","angle":"352°","camCount":4,"location":"여수항 #1-#4"}'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0002'), 5, 1, 1, 2, + '{"title":"오염 현장 촬영","date":"2026-02-18 14:00","by":"해경 촬영","stage":"조사 단계","thumbCount":5}', + '{"title":"오염범위 항공촬영","device":"DJI Mavic 3","alt":"100m","duration":"05:30","stage":"조사 단계","videoCount":1}', + '{"title":"Sentinel-2 MSI","sensor":"Sentinel-2","date":"2026-02-18 10:30 UTC","resolution":"20m","detection":"OIL FILM DETECTED","thumbCount":1}', + '{"title":"군산항 CCTV #1","live":true,"ptz":"PTZ","angle":"180°","camCount":2,"location":"군산항 #1-#2"}'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0003'), 8, 2, 1, 3, + '{"title":"기름오염 현장사진","date":"2026-02-18 14:20","by":"통영서 촬영","stage":"대응 단계","thumbCount":6}', + '{"title":"오염확산 항공촬영","device":"DJI Matrice 300","alt":"120m","duration":"06:45","stage":"대응 단계","videoCount":2}', + '{"title":"KOMPSAT-5 SAR","sensor":"KOMPSAT-5","date":"2026-02-18 11:00 UTC","resolution":"5m","detection":"OIL SLICK DETECTED","thumbCount":1}', + '{"title":"통영항 CCTV #2","live":true,"ptz":"PTZ","angle":"270°","camCount":3,"location":"통영항 #1-#3"}'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0004'), 15, 3, 2, 4, + '{"title":"유출 현장 해상 촬영","date":"2026-02-15 14:00","by":"동해서 촬영","stage":"종료 단계","thumbCount":7}', + '{"title":"방제완료 확인촬영","device":"DJI Matrice 300","alt":"200m","duration":"10:20","stage":"종료확인 단계","videoCount":3}', + '{"title":"Sentinel-1 SAR","sensor":"Sentinel-1 / KOMPSAT-5","date":"2026-02-15 09:00 UTC","resolution":"10m","detection":"OIL SLICK DETECTED","thumbCount":2}', + '{"title":"동해항 CCTV #1","live":false,"ptz":"PTZ","angle":"90°","camCount":4,"location":"동해항 #1-#4"}'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0005'), 3, 1, 0, 1, + '{"title":"해안 오염 촬영","date":"2026-02-12 10:00","by":"완도서 촬영","stage":"조사 단계","thumbCount":3}', + '{"title":"해안선 항공촬영","device":"DJI Mavic 3","alt":"80m","duration":"04:15","stage":"조사 단계","videoCount":1}', + '{"title":"위성영상 없음","sensor":"—","date":"—","resolution":"—","detection":"—","thumbCount":0}', + '{"title":"사곡 해안 CCTV","live":true,"ptz":"Fixed","angle":"—","camCount":1,"location":"사곡해수욕장 #1"}'), + ((SELECT ACDNT_SN FROM ACDNT WHERE ACDNT_CD='INC-2026-0006'), 6, 2, 1, 2, + '{"title":"부두 유출 현장촬영","date":"2026-02-10 11:30","by":"제주서 촬영","stage":"종료 단계","thumbCount":6}', + '{"title":"부두 주변 항공촬영","device":"DJI Matrice 300","alt":"100m","duration":"06:00","stage":"종료 단계","videoCount":2}', + '{"title":"Sentinel-2 MSI","sensor":"Sentinel-2","date":"2026-02-10 09:30 UTC","resolution":"20m","detection":"OIL FILM DETECTED","thumbCount":1}', + '{"title":"제주항 CCTV #1","live":false,"ptz":"PTZ","angle":"210°","camCount":2,"location":"제주항 #1-#2"}'); diff --git a/frontend/src/tabs/incidents/components/IncidentTable.tsx b/frontend/src/tabs/incidents/components/IncidentTable.tsx index 87dcdae..0614882 100755 --- a/frontend/src/tabs/incidents/components/IncidentTable.tsx +++ b/frontend/src/tabs/incidents/components/IncidentTable.tsx @@ -1,220 +1,21 @@ -import { useState } from 'react' - -interface Incident { - id: number - name: string - occurredAt: string - createdAt: string - modelType: 'KOSPS' | 'NOAA' - duration: string - oilType: string - volume: number - vesselType: string - kospsStatus: 'completed' | 'running' | 'pending' | 'error' - poseidonStatus: 'completed' | 'running' | 'pending' | 'error' - opendriftStatus: 'completed' | 'running' | 'pending' | 'error' - phase: string - analyst: string -} - -// Mock 데이터 -const mockIncidents: Incident[] = [ - { - id: 1, - name: '여수 유조선 충돌', - occurredAt: '2025-02-18 06:30', - createdAt: '2025-02-18', - modelType: 'KOSPS', - duration: '72H', - oilType: 'BUNKER_C', - volume: 350.0, - vesselType: '유조선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'completed', - phase: '대응', - analyst: '남해청, 방재과', - }, - { - id: 2, - name: '통영 화물선 파손', - occurredAt: '2025-02-08 14:20', - createdAt: '2025-02-08', - modelType: 'KOSPS', - duration: '48H', - oilType: 'DIESEL', - volume: 120.0, - vesselType: '화물선', - kospsStatus: 'completed', - poseidonStatus: 'running', - opendriftStatus: 'completed', - phase: '대기', - analyst: '남해청, 통영지', - }, - { - id: 3, - name: '군산항 송유관 파열', - occurredAt: '2025-02-09 09:15', - createdAt: '2025-02-09', - modelType: 'NOAA', - duration: '72H', - oilType: 'CRUDE_OIL', - volume: 580.0, - vesselType: '육상시설', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'running', - phase: '대응', - analyst: '서해청, 군산지', - }, - { - id: 4, - name: '인천항 기름선 파손', - occurredAt: '2025-02-05 11:40', - createdAt: '2025-02-05', - modelType: 'KOSPS', - duration: '48H', - oilType: 'BUNKER_C', - volume: 85.0, - vesselType: '유조선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'completed', - phase: '대응', - analyst: '중부청, 인천지', - }, - { - id: 5, - name: '제주 담배 해양사', - occurredAt: '2025-01-28 07:50', - createdAt: '2025-01-28', - modelType: 'KOSPS', - duration: '24H', - oilType: 'DIESEL', - volume: 45.0, - vesselType: '어선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'error', - phase: '대기', - analyst: '제주청, 제주지', - }, - { - id: 6, - name: '포항 영일만 탱커', - occurredAt: '2025-01-25 16:00', - createdAt: '2025-01-25', - modelType: 'NOAA', - duration: '72H', - oilType: 'CRUDE_OIL', - volume: 220.0, - vesselType: '유조선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'completed', - phase: '대응', - analyst: '동해청, 포항지', - }, - { - id: 7, - name: '목포 벙커링 유출', - occurredAt: '2025-01-20 13:10', - createdAt: '2025-01-20', - modelType: 'KOSPS', - duration: '48H', - oilType: 'BUNKER_C', - volume: 95.0, - vesselType: '예인선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'completed', - phase: '대응', - analyst: '서해청, 목포지', - }, - { - id: 8, - name: '부산 감천항 충돌', - occurredAt: '2025-01-15 22:10', - createdAt: '2025-01-14', - modelType: 'KOSPS', - duration: '12H', - oilType: 'BUNKER_C', - volume: 28.0, - vesselType: '화물선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'running', - phase: '대응', - analyst: '남해청, 부산지', - }, - { - id: 9, - name: '태안 해역 유출', - occurredAt: '2025-01-12 04:45', - createdAt: '2025-01-12', - modelType: 'NOAA', - duration: '72H', - oilType: 'CRUDE_OIL', - volume: 1200.0, - vesselType: '유조선', - kospsStatus: 'completed', - poseidonStatus: 'completed', - opendriftStatus: 'completed', - phase: '대응', - analyst: '중부청, 태안지', - }, - { - id: 10, - name: '울산항 윤활유 유출', - occurredAt: '2025-01-08 10:30', - createdAt: '2025-01-08', - modelType: 'KOSPS', - duration: '24H', - oilType: 'LUBE_OIL', - volume: 12.5, - vesselType: '화물선', - kospsStatus: 'completed', - poseidonStatus: 'error', - opendriftStatus: 'completed', - phase: '대기', - analyst: '남해청, 울산지', - }, -] +import { useState, useEffect } from 'react' +import type { IncidentListItem } from '../services/incidentsApi' +import { fetchIncidentsRaw } from '../services/incidentsApi' export function IncidentTable() { - const [incidents] = useState(mockIncidents) + const [incidents, setIncidents] = useState([]) const [searchTerm, setSearchTerm] = useState('') - const getStatusBadge = (status: string) => { - switch (status) { - case 'completed': - return ( - - 완료 - - ) - case 'running': - return ( - - 실행중 - - ) - case 'pending': - return ( - - 대기 - - ) - case 'error': - return ( - - 오류 - - ) - default: - return null - } - } + useEffect(() => { + fetchIncidentsRaw() + .then(setIncidents) + .catch(() => setIncidents([])) + }, []); + + const filteredIncidents = incidents.filter((inc) => { + if (!searchTerm) return true; + return inc.acdntNm.toLowerCase().includes(searchTerm.toLowerCase()); + }); return (
@@ -222,7 +23,7 @@ export function IncidentTable() {

유출유 확산 예측 목록

-

총 {incidents.length}건

+

총 {filteredIncidents.length}건

@@ -524,7 +443,7 @@ const WeatherPopup = forwardRef{data.icon}
{data.temp}
-
{data.desc}
+
{data.weatherDc}
@@ -548,7 +467,7 @@ const WeatherPopup = forwardRef
고조 (만조)
-
{data.high}
+
{data.highTide}
저조 (간조)
-
{data.low}
+
{data.lowTide}
@@ -584,7 +503,7 @@ const WeatherPopup = forwardRef
⚠ 방제 작업 영향
-
{data.impact}
+
{data.impactDc}
diff --git a/frontend/src/tabs/incidents/components/IncidentsView.tsx b/frontend/src/tabs/incidents/components/IncidentsView.tsx index 0c4a17d..7cba78d 100755 --- a/frontend/src/tabs/incidents/components/IncidentsView.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsView.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from 'react' +import { useState, useEffect, useMemo } from 'react' import { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaflet' import L from 'leaflet' import type { LatLngExpression } from 'leaflet' @@ -6,46 +6,8 @@ import 'leaflet/dist/leaflet.css' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel' import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData' - -// Mock incident data (HTML 참고 6건) -const mockIncidents: Incident[] = [ - { - id: '1', name: '여수항 유류유출', status: 'active', - date: '2026-02-18', time: '15:01', region: '남해청', office: '여수서', - location: { lat: 34.74, lon: 127.68 }, - causeType: '충돌/좌초', oilType: 'BUNKER_C', prediction: '예측완료', mediaCount: 4, - }, - { - id: '2', name: '군산항 인근 오염', status: 'investigating', - date: '2026-02-18', time: '13:01', region: '서해청', office: '군산서', - location: { lat: 35.97, lon: 126.72 }, - causeType: '원인미상', mediaCount: 2, - }, - { - id: '3', name: '통영 해역 기름오염', status: 'active', - date: '2026-02-18', time: '13:31', region: '남해청', office: '통영서', - location: { lat: 34.85, lon: 128.43 }, - causeType: '화물/하역', oilType: 'DIESEL', prediction: '예측완료', mediaCount: 3, - }, - { - id: '4', name: '동해항 유출사고', status: 'closed', - date: '2026-02-15', time: '13:30', region: '동해청', office: '동해서', - location: { lat: 37.49, lon: 129.11 }, - causeType: '충돌/좌초', oilType: 'HEAVY_FUEL_OIL', prediction: '예측완료', mediaCount: 5, - }, - { - id: '5', name: '사곡해수욕장 해양오염', status: 'investigating', - date: '2026-02-12', time: '09:20', region: '남해청', office: '완도서', - location: { lat: 34.32, lon: 126.76 }, - causeType: '원인미상', mediaCount: 1, - }, - { - id: '6', name: '제주항 부두 유출', status: 'closed', - date: '2026-02-10', time: '11:00', region: '제주청', office: '제주서', - location: { lat: 33.51, lon: 126.53 }, - causeType: '항만/배관', oilType: 'DIESEL', mediaCount: 2, - }, -] +import { fetchIncidents } from '../services/incidentsApi' +import type { IncidentCompat } from '../services/incidentsApi' /* ── Vessel DivIcon ──────────────────────────────── */ function makeVesselIcon(v: Vessel) { @@ -64,7 +26,8 @@ function makeVesselIcon(v: Vessel) { IncidentsView ════════════════════════════════════════════════════ */ export function IncidentsView() { - const [selectedIncidentId, setSelectedIncidentId] = useState(mockIncidents[0].id) + const [incidents, setIncidents] = useState([]) + const [selectedIncidentId, setSelectedIncidentId] = useState(null) const [selectedVessel, setSelectedVessel] = useState(null) const [detailVessel, setDetailVessel] = useState(null) @@ -73,8 +36,17 @@ export function IncidentsView() { const [analysisActive, setAnalysisActive] = useState(false) const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([]) + useEffect(() => { + fetchIncidents().then((data) => { + setIncidents(data); + if (data.length > 0) { + setSelectedIncidentId(data[0].id); + } + }); + }, []); + const mapCenter: LatLngExpression = [35.0, 127.8] - const selectedIncident = mockIncidents.find((i) => i.id === selectedIncidentId) ?? null + const selectedIncident = incidents.find((i) => i.id === selectedIncidentId) ?? null const vesselIcons = useMemo(() => mockVessels.map((v) => makeVesselIcon(v)), []) @@ -108,7 +80,7 @@ export function IncidentsView() {
{/* Left Panel */} @@ -177,7 +149,7 @@ export function IncidentsView() { url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" attribution='© OpenStreetMap' /> - {mockIncidents.map((inc) => { + {incidents.map((inc) => { const c = getMarkerColor(inc.status) const sel = selectedIncidentId === inc.id return ( diff --git a/frontend/src/tabs/incidents/components/MediaModal.tsx b/frontend/src/tabs/incidents/components/MediaModal.tsx index 72993a0..5f71937 100755 --- a/frontend/src/tabs/incidents/components/MediaModal.tsx +++ b/frontend/src/tabs/incidents/components/MediaModal.tsx @@ -1,5 +1,7 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import type { Incident } from './IncidentsLeftPanel' +import { fetchIncidentMedia } from '../services/incidentsApi' +import type { MediaInfo } from '../services/incidentsApi' type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv' @@ -11,61 +13,19 @@ const MEDIA_TABS: { id: MediaTab; label: string; icon: string }[] = [ { id: 'cctv', label: 'CCTV', icon: '📹' }, ] -/* ── Mock media data per incident ─────────────────── */ -interface IncidentMedia { - photos: number - videos: number - satellites: number - cctvs: number - photoMeta: { title: string; date: string; by: string; stage: string; thumbCount: number } - droneMeta: { title: string; device: string; alt: string; duration: string; stage: string; videoCount: number } - satMeta: { title: string; sensor: string; date: string; resolution: string; detection: string; thumbCount: number } - cctvMeta: { title: string; live: boolean; ptz: string; angle: string; camCount: number; location: string } +function str(obj: Record | null, key: string, fallback = '—'): string { + if (!obj || obj[key] == null) return fallback; + return String(obj[key]); } -const MEDIA_DATA: Record = { - '1': { - photos: 12, videos: 3, satellites: 2, cctvs: 4, - photoMeta: { title: '유출 초기 해상 촬영', date: '2026-02-18 15:30', by: '방제정 촬영', stage: '초기대응 단계', thumbCount: 6 }, - droneMeta: { title: '방제작업 항공촬영', device: 'DJI Matrice 300', alt: '150m', duration: '08:12', stage: '방제작업 단계', videoCount: 3 }, - satMeta: { title: 'Sentinel-1 SAR', sensor: 'Sentinel-1 / KOMPSAT-5', date: '2026-02-18 18:00 UTC', resolution: '10m', detection: 'OIL SLICK DETECTED', thumbCount: 2 }, - cctvMeta: { title: '여수항 방파제 #3', live: true, ptz: 'PTZ', angle: '352°', camCount: 4, location: '여수항 #1-#4' }, - }, - '2': { - photos: 5, videos: 1, satellites: 1, cctvs: 2, - photoMeta: { title: '오염 현장 촬영', date: '2026-02-18 14:00', by: '해경 촬영', stage: '조사 단계', thumbCount: 5 }, - droneMeta: { title: '오염범위 항공촬영', device: 'DJI Mavic 3', alt: '100m', duration: '05:30', stage: '조사 단계', videoCount: 1 }, - satMeta: { title: 'Sentinel-2 MSI', sensor: 'Sentinel-2', date: '2026-02-18 10:30 UTC', resolution: '20m', detection: 'OIL FILM DETECTED', thumbCount: 1 }, - cctvMeta: { title: '군산항 CCTV #1', live: true, ptz: 'PTZ', angle: '180°', camCount: 2, location: '군산항 #1-#2' }, - }, - '3': { - photos: 8, videos: 2, satellites: 1, cctvs: 3, - photoMeta: { title: '기름오염 현장사진', date: '2026-02-18 14:20', by: '통영서 촬영', stage: '대응 단계', thumbCount: 6 }, - droneMeta: { title: '오염확산 항공촬영', device: 'DJI Matrice 300', alt: '120m', duration: '06:45', stage: '대응 단계', videoCount: 2 }, - satMeta: { title: 'KOMPSAT-5 SAR', sensor: 'KOMPSAT-5', date: '2026-02-18 11:00 UTC', resolution: '5m', detection: 'OIL SLICK DETECTED', thumbCount: 1 }, - cctvMeta: { title: '통영항 CCTV #2', live: true, ptz: 'PTZ', angle: '270°', camCount: 3, location: '통영항 #1-#3' }, - }, - '4': { - photos: 15, videos: 3, satellites: 2, cctvs: 4, - photoMeta: { title: '유출 현장 해상 촬영', date: '2026-02-15 14:00', by: '동해서 촬영', stage: '종료 단계', thumbCount: 7 }, - droneMeta: { title: '방제완료 확인촬영', device: 'DJI Matrice 300', alt: '200m', duration: '10:20', stage: '종료확인 단계', videoCount: 3 }, - satMeta: { title: 'Sentinel-1 SAR', sensor: 'Sentinel-1 / KOMPSAT-5', date: '2026-02-15 09:00 UTC', resolution: '10m', detection: 'OIL SLICK DETECTED', thumbCount: 2 }, - cctvMeta: { title: '동해항 CCTV #1', live: false, ptz: 'PTZ', angle: '90°', camCount: 4, location: '동해항 #1-#4' }, - }, - '5': { - photos: 3, videos: 1, satellites: 0, cctvs: 1, - photoMeta: { title: '해안 오염 촬영', date: '2026-02-12 10:00', by: '완도서 촬영', stage: '조사 단계', thumbCount: 3 }, - droneMeta: { title: '해안선 항공촬영', device: 'DJI Mavic 3', alt: '80m', duration: '04:15', stage: '조사 단계', videoCount: 1 }, - satMeta: { title: '위성영상 없음', sensor: '—', date: '—', resolution: '—', detection: '—', thumbCount: 0 }, - cctvMeta: { title: '사곡 해안 CCTV', live: true, ptz: 'Fixed', angle: '—', camCount: 1, location: '사곡해수욕장 #1' }, - }, - '6': { - photos: 6, videos: 2, satellites: 1, cctvs: 2, - photoMeta: { title: '부두 유출 현장촬영', date: '2026-02-10 11:30', by: '제주서 촬영', stage: '종료 단계', thumbCount: 6 }, - droneMeta: { title: '부두 주변 항공촬영', device: 'DJI Matrice 300', alt: '100m', duration: '06:00', stage: '종료 단계', videoCount: 2 }, - satMeta: { title: 'Sentinel-2 MSI', sensor: 'Sentinel-2', date: '2026-02-10 09:30 UTC', resolution: '20m', detection: 'OIL FILM DETECTED', thumbCount: 1 }, - cctvMeta: { title: '제주항 CCTV #1', live: false, ptz: 'PTZ', angle: '210°', camCount: 2, location: '제주항 #1-#2' }, - }, +function num(obj: Record | null, key: string, fallback = 0): number { + if (!obj || obj[key] == null) return fallback; + return Number(obj[key]); +} + +function bool(obj: Record | null, key: string): boolean { + if (!obj || obj[key] == null) return false; + return Boolean(obj[key]); } /* ════════════════════════════════════════════════════ @@ -74,10 +34,13 @@ const MEDIA_DATA: Record = { export function MediaModal({ incident, onClose }: { incident: Incident; onClose: () => void }) { const [activeTab, setActiveTab] = useState('all') const [selectedCam, setSelectedCam] = useState(0) - const media = MEDIA_DATA[incident.id] || MEDIA_DATA['1'] - const total = media.photos + media.videos + media.satellites + media.cctvs + const [media, setMedia] = useState(null) - // Timeline mock dots + useEffect(() => { + fetchIncidentMedia(parseInt(incident.id)).then(setMedia); + }, [incident.id]); + + // Timeline dots (UI constant) const timelineDots = [ { pct: 5, color: '#ef4444', label: incident.time }, { pct: 30, color: '#ef4444', label: '' }, @@ -87,6 +50,25 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: { pct: 95, color: '#6b7280', label: '' }, ] + if (!media) { + return ( +
{ if (e.target === e.currentTarget) onClose() }} style={{ + position: 'fixed', inset: 0, zIndex: 10000, + display: 'flex', alignItems: 'center', justifyContent: 'center', + background: 'rgba(0,0,0,0.7)', backdropFilter: 'blur(6px)', + }}> +
+ 현장정보를 불러오는 중... +
+
+ ) + } + + const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt + const showPhoto = activeTab === 'all' || activeTab === 'photo' const showVideo = activeTab === 'all' || activeTab === 'video' const showSat = activeTab === 'all' || activeTab === 'satellite' @@ -118,7 +100,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: 현장정보 — {incident.name}
- {incident.name} · {incident.date} · 사진 {media.photos} / 영상 {media.videos} / 위성 {media.satellites} / CCTV {media.cctvs} + {incident.name} · {incident.date} · 사진 {media.photoCnt} / 영상 {media.videoCnt} / 위성 {media.satCnt} / CCTV {media.cctvCnt}
@@ -192,7 +174,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
📷 - 현장사진 — {media.photoMeta.title} + 현장사진 — {str(media.photoMeta, 'title', '현장 사진')}
@@ -206,13 +188,13 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} 해상 사진
- {media.photoMeta.date} · {media.photoMeta.by} + {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
{/* Thumbnails */}
- {Array.from({ length: Math.min(media.photoMeta.thumbCount, 7) }).map((_, i) => ( + {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map((_, i) => (
- 📷 사진 {media.photoMeta.thumbCount}장 · {media.photoMeta.stage} + 📷 사진 {num(media.photoMeta, 'thumbCount')}장 · {str(media.photoMeta, 'stage')} 🔗 R&D 연계
@@ -242,7 +224,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
🎬 - 드론 영상 — {media.droneMeta.title} + 드론 영상 — {str(media.droneMeta, 'title', '드론 영상')}
- {media.droneMeta.device} · {media.droneMeta.alt} 고도 + {str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')} 고도
{/* Video controls */} @@ -270,11 +252,11 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: fontSize: 12, color: '#c084fc', cursor: 'pointer', }}>▶
- 02:34 / {media.droneMeta.duration} + 02:34 / {str(media.droneMeta, 'duration')}
- 🎬 영상 {media.droneMeta.videoCount}건 · {media.droneMeta.stage} + 🎬 영상 {num(media.droneMeta, 'videoCount')}건 · {str(media.droneMeta, 'stage')}
📂 전체보기 @@ -295,7 +277,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
🛰 - 위성영상 — {media.satMeta.title} + 위성영상 — {str(media.satMeta, 'title', '위성영상')}
@@ -303,25 +285,25 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
- {media.satMeta.detection !== '—' && ( + {str(media.satMeta, 'detection') !== '—' && (
- {media.satMeta.detection} + {str(media.satMeta, 'detection')}
🛰
- {media.satMeta.title} 위성영상 + {str(media.satMeta, 'title', '위성영상')} 위성영상
- {media.satMeta.date} · 해상도 {media.satMeta.resolution} + {str(media.satMeta, 'date')} · 해상도 {str(media.satMeta, 'resolution')}
)} - {media.satMeta.detection === '—' && ( + {str(media.satMeta, 'detection') === '—' && (
🛰
위성영상 없음
@@ -329,9 +311,9 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: )}
- {media.satMeta.thumbCount > 0 && ( + {num(media.satMeta, 'thumbCount') > 0 && (
- {Array.from({ length: media.satMeta.thumbCount }).map((_, i) => ( + {Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
- 🛰 위성 {media.satMeta.thumbCount}장 · {media.satMeta.sensor} + 🛰 위성 {num(media.satMeta, 'thumbCount')}장 · {str(media.satMeta, 'sensor')} 🔍 편집/측 비교
@@ -362,11 +344,11 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
📹 - CCTV — {media.cctvMeta.title} + CCTV — {str(media.cctvMeta, 'title', 'CCTV')}
- {media.cctvMeta.live && ( + {bool(media.cctvMeta, 'live') && (
- {media.cctvMeta.live && ( + {bool(media.cctvMeta, 'live') && (
● LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
)}
📹
- {media.cctvMeta.title.replace('#', 'CCTV #')} + {str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
- {media.cctvMeta.ptz} · {media.cctvMeta.angle} · {media.cctvMeta.live ? '실시간 스트리밍' : '녹화 영상'} + {str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} · {bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
{/* CAM buttons */}
- {Array.from({ length: media.cctvMeta.camCount }).map((_, i) => ( + {Array.from({ length: num(media.cctvMeta, 'camCount') }).map((_, i) => (
- 📹 CCTV {media.cctvMeta.camCount}채널 · {media.cctvMeta.location} + 📹 CCTV {num(media.cctvMeta, 'camCount')}채널 · {str(media.cctvMeta, 'location')}
🔴 녹화영상 @@ -423,10 +405,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose: display: 'flex', alignItems: 'center', justifyContent: 'space-between', }}>
- 📷 사진 {media.photos} - 🎬 영상 {media.videos} - 🛰 위성 {media.satellites} - 📹 CCTV {media.cctvs} + 📷 사진 {media.photoCnt} + 🎬 영상 {media.videoCnt} + 🛰 위성 {media.satCnt} + 📹 CCTV {media.cctvCnt} 📎 총 {total}건
diff --git a/frontend/src/tabs/incidents/services/incidentsApi.ts b/frontend/src/tabs/incidents/services/incidentsApi.ts new file mode 100644 index 0000000..b8c4652 --- /dev/null +++ b/frontend/src/tabs/incidents/services/incidentsApi.ts @@ -0,0 +1,172 @@ +import api from '@common/services/api'; + +// ============================================================ +// 백엔드 API 응답 타입 +// ============================================================ + +export interface IncidentListItem { + acdntSn: number; + acdntCd: string; + acdntNm: string; + acdntTpCd: string; + acdntSttsCd: string; + lat: number; + lng: number; + locDc: string; + occrnDtm: string; + regionNm: string; + officeNm: string; + svrtCd: string | null; + vesselTp: string | null; + phaseCd: string; + analystNm: string | null; + oilTpCd: string | null; + spilQty: number | null; + spilUnitCd: string | null; + fcstHr: number | null; + mediaCnt: number; +} + +export interface PredExecItem { + predExecSn: number; + algoCd: string; + execSttsCd: string; + bgngDtm: string | null; + cmplDtm: string | null; + reqdSec: number | null; +} + +export interface WeatherInfo { + locNm: string; + obsDtm: string; + icon: string; + temp: string; + weatherDc: string; + wind: string; + wave: string; + humid: string; + vis: string; + sst: string; + tide: string; + highTide: string; + lowTide: string; + forecast: Array<{ hour: string; icon: string; temp: string }>; + impactDc: string; +} + +export interface MediaInfo { + photoCnt: number; + videoCnt: number; + satCnt: number; + cctvCnt: number; + photoMeta: Record | null; + droneMeta: Record | null; + satMeta: Record | null; + cctvMeta: Record | null; +} + +export interface IncidentDetail extends IncidentListItem { + predictions: PredExecItem[]; + weather: WeatherInfo | null; + media: MediaInfo | null; +} + +// ============================================================ +// 프론트 호환 타입 +// ============================================================ + +export interface IncidentCompat { + id: string; + name: string; + status: 'active' | 'investigating' | 'closed'; + date: string; + time: string; + region: string; + office: string; + location: { lat: number; lon: number }; + causeType?: string; + oilType?: string; + prediction?: string; + vesselName?: string; + mediaCount?: number; +} + +function toCompat(item: IncidentListItem): IncidentCompat { + const dt = new Date(item.occrnDtm); + const statusMap: Record = { + ACTIVE: 'active', + INVESTIGATING: 'investigating', + CLOSED: 'closed', + }; + return { + id: String(item.acdntSn), + name: item.acdntNm, + status: statusMap[item.acdntSttsCd] ?? 'active', + date: dt.toISOString().slice(0, 10), + time: dt.toTimeString().slice(0, 5), + region: item.regionNm, + office: item.officeNm, + location: { lat: item.lat, lon: item.lng }, + causeType: item.acdntTpCd, + oilType: item.oilTpCd ?? undefined, + prediction: item.fcstHr ? '예측완료' : undefined, + mediaCount: item.mediaCnt, + }; +} + +// ============================================================ +// API 호출 함수 +// ============================================================ + +export async function fetchIncidentsRaw(): Promise { + const { data } = await api.get('/incidents'); + return data; +} + +export async function fetchIncidents(filters?: { + status?: string; + region?: string; + search?: string; + startDate?: string; + endDate?: string; +}): Promise { + const params = new URLSearchParams(); + if (filters?.status) params.set('status', filters.status); + if (filters?.region) params.set('region', filters.region); + if (filters?.search) params.set('search', filters.search); + if (filters?.startDate) params.set('startDate', filters.startDate); + if (filters?.endDate) params.set('endDate', filters.endDate); + + const query = params.toString(); + const url = query ? `/incidents?${query}` : '/incidents'; + const { data } = await api.get(url); + return data.map(toCompat); +} + +export async function fetchIncidentDetail(sn: number): Promise { + const { data } = await api.get(`/incidents/${sn}`); + return data; +} + +export async function fetchIncidentWeather(sn: number): Promise { + try { + const { data } = await api.get(`/incidents/${sn}/weather`); + return data; + } catch { + return null; + } +} + +export async function fetchIncidentMedia(sn: number): Promise { + try { + const { data } = await api.get(`/incidents/${sn}/media`); + return data; + } catch { + return null; + } +} + +export async function fetchIncidentPredictions(sn: number): Promise { + const { data } = await api.get(`/incidents/${sn}/predictions`); + return data; +} -- 2.45.2