Merge pull request 'feat(incidents): 사고관리 탭 mock → DB/API 전환' (#35) from feature/incidents-crud into develop

Reviewed-on: #35
This commit is contained in:
htlee 2026-02-28 22:22:24 +09:00
커밋 d44a84e05b
9개의 변경된 파일957개의 추가작업 그리고 478개의 파일을 삭제

파일 보기

@ -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;

파일 보기

@ -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<string, unknown> | null;
droneMeta: Record<string, unknown> | null;
satMeta: Record<string, unknown> | null;
cctvMeta: Record<string, unknown> | 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<IncidentListItem[]> {
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<string, unknown>) => ({
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<IncidentDetail | null> {
// 기본 정보 + 첫 유출건
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<string, unknown>;
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<PredExecItem[]> {
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<string, unknown>) => ({
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<WeatherInfo | null> {
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<string, unknown>;
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<MediaInfo | null> {
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<string, unknown>;
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<string, unknown>) ?? null,
droneMeta: (r.drone_meta as Record<string, unknown>) ?? null,
satMeta: (r.sat_meta as Record<string, unknown>) ?? null,
cctvMeta: (r.cctv_meta as Record<string, unknown>) ?? null,
};
}

파일 보기

@ -17,6 +17,7 @@ import boardRouter from './board/boardRouter.js'
import hnsRouter from './hns/hnsRouter.js' import hnsRouter from './hns/hnsRouter.js'
import reportsRouter from './reports/reportsRouter.js' import reportsRouter from './reports/reportsRouter.js'
import assetsRouter from './assets/assetsRouter.js' import assetsRouter from './assets/assetsRouter.js'
import incidentsRouter from './incidents/incidentsRouter.js'
import { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -145,6 +146,7 @@ app.use('/api/simulation', simulationLimiter, simulationRouter)
app.use('/api/hns', hnsRouter) app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter) app.use('/api/reports', reportsRouter)
app.use('/api/assets', assetsRouter) app.use('/api/assets', assetsRouter)
app.use('/api/incidents', incidentsRouter)
// 헬스 체크 // 헬스 체크
app.get('/health', (_req, res) => { app.get('/health', (_req, res) => {

파일 보기

@ -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"}');

파일 보기

@ -1,220 +1,21 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import type { IncidentListItem } from '../services/incidentsApi'
interface Incident { import { fetchIncidentsRaw } from '../services/incidentsApi'
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: '남해청, 울산지',
},
]
export function IncidentTable() { export function IncidentTable() {
const [incidents] = useState<Incident[]>(mockIncidents) const [incidents, setIncidents] = useState<IncidentListItem[]>([])
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const getStatusBadge = (status: string) => { useEffect(() => {
switch (status) { fetchIncidentsRaw()
case 'completed': .then(setIncidents)
return ( .catch(() => setIncidents([]))
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(34,197,94,0.15)] text-green-400"> }, []);
</span> const filteredIncidents = incidents.filter((inc) => {
) if (!searchTerm) return true;
case 'running': return inc.acdntNm.toLowerCase().includes(searchTerm.toLowerCase());
return ( });
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(249,115,22,0.15)] text-orange-400">
</span>
)
case 'pending':
return (
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(138,150,168,0.15)] text-text-3">
</span>
)
case 'error':
return (
<span className="px-2 py-1 text-[10px] font-semibold rounded-md bg-[rgba(239,68,68,0.15)] text-status-red">
</span>
)
default:
return null
}
}
return ( return (
<div className="flex flex-col h-full bg-bg-0"> <div className="flex flex-col h-full bg-bg-0">
@ -222,7 +23,7 @@ export function IncidentTable() {
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<div> <div>
<h1 className="text-xl font-bold text-text-1"> </h1> <h1 className="text-xl font-bold text-text-1"> </h1>
<p className="text-sm text-text-3 mt-1"> {incidents.length}</p> <p className="text-sm text-text-3 mt-1"> {filteredIncidents.length}</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button className="px-4 py-2 text-sm font-semibold border border-border rounded-md bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1 transition-all"> <button className="px-4 py-2 text-sm font-semibold border border-border rounded-md bg-bg-3 text-text-2 hover:bg-bg-hover hover:text-text-1 transition-all">
@ -251,60 +52,42 @@ export function IncidentTable() {
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">KOSPS</th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">POSEIDON</th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">OpenDrift</th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{incidents.map((incident) => ( {filteredIncidents.map((incident) => (
<tr <tr
key={incident.id} key={incident.acdntSn}
className="hover:bg-bg-2 transition-colors cursor-pointer group" className="hover:bg-bg-2 transition-colors cursor-pointer group"
> >
<td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.id}</td> <td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.acdntSn}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" /> <span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
<span className="text-sm font-semibold text-text-1 group-hover:text-primary-cyan transition-colors"> <span className="text-sm font-semibold text-text-1 group-hover:text-primary-cyan transition-colors">
{incident.name} {incident.acdntNm}
</span> </span>
</div> </div>
</td> </td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.occurredAt}</td> <td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.occrnDtm}</td>
<td className="px-4 py-3 text-sm text-text-2">{incident.createdAt}</td> <td className="px-4 py-3 text-sm text-text-2">{incident.vesselTp ?? '—'}</td>
<td className="px-4 py-3"> <td className="px-4 py-3 text-sm text-text-2">{incident.oilTpCd ?? '—'}</td>
<span className={`px-2 py-1 text-xs font-semibold rounded-md ${
incident.modelType === 'KOSPS'
? 'bg-[rgba(6,182,212,0.15)] text-primary-cyan'
: 'bg-[rgba(239,68,68,0.15)] text-status-red'
}`}>
{incident.modelType}
</span>
</td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{incident.duration}</td>
<td className="px-4 py-3 text-sm text-text-2">{incident.oilType}</td>
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold"> <td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
{incident.volume.toFixed(2)} {incident.spilQty != null ? incident.spilQty.toFixed(2) : '—'}
</td> </td>
<td className="px-4 py-3 text-sm text-text-2">{incident.vesselType}</td> <td className="px-4 py-3 text-sm text-text-2">{incident.acdntTpCd}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(incident.kospsStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(incident.poseidonStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(incident.opendriftStatus)}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="px-2 py-1 text-xs font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400"> <span className="px-2 py-1 text-xs font-semibold rounded-md bg-[rgba(168,85,247,0.15)] text-purple-400">
{incident.phase} {incident.phaseCd}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-sm text-text-2">{incident.analyst}</td> <td className="px-4 py-3 text-sm text-text-2">{incident.analystNm ?? '—'}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

파일 보기

@ -26,99 +26,8 @@ interface IncidentsLeftPanelProps {
const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const
const REGIONS = ['전체', '남해청', '서해청', '동해청', '제주청', '중부청'] as const const REGIONS = ['전체', '남해청', '서해청', '동해청', '제주청', '중부청'] as const
/* ── Mock Weather Data ─────────────────────────────── */ import { fetchIncidentWeather } from '../services/incidentsApi'
interface IncidentWeather { import type { WeatherInfo } from '../services/incidentsApi'
loc: string
time: string
icon: string
temp: string
desc: string
wind: string
wave: string
humid: string
vis: string
sst: string
tide: string
high: string
low: string
forecast: { hour: string; icon: string; temp: string }[]
impact: string
}
const WEATHER_DATA: Record<string, IncidentWeather> = {
'1': {
loc: '여수항 사고해역', time: '2026-02-18 15:00 KST', icon: '⛅', temp: '7.2°C', desc: '구름 많음',
wind: 'SW 5.2m/s', wave: '1.5m', humid: '68%', vis: '10km', sst: '12.3°C', tide: 'NE 0.8kn',
high: '06:24 · 1.82m', low: '12:51 · 0.34m',
forecast: [
{ 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°' },
],
impact: '풍속 5m/s 이상 — 오일붐 전개 주의 필요. 21시 이후 강우 예보 — 해안방제 일정 조정 권고.',
},
'2': {
loc: '군산항 인근', time: '2026-02-18 13:00 KST', icon: '🌥', temp: '4.8°C', desc: '흐림',
wind: 'NW 6.1m/s', wave: '2.0m', humid: '72%', vis: '8km', sst: '9.5°C', tide: 'W 0.5kn',
high: '05:48 · 2.10m', low: '12:15 · 0.28m',
forecast: [
{ 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°' },
],
impact: '풍속 6m/s — 방제선 운항 주의. 야간 강설 예보 — 갑판 작업 제한 가능.',
},
'3': {
loc: '통영 해역', time: '2026-02-18 13:30 KST', icon: '🌤', temp: '8.5°C', desc: '대체로 맑음',
wind: 'SE 3.8m/s', wave: '1.0m', humid: '62%', vis: '15km', sst: '13.1°C', tide: 'E 0.6kn',
high: '06:05 · 1.75m', low: '12:32 · 0.41m',
forecast: [
{ 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°' },
],
impact: '양호한 기상 조건. 방제 작업에 적합. 파고 1.0m 이하로 해상 작업 가능.',
},
'4': {
loc: '동해항', time: '2026-02-15 13:30 KST', icon: '🌊', temp: '2.1°C', desc: '강풍주의보',
wind: 'NE 9.7m/s', wave: '3.2m', humid: '78%', vis: '6km', sst: '8.2°C', tide: 'S 1.2kn',
high: '07:12 · 1.95m', low: '13:40 · 0.22m',
forecast: [
{ 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°' },
],
impact: '풍속 9.7m/s — 해상 방제 작업 중단 권고. 파고 3m 이상 — 선박 접안 불가.',
},
'5': {
loc: '사곡해수욕장', time: '2026-02-12 09:20 KST', icon: '☀', temp: '9.8°C', desc: '맑음',
wind: 'S 2.4m/s', wave: '0.8m', humid: '55%', vis: '20km', sst: '14.0°C', tide: 'SW 0.3kn',
high: '05:55 · 1.68m', low: '12:20 · 0.38m',
forecast: [
{ 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°' },
],
impact: '기상 양호 — 해안 방제 작업 최적 조건. 파고 낮아 수거 작업 유리.',
},
'6': {
loc: '제주항 부두', time: '2026-02-10 11:00 KST', icon: '🌧', temp: '10.3°C', desc: '비',
wind: 'W 7.5m/s', wave: '2.5m', humid: '85%', vis: '4km', sst: '15.2°C', tide: 'NW 0.9kn',
high: '06:38 · 2.05m', low: '13:05 · 0.30m',
forecast: [
{ 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°' },
],
impact: '강우 중 — 유흡착재 효율 저하. 풍속 7.5m/s — 오일붐 전개 주의 필요. 시정 4km — 선박 이동 주의.',
},
}
function formatDate(d: Date) { function formatDate(d: Date) {
const y = d.getFullYear() const y = d.getFullYear()
@ -161,8 +70,18 @@ export function IncidentsLeftPanel({
// Weather popup // Weather popup
const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null) const [weatherPopupId, setWeatherPopupId] = useState<string | null>(null)
const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 }) const [weatherPos, setWeatherPos] = useState<{ top: number; left: number }>({ top: 0, left: 0 })
const [weatherInfo, setWeatherInfo] = useState<WeatherInfo | null>(null)
const weatherRef = useRef<HTMLDivElement>(null) const weatherRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!weatherPopupId) return
let cancelled = false
fetchIncidentWeather(parseInt(weatherPopupId)).then((data) => {
if (!cancelled) setWeatherInfo(data)
})
return () => { cancelled = true; setWeatherInfo(null) }
}, [weatherPopupId]);
useEffect(() => { useEffect(() => {
if (!weatherPopupId) return if (!weatherPopupId) return
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@ -438,10 +357,10 @@ export function IncidentsLeftPanel({
)} )}
{/* Weather Popup (fixed position) */} {/* Weather Popup (fixed position) */}
{weatherPopupId && WEATHER_DATA[weatherPopupId] && ( {weatherPopupId && weatherInfo && (
<WeatherPopup <WeatherPopup
ref={weatherRef} ref={weatherRef}
data={WEATHER_DATA[weatherPopupId]} data={weatherInfo}
position={weatherPos} position={weatherPos}
onClose={() => setWeatherPopupId(null)} onClose={() => setWeatherPopupId(null)}
/> />
@ -490,7 +409,7 @@ function PgBtn({ label, active, disabled, onClick }: { label: string; active?: b
WeatherPopup WeatherPopup
*/ */
const WeatherPopup = forwardRef<HTMLDivElement, { const WeatherPopup = forwardRef<HTMLDivElement, {
data: IncidentWeather data: WeatherInfo
position: { top: number; left: number } position: { top: number; left: number }
onClose: () => void onClose: () => void
}>(({ data, position, onClose }, ref) => { }>(({ data, position, onClose }, ref) => {
@ -510,8 +429,8 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 14 }}>🌤</span> <span style={{ fontSize: 14 }}>🌤</span>
<div> <div>
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>{data.loc}</div> <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fK)' }}>{data.locNm}</div>
<div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{data.time}</div> <div style={{ fontSize: 8, color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{data.obsDtm}</div>
</div> </div>
</div> </div>
<span onClick={onClose} style={{ fontSize: 14, cursor: 'pointer', color: 'var(--t3)', padding: 2 }}></span> <span onClick={onClose} style={{ fontSize: 14, cursor: 'pointer', color: 'var(--t3)', padding: 2 }}></span>
@ -524,7 +443,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<div style={{ fontSize: 28 }}>{data.icon}</div> <div style={{ fontSize: 28 }}>{data.icon}</div>
<div> <div>
<div style={{ fontSize: 20, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{data.temp}</div> <div style={{ fontSize: 20, fontWeight: 700, color: 'var(--t1)', fontFamily: 'var(--fM)' }}>{data.temp}</div>
<div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{data.desc}</div> <div style={{ fontSize: 9, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>{data.weatherDc}</div>
</div> </div>
</div> </div>
@ -548,7 +467,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<span style={{ fontSize: 12 }}></span> <span style={{ fontSize: 12 }}></span>
<div> <div>
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div> <div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div>
<div style={{ color: '#60a5fa', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.high}</div> <div style={{ color: '#60a5fa', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.highTide}</div>
</div> </div>
</div> </div>
<div style={{ <div style={{
@ -559,7 +478,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
<span style={{ fontSize: 12 }}></span> <span style={{ fontSize: 12 }}></span>
<div> <div>
<div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div> <div style={{ color: 'var(--t3)', fontSize: 7, fontFamily: 'var(--fK)' }}> ()</div>
<div style={{ color: 'var(--cyan)', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.low}</div> <div style={{ color: 'var(--cyan)', fontWeight: 700, fontFamily: 'var(--fM)', fontSize: 10 }}>{data.lowTide}</div>
</div> </div>
</div> </div>
</div> </div>
@ -584,7 +503,7 @@ const WeatherPopup = forwardRef<HTMLDivElement, {
background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)', borderRadius: 6, background: 'rgba(249,115,22,0.05)', border: '1px solid rgba(249,115,22,0.12)', borderRadius: 6,
}}> }}>
<div style={{ fontSize: 8, fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: 3 }}> </div> <div style={{ fontSize: 8, fontWeight: 700, color: 'var(--orange)', fontFamily: 'var(--fK)', marginBottom: 3 }}> </div>
<div style={{ fontSize: 8, color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.5 }}>{data.impact}</div> <div style={{ fontSize: 8, color: 'var(--t2)', fontFamily: 'var(--fK)', lineHeight: 1.5 }}>{data.impactDc}</div>
</div> </div>
</div> </div>
</div> </div>

파일 보기

@ -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 { MapContainer, TileLayer, CircleMarker, Popup, Marker } from 'react-leaflet'
import L from 'leaflet' import L from 'leaflet'
import type { LatLngExpression } from 'leaflet' import type { LatLngExpression } from 'leaflet'
@ -6,46 +6,8 @@ import 'leaflet/dist/leaflet.css'
import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel' import { IncidentsLeftPanel, type Incident } from './IncidentsLeftPanel'
import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel' import { IncidentsRightPanel, type ViewMode, type AnalysisSection } from './IncidentsRightPanel'
import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData' import { mockVessels, VESSEL_LEGEND, type Vessel } from '@common/mock/vesselMockData'
import { fetchIncidents } from '../services/incidentsApi'
// Mock incident data (HTML 참고 6건) import type { IncidentCompat } from '../services/incidentsApi'
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,
},
]
/* ── Vessel DivIcon ──────────────────────────────── */ /* ── Vessel DivIcon ──────────────────────────────── */
function makeVesselIcon(v: Vessel) { function makeVesselIcon(v: Vessel) {
@ -64,7 +26,8 @@ function makeVesselIcon(v: Vessel) {
IncidentsView IncidentsView
*/ */
export function IncidentsView() { export function IncidentsView() {
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(mockIncidents[0].id) const [incidents, setIncidents] = useState<IncidentCompat[]>([])
const [selectedIncidentId, setSelectedIncidentId] = useState<string | null>(null)
const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null) const [selectedVessel, setSelectedVessel] = useState<Vessel | null>(null)
const [detailVessel, setDetailVessel] = useState<Vessel | null>(null) const [detailVessel, setDetailVessel] = useState<Vessel | null>(null)
@ -73,8 +36,17 @@ export function IncidentsView() {
const [analysisActive, setAnalysisActive] = useState(false) const [analysisActive, setAnalysisActive] = useState(false)
const [analysisTags, setAnalysisTags] = useState<{ icon: string; label: string; color: string }[]>([]) 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 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)), []) const vesselIcons = useMemo(() => mockVessels.map((v) => makeVesselIcon(v)), [])
@ -108,7 +80,7 @@ export function IncidentsView() {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Left Panel */} {/* Left Panel */}
<IncidentsLeftPanel <IncidentsLeftPanel
incidents={mockIncidents} incidents={incidents}
selectedIncidentId={selectedIncidentId} selectedIncidentId={selectedIncidentId}
onIncidentSelect={setSelectedIncidentId} onIncidentSelect={setSelectedIncidentId}
/> />
@ -177,7 +149,7 @@ export function IncidentsView() {
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/> />
{mockIncidents.map((inc) => { {incidents.map((inc) => {
const c = getMarkerColor(inc.status) const c = getMarkerColor(inc.status)
const sel = selectedIncidentId === inc.id const sel = selectedIncidentId === inc.id
return ( return (

파일 보기

@ -1,5 +1,7 @@
import { useState } from 'react' import { useState, useEffect } from 'react'
import type { Incident } from './IncidentsLeftPanel' import type { Incident } from './IncidentsLeftPanel'
import { fetchIncidentMedia } from '../services/incidentsApi'
import type { MediaInfo } from '../services/incidentsApi'
type MediaTab = 'all' | 'photo' | 'video' | 'satellite' | 'cctv' 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: '📹' }, { id: 'cctv', label: 'CCTV', icon: '📹' },
] ]
/* ── Mock media data per incident ─────────────────── */ function str(obj: Record<string, unknown> | null, key: string, fallback = '—'): string {
interface IncidentMedia { if (!obj || obj[key] == null) return fallback;
photos: number return String(obj[key]);
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 }
} }
const MEDIA_DATA: Record<string, IncidentMedia> = { function num(obj: Record<string, unknown> | null, key: string, fallback = 0): number {
'1': { if (!obj || obj[key] == null) return fallback;
photos: 12, videos: 3, satellites: 2, cctvs: 4, return Number(obj[key]);
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 }, function bool(obj: Record<string, unknown> | null, key: string): boolean {
cctvMeta: { title: '여수항 방파제 #3', live: true, ptz: 'PTZ', angle: '352°', camCount: 4, location: '여수항 #1-#4' }, if (!obj || obj[key] == null) return false;
}, return Boolean(obj[key]);
'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' },
},
} }
/* /*
@ -74,10 +34,13 @@ const MEDIA_DATA: Record<string, IncidentMedia> = {
export function MediaModal({ incident, onClose }: { incident: Incident; onClose: () => void }) { export function MediaModal({ incident, onClose }: { incident: Incident; onClose: () => void }) {
const [activeTab, setActiveTab] = useState<MediaTab>('all') const [activeTab, setActiveTab] = useState<MediaTab>('all')
const [selectedCam, setSelectedCam] = useState(0) const [selectedCam, setSelectedCam] = useState(0)
const media = MEDIA_DATA[incident.id] || MEDIA_DATA['1'] const [media, setMedia] = useState<MediaInfo | null>(null)
const total = media.photos + media.videos + media.satellites + media.cctvs
// Timeline mock dots useEffect(() => {
fetchIncidentMedia(parseInt(incident.id)).then(setMedia);
}, [incident.id]);
// Timeline dots (UI constant)
const timelineDots = [ const timelineDots = [
{ pct: 5, color: '#ef4444', label: incident.time }, { pct: 5, color: '#ef4444', label: incident.time },
{ pct: 30, color: '#ef4444', label: '' }, { pct: 30, color: '#ef4444', label: '' },
@ -87,6 +50,25 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{ pct: 95, color: '#6b7280', label: '' }, { pct: 95, color: '#6b7280', label: '' },
] ]
if (!media) {
return (
<div onClick={(e) => { 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)',
}}>
<div style={{
width: 300, padding: 40, background: '#0d1117', border: '1px solid #30363d',
borderRadius: 14, textAlign: 'center', color: '#8b949e', fontFamily: 'var(--fK)', fontSize: 12,
}}>
...
</div>
</div>
)
}
const total = media.photoCnt + media.videoCnt + media.satCnt + media.cctvCnt
const showPhoto = activeTab === 'all' || activeTab === 'photo' const showPhoto = activeTab === 'all' || activeTab === 'photo'
const showVideo = activeTab === 'all' || activeTab === 'video' const showVideo = activeTab === 'all' || activeTab === 'video'
const showSat = activeTab === 'all' || activeTab === 'satellite' const showSat = activeTab === 'all' || activeTab === 'satellite'
@ -118,7 +100,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{incident.name} {incident.name}
</div> </div>
<div style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}> <div style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{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}
</div> </div>
</div> </div>
</div> </div>
@ -192,7 +174,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>📷</span> <span style={{ fontSize: 12 }}>📷</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{media.photoMeta.title} {str(media.photoMeta, 'title', '현장 사진')}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
@ -206,13 +188,13 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
{incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')} {incident.name.replace('유류유출', '유출 현장').replace('오염', '현장')}
</div> </div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}> <div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{media.photoMeta.date} · {media.photoMeta.by} {str(media.photoMeta, 'date')} · {str(media.photoMeta, 'by')}
</div> </div>
</div> </div>
{/* Thumbnails */} {/* Thumbnails */}
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}> <div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}> <div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
{Array.from({ length: Math.min(media.photoMeta.thumbCount, 7) }).map((_, i) => ( {Array.from({ length: Math.min(num(media.photoMeta, 'thumbCount'), 7) }).map((_, i) => (
<div key={i} style={{ <div key={i} style={{
width: 40, height: 36, borderRadius: 4, width: 40, height: 36, borderRadius: 4,
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22', background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
@ -224,7 +206,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
📷 {media.photoMeta.thumbCount} · {media.photoMeta.stage} 📷 {num(media.photoMeta, 'thumbCount')} · {str(media.photoMeta, 'stage')}
</span> </span>
<span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D </span> <span style={{ fontSize: 8, color: '#a78bfa', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔗 R&D </span>
</div> </div>
@ -242,7 +224,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>🎬</span> <span style={{ fontSize: 12 }}>🎬</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{media.droneMeta.title} {str(media.droneMeta, 'title', '드론 영상')}
</span> </span>
</div> </div>
<span style={{ <span style={{
@ -256,7 +238,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}> <div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{media.droneMeta.device} · {media.droneMeta.alt} {str(media.droneMeta, 'device')} · {str(media.droneMeta, 'alt')}
</div> </div>
</div> </div>
{/* Video controls */} {/* Video controls */}
@ -270,11 +252,11 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
fontSize: 12, color: '#c084fc', cursor: 'pointer', fontSize: 12, color: '#c084fc', cursor: 'pointer',
}}></div> }}></div>
<span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}></span> <span style={{ fontSize: 12, color: '#8b949e', cursor: 'pointer' }}></span>
<span style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>02:34 / {media.droneMeta.duration}</span> <span style={{ fontSize: 10, color: '#8b949e', fontFamily: 'var(--fM)' }}>02:34 / {str(media.droneMeta, 'duration')}</span>
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
🎬 {media.droneMeta.videoCount} · {media.droneMeta.stage} 🎬 {num(media.droneMeta, 'videoCount')} · {str(media.droneMeta, 'stage')}
</span> </span>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>📂 </span> <span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>📂 </span>
@ -295,7 +277,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>🛰</span> <span style={{ fontSize: 12 }}>🛰</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
{media.satMeta.title} {str(media.satMeta, 'title', '위성영상')}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: 4 }}> <div style={{ display: 'flex', gap: 4 }}>
@ -303,25 +285,25 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
</div> </div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
{media.satMeta.detection !== '—' && ( {str(media.satMeta, 'detection') !== '—' && (
<div style={{ <div style={{
position: 'absolute', top: '15%', left: '10%', width: '55%', height: '60%', position: 'absolute', top: '15%', left: '10%', width: '55%', height: '60%',
border: '2px dashed #ef4444', borderRadius: 4, border: '2px dashed #ef4444', borderRadius: 4,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 6,
}}> }}>
<div style={{ position: 'absolute', top: -10, left: 8, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)', background: '#0d1117', padding: '0 4px' }}> <div style={{ position: 'absolute', top: -10, left: 8, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)', background: '#0d1117', padding: '0 4px' }}>
{media.satMeta.detection} {str(media.satMeta, 'detection')}
</div> </div>
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div> <div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
<div style={{ fontSize: 11, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}> <div style={{ fontSize: 11, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
{media.satMeta.title} {str(media.satMeta, 'title', '위성영상')}
</div> </div>
<div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)' }}> <div style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{media.satMeta.date} · {media.satMeta.resolution} {str(media.satMeta, 'date')} · {str(media.satMeta, 'resolution')}
</div> </div>
</div> </div>
)} )}
{media.satMeta.detection === '—' && ( {str(media.satMeta, 'detection') === '—' && (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 40, color: '#30363d' }}>🛰</div> <div style={{ fontSize: 40, color: '#30363d' }}>🛰</div>
<div style={{ fontSize: 11, color: '#8b949e', fontFamily: 'var(--fK)', marginTop: 8 }}> </div> <div style={{ fontSize: 11, color: '#8b949e', fontFamily: 'var(--fK)', marginTop: 8 }}> </div>
@ -329,9 +311,9 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
)} )}
</div> </div>
<div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}> <div style={{ flexShrink: 0, padding: '8px 12px', borderTop: '1px solid #21262d' }}>
{media.satMeta.thumbCount > 0 && ( {num(media.satMeta, 'thumbCount') > 0 && (
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}> <div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
{Array.from({ length: media.satMeta.thumbCount }).map((_, i) => ( {Array.from({ length: num(media.satMeta, 'thumbCount') }).map((_, i) => (
<div key={i} style={{ <div key={i} style={{
width: 40, height: 36, borderRadius: 4, width: 40, height: 36, borderRadius: 4,
background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22', background: i === 0 ? 'rgba(168,85,247,0.15)' : '#161b22',
@ -344,7 +326,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
🛰 {media.satMeta.thumbCount} · {media.satMeta.sensor} 🛰 {num(media.satMeta, 'thumbCount')} · {str(media.satMeta, 'sensor')}
</span> </span>
<span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔍 / </span> <span style={{ fontSize: 8, color: '#58a6ff', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔍 / </span>
</div> </div>
@ -362,11 +344,11 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 12 }}>📹</span> <span style={{ fontSize: 12 }}>📹</span>
<span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 12, fontWeight: 700, color: '#f0f6fc', fontFamily: 'var(--fK)' }}>
CCTV {media.cctvMeta.title} CCTV {str(media.cctvMeta, 'title', 'CCTV')}
</span> </span>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{media.cctvMeta.live && ( {bool(media.cctvMeta, 'live') && (
<span style={{ <span style={{
padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700, padding: '2px 8px', borderRadius: 4, fontSize: 9, fontWeight: 700,
background: 'rgba(34,197,94,0.15)', color: '#22c55e', background: 'rgba(34,197,94,0.15)', color: '#22c55e',
@ -376,23 +358,23 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
</div> </div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, position: 'relative' }}> <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 8, position: 'relative' }}>
{media.cctvMeta.live && ( {bool(media.cctvMeta, 'live') && (
<div style={{ position: 'absolute', top: 10, left: 16, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)' }}> <div style={{ position: 'absolute', top: 10, left: 16, fontSize: 9, fontWeight: 700, color: '#ef4444', fontFamily: 'var(--fM)' }}>
LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })} LIVE {new Date().toLocaleTimeString('ko-KR', { hour12: false })}
</div> </div>
)} )}
<div style={{ fontSize: 48, color: '#30363d' }}>📹</div> <div style={{ fontSize: 48, color: '#30363d' }}>📹</div>
<div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}> <div style={{ fontSize: 12, color: '#c9d1d9', fontWeight: 600, fontFamily: 'var(--fK)' }}>
{media.cctvMeta.title.replace('#', 'CCTV #')} {str(media.cctvMeta, 'title', 'CCTV').replace('#', 'CCTV #')}
</div> </div>
<div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}> <div style={{ fontSize: 9, color: '#8b949e', fontFamily: 'var(--fM)' }}>
{media.cctvMeta.ptz} · {media.cctvMeta.angle} · {media.cctvMeta.live ? '실시간 스트리밍' : '녹화 영상'} {str(media.cctvMeta, 'ptz')} · {str(media.cctvMeta, 'angle')} · {bool(media.cctvMeta, 'live') ? '실시간 스트리밍' : '녹화 영상'}
</div> </div>
</div> </div>
{/* CAM buttons */} {/* CAM buttons */}
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}> <div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid #21262d', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 6 }}> <div style={{ display: 'flex', gap: 6 }}>
{Array.from({ length: media.cctvMeta.camCount }).map((_, i) => ( {Array.from({ length: num(media.cctvMeta, 'camCount') }).map((_, i) => (
<button key={i} onClick={() => setSelectedCam(i)} style={{ <button key={i} onClick={() => setSelectedCam(i)} style={{
padding: '6px 16px', borderRadius: 4, fontSize: 10, fontWeight: 600, padding: '6px 16px', borderRadius: 4, fontSize: 10, fontWeight: 600,
fontFamily: 'var(--fM)', cursor: 'pointer', fontFamily: 'var(--fM)', cursor: 'pointer',
@ -404,7 +386,7 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}> <span style={{ fontSize: 8, color: '#8b949e', fontFamily: 'var(--fK)' }}>
📹 CCTV {media.cctvMeta.camCount} · {media.cctvMeta.location} 📹 CCTV {num(media.cctvMeta, 'camCount')} · {str(media.cctvMeta, 'location')}
</span> </span>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>
<span style={{ fontSize: 8, color: '#ef4444', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔴 </span> <span style={{ fontSize: 8, color: '#ef4444', cursor: 'pointer', fontFamily: 'var(--fK)' }}>🔴 </span>
@ -423,10 +405,10 @@ export function MediaModal({ incident, onClose }: { incident: Incident; onClose:
display: 'flex', alignItems: 'center', justifyContent: 'space-between', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
}}> }}>
<div style={{ display: 'flex', gap: 16, fontSize: 10, fontFamily: 'var(--fM)', color: '#8b949e' }}> <div style={{ display: 'flex', gap: 16, fontSize: 10, fontFamily: 'var(--fM)', color: '#8b949e' }}>
<span>📷 <b style={{ color: '#f0f6fc' }}>{media.photos}</b></span> <span>📷 <b style={{ color: '#f0f6fc' }}>{media.photoCnt}</b></span>
<span>🎬 <b style={{ color: '#f0f6fc' }}>{media.videos}</b></span> <span>🎬 <b style={{ color: '#f0f6fc' }}>{media.videoCnt}</b></span>
<span>🛰 <b style={{ color: '#f0f6fc' }}>{media.satellites}</b></span> <span>🛰 <b style={{ color: '#f0f6fc' }}>{media.satCnt}</b></span>
<span>📹 CCTV <b style={{ color: '#f0f6fc' }}>{media.cctvs}</b></span> <span>📹 CCTV <b style={{ color: '#f0f6fc' }}>{media.cctvCnt}</b></span>
<span>📎 <b style={{ color: '#c084fc' }}>{total}</b></span> <span>📎 <b style={{ color: '#c084fc' }}>{total}</b></span>
</div> </div>
<div style={{ display: 'flex', gap: 8 }}> <div style={{ display: 'flex', gap: 8 }}>

파일 보기

@ -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<string, unknown> | null;
droneMeta: Record<string, unknown> | null;
satMeta: Record<string, unknown> | null;
cctvMeta: Record<string, unknown> | 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<string, 'active' | 'investigating' | 'closed'> = {
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<IncidentListItem[]> {
const { data } = await api.get<IncidentListItem[]>('/incidents');
return data;
}
export async function fetchIncidents(filters?: {
status?: string;
region?: string;
search?: string;
startDate?: string;
endDate?: string;
}): Promise<IncidentCompat[]> {
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<IncidentListItem[]>(url);
return data.map(toCompat);
}
export async function fetchIncidentDetail(sn: number): Promise<IncidentDetail> {
const { data } = await api.get<IncidentDetail>(`/incidents/${sn}`);
return data;
}
export async function fetchIncidentWeather(sn: number): Promise<WeatherInfo | null> {
try {
const { data } = await api.get<WeatherInfo>(`/incidents/${sn}/weather`);
return data;
} catch {
return null;
}
}
export async function fetchIncidentMedia(sn: number): Promise<MediaInfo | null> {
try {
const { data } = await api.get<MediaInfo>(`/incidents/${sn}/media`);
return data;
} catch {
return null;
}
}
export async function fetchIncidentPredictions(sn: number): Promise<PredExecItem[]> {
const { data } = await api.get<PredExecItem[]>(`/incidents/${sn}/predictions`);
return data;
}