From ff085252b0adaadf0dde28424dc8961433fd9c8f Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 1 Mar 2026 01:17:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase4):=20Board/HNS/Prediction/Aerial/Res?= =?UTF-8?q?cue=20Mock=20=E2=86=92=20API=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Board: 매뉴얼 CRUD + 첨부파일 API (012_board_ext.sql) - HNS: 분석 CRUD 5개 API (013_hns_analysis.sql) - Prediction: 분석/역추적/오일펜스 7개 API (014_prediction.sql) - Aerial: 미디어/CCTV/위성 6개 API + PostGIS (015_aerial.sql) - Rescue: 구난 작전/시나리오 3개 API + JSONB (016_rescue.sql) - backtrackMockData.ts 삭제 Co-Authored-By: Claude Opus 4.6 --- backend/src/aerial/aerialRouter.ts | 144 ++++++ backend/src/aerial/aerialService.ts | 341 ++++++++++++++ backend/src/board/boardRouter.ts | 95 +++- backend/src/board/boardService.ts | 157 +++++++ backend/src/hns/hnsRouter.ts | 81 +++- backend/src/hns/hnsService.ts | 157 +++++++ backend/src/prediction/predictionRouter.ts | 127 ++++++ backend/src/prediction/predictionService.ts | 423 ++++++++++++++++++ backend/src/rescue/rescueRouter.ts | 66 +++ backend/src/rescue/rescueService.ts | 217 +++++++++ backend/src/server.ts | 6 + database/migration/012_board_ext.sql | 51 +++ database/migration/013_hns_analysis.sql | 130 ++++++ database/migration/014_prediction.sql | 111 +++++ database/migration/015_aerial.sql | 128 ++++++ database/migration/016_rescue.sql | 182 ++++++++ frontend/src/common/mock/backtrackMockData.ts | 126 ------ .../src/tabs/aerial/components/CctvView.tsx | 110 +++-- .../aerial/components/MediaManagement.tsx | 129 +++--- .../src/tabs/aerial/services/aerialApi.ts | 105 +++++ .../src/tabs/board/components/BoardView.tsx | 185 ++++---- frontend/src/tabs/board/services/boardApi.ts | 61 +++ .../hns/components/HNSAnalysisListTable.tsx | 269 ++++------- .../tabs/hns/components/HNSRecalcModal.tsx | 27 +- .../tabs/hns/components/HNSScenarioView.tsx | 28 +- frontend/src/tabs/hns/components/HNSView.tsx | 14 +- frontend/src/tabs/hns/services/hnsApi.ts | 70 +++ .../components/AnalysisListTable.tsx | 247 ++-------- .../prediction/components/OilSpillView.tsx | 134 +++++- .../tabs/prediction/components/RightPanel.tsx | 103 ++--- .../tabs/prediction/services/predictionApi.ts | 115 +++++ .../rescue/components/RescueScenarioView.tsx | 311 ++++++------- .../src/tabs/rescue/components/RescueView.tsx | 94 +++- .../src/tabs/rescue/services/rescueApi.ts | 72 +++ 34 files changed, 3590 insertions(+), 1026 deletions(-) create mode 100644 backend/src/aerial/aerialRouter.ts create mode 100644 backend/src/aerial/aerialService.ts create mode 100644 backend/src/prediction/predictionRouter.ts create mode 100644 backend/src/prediction/predictionService.ts create mode 100644 backend/src/rescue/rescueRouter.ts create mode 100644 backend/src/rescue/rescueService.ts create mode 100644 database/migration/012_board_ext.sql create mode 100644 database/migration/013_hns_analysis.sql create mode 100644 database/migration/014_prediction.sql create mode 100644 database/migration/015_aerial.sql create mode 100644 database/migration/016_rescue.sql delete mode 100755 frontend/src/common/mock/backtrackMockData.ts create mode 100644 frontend/src/tabs/aerial/services/aerialApi.ts create mode 100644 frontend/src/tabs/hns/services/hnsApi.ts create mode 100644 frontend/src/tabs/prediction/services/predictionApi.ts create mode 100644 frontend/src/tabs/rescue/services/rescueApi.ts diff --git a/backend/src/aerial/aerialRouter.ts b/backend/src/aerial/aerialRouter.ts new file mode 100644 index 0000000..7370579 --- /dev/null +++ b/backend/src/aerial/aerialRouter.ts @@ -0,0 +1,144 @@ +import express from 'express'; +import { + listMedia, + createMedia, + listCctv, + listSatRequests, + createSatRequest, + updateSatRequestStatus, + isValidSatStatus, +} from './aerialService.js'; +import { isValidNumber } from '../middleware/security.js'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; + +const router = express.Router(); + +// ============================================================ +// AERIAL_MEDIA 라우트 +// ============================================================ + +// GET /api/aerial/media — 미디어 목록 +router.get('/media', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const { equipType, mediaType, acdntSn, search } = req.query; + const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined; + if (acdntSn && !isValidNumber(acdntSnNum, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const items = await listMedia({ + equipType: equipType as string | undefined, + mediaType: mediaType as string | undefined, + acdntSn: acdntSnNum, + search: search as string | undefined, + }); + res.json(items); + } catch (err) { + console.error('[aerial] 미디어 목록 오류:', err); + res.status(500).json({ error: '미디어 목록 조회 실패' }); + } +}); + +// POST /api/aerial/media — 미디어 메타 등록 +router.post('/media', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { + try { + const { + acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc, + equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution, + } = req.body; + if (!fileNm) { + res.status(400).json({ error: '파일명은 필수입니다.' }); + return; + } + const result = await createMedia({ + acdntSn, fileNm, orgnlNm, filePath, lon, lat, locDc, + equipTpCd, equipNm, mediaTpCd, takngDtm, fileSz, resolution, + }); + res.status(201).json(result); + } catch (err) { + console.error('[aerial] 미디어 등록 오류:', err); + res.status(500).json({ error: '미디어 등록 실패' }); + } +}); + +// ============================================================ +// CCTV_CAMERA 라우트 +// ============================================================ + +// GET /api/aerial/cctv — CCTV 목록 +router.get('/cctv', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const { region, status } = req.query; + const items = await listCctv({ + region: region as string | undefined, + status: status as string | undefined, + }); + res.json(items); + } catch (err) { + console.error('[aerial] CCTV 목록 오류:', err); + res.status(500).json({ error: 'CCTV 목록 조회 실패' }); + } +}); + +// ============================================================ +// SAT_REQUEST 라우트 +// ============================================================ + +// GET /api/aerial/satellite — 위성 촬영 요청 목록 +router.get('/satellite', requireAuth, requirePermission('aerial', 'READ'), async (req, res) => { + try { + const { status } = req.query; + const items = await listSatRequests({ + status: status as string | undefined, + }); + res.json(items); + } catch (err) { + console.error('[aerial] 위성 요청 목록 오류:', err); + res.status(500).json({ error: '위성 요청 목록 조회 실패' }); + } +}); + +// POST /api/aerial/satellite — 위성 촬영 요청 생성 +router.post('/satellite', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { + try { + const { + reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2, + satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm, + } = req.body; + if (!reqCd) { + res.status(400).json({ error: '요청코드는 필수입니다.' }); + return; + } + const result = await createSatRequest({ + reqCd, acdntSn, lon, lat, zoneDc, coordDc, zoneAreaKm2, + satNm, providerNm, resolution, purposeDc, reqstrNm, reqDtm, expectedRcvDtm, + }); + res.status(201).json(result); + } catch (err) { + console.error('[aerial] 위성 요청 생성 오류:', err); + res.status(500).json({ error: '위성 요청 생성 실패' }); + } +}); + +// POST /api/aerial/satellite/:sn/status — 위성 요청 상태 변경 +router.post('/satellite/:sn/status', requireAuth, requirePermission('aerial', 'CREATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 요청 번호' }); + return; + } + const { sttsCd } = req.body; + if (!sttsCd || !isValidSatStatus(sttsCd)) { + res.status(400).json({ error: '유효하지 않은 상태값 (PENDING/SHOOTING/COMPLETED/CANCELLED)' }); + return; + } + await updateSatRequestStatus(sn, sttsCd); + res.json({ success: true }); + } catch (err) { + console.error('[aerial] 위성 요청 상태 변경 오류:', err); + res.status(500).json({ error: '위성 요청 상태 변경 실패' }); + } +}); + +export default router; diff --git a/backend/src/aerial/aerialService.ts b/backend/src/aerial/aerialService.ts new file mode 100644 index 0000000..f9db73e --- /dev/null +++ b/backend/src/aerial/aerialService.ts @@ -0,0 +1,341 @@ +import { wingPool } from '../db/wingDb.js'; + +// ============================================================ +// AERIAL_MEDIA +// ============================================================ + +interface AerialMediaItem { + aerialMediaSn: number; + acdntSn: number | null; + fileNm: string; + orgnlNm: string | null; + filePath: string | null; + lon: number | null; + lat: number | null; + locDc: string | null; + equipTpCd: string | null; + equipNm: string | null; + mediaTpCd: string | null; + takngDtm: string | null; + fileSz: string | null; + resolution: string | null; + regDtm: string; +} + +interface ListMediaInput { + equipType?: string; + mediaType?: string; + acdntSn?: number; + search?: string; +} + +function rowToMedia(r: Record): AerialMediaItem { + return { + aerialMediaSn: r.aerial_media_sn as number, + acdntSn: r.acdnt_sn as number | null, + fileNm: r.file_nm as string, + orgnlNm: r.orgnl_nm as string | null, + filePath: r.file_path as string | null, + lon: r.lon ? parseFloat(r.lon as string) : null, + lat: r.lat ? parseFloat(r.lat as string) : null, + locDc: r.loc_dc as string | null, + equipTpCd: r.equip_tp_cd as string | null, + equipNm: r.equip_nm as string | null, + mediaTpCd: r.media_tp_cd as string | null, + takngDtm: r.takng_dtm as string | null, + fileSz: r.file_sz as string | null, + resolution: r.resolution as string | null, + regDtm: r.reg_dtm as string, + }; +} + +export async function listMedia(input: ListMediaInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"]; + const params: (string | number)[] = []; + let idx = 1; + + if (input.equipType) { + conditions.push(`EQUIP_TP_CD = $${idx++}`); + params.push(input.equipType); + } + if (input.mediaType) { + conditions.push(`MEDIA_TP_CD = $${idx++}`); + params.push(input.mediaType); + } + if (input.acdntSn) { + conditions.push(`ACDNT_SN = $${idx++}`); + params.push(input.acdntSn); + } + if (input.search) { + conditions.push(`(FILE_NM ILIKE '%' || $${idx} || '%' OR EQUIP_NM ILIKE '%' || $${idx} || '%')`); + params.push(input.search); + idx++; + } + + const { rows } = await wingPool.query( + `SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, + LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, + TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM + FROM AERIAL_MEDIA + WHERE ${conditions.join(' AND ')} + ORDER BY TAKNG_DTM DESC NULLS LAST`, + params + ); + + return rows.map((r: Record) => rowToMedia(r)); +} + +export async function createMedia(input: { + acdntSn?: number; + fileNm: string; + orgnlNm?: string; + filePath?: string; + lon?: number; + lat?: number; + locDc?: string; + equipTpCd?: string; + equipNm?: string; + mediaTpCd?: string; + takngDtm?: string; + fileSz?: string; + resolution?: string; +}): Promise<{ aerialMediaSn: number }> { + const { rows } = await wingPool.query( + `INSERT INTO AERIAL_MEDIA ( + ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH, + LON, LAT, + GEOM, + LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, + TAKNG_DTM, FILE_SZ, RESOLUTION + ) VALUES ( + $1, $2, $3, $4, + $5, $6, + CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::float, $6::float), 4326) END, + $7, $8, $9, $10, + $11, $12, $13 + ) RETURNING AERIAL_MEDIA_SN`, + [ + input.acdntSn || null, + input.fileNm, + input.orgnlNm || null, + input.filePath || null, + input.lon || null, + input.lat || null, + input.locDc || null, + input.equipTpCd || null, + input.equipNm || null, + input.mediaTpCd || null, + input.takngDtm || null, + input.fileSz || null, + input.resolution || null, + ] + ); + + return { aerialMediaSn: rows[0].aerial_media_sn }; +} + +// ============================================================ +// CCTV_CAMERA +// ============================================================ + +interface CctvCameraItem { + cctvSn: number; + cameraNm: string; + regionNm: string | null; + lon: number | null; + lat: number | null; + locDc: string | null; + coordDc: string | null; + sttsCd: string; + ptzYn: string; + sourceNm: string | null; + streamUrl: string | null; + regDtm: string; +} + +interface ListCctvInput { + region?: string; + status?: string; +} + +function rowToCctv(r: Record): CctvCameraItem { + return { + cctvSn: r.cctv_sn as number, + cameraNm: r.camera_nm as string, + regionNm: r.region_nm as string | null, + lon: r.lon ? parseFloat(r.lon as string) : null, + lat: r.lat ? parseFloat(r.lat as string) : null, + locDc: r.loc_dc as string | null, + coordDc: r.coord_dc as string | null, + sttsCd: r.stts_cd as string, + ptzYn: r.ptz_yn as string, + sourceNm: r.source_nm as string | null, + streamUrl: r.stream_url as string | null, + regDtm: r.reg_dtm as string, + }; +} + +export async function listCctv(input: ListCctvInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"]; + const params: string[] = []; + let idx = 1; + + if (input.region) { + conditions.push(`REGION_NM = $${idx++}`); + params.push(input.region); + } + if (input.status) { + conditions.push(`STTS_CD = $${idx++}`); + params.push(input.status); + } + + const { rows } = await wingPool.query( + `SELECT CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT, + LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL, REG_DTM + FROM CCTV_CAMERA + WHERE ${conditions.join(' AND ')} + ORDER BY REGION_NM, CAMERA_NM`, + params + ); + + return rows.map((r: Record) => rowToCctv(r)); +} + +// ============================================================ +// SAT_REQUEST +// ============================================================ + +interface SatRequestItem { + satReqSn: number; + reqCd: string; + acdntSn: number | null; + lon: number | null; + lat: number | null; + zoneDc: string | null; + coordDc: string | null; + zoneAreaKm2: number | null; + satNm: string | null; + providerNm: string | null; + resolution: string | null; + purposeDc: string | null; + reqstrNm: string | null; + reqDtm: string | null; + expectedRcvDtm: string | null; + sttsCd: string; + regDtm: string; +} + +interface ListSatRequestsInput { + status?: string; +} + +function rowToSatRequest(r: Record): SatRequestItem { + return { + satReqSn: r.sat_req_sn as number, + reqCd: r.req_cd as string, + acdntSn: r.acdnt_sn as number | null, + lon: r.lon ? parseFloat(r.lon as string) : null, + lat: r.lat ? parseFloat(r.lat as string) : null, + zoneDc: r.zone_dc as string | null, + coordDc: r.coord_dc as string | null, + zoneAreaKm2: r.zone_area_km2 ? parseFloat(r.zone_area_km2 as string) : null, + satNm: r.sat_nm as string | null, + providerNm: r.provider_nm as string | null, + resolution: r.resolution as string | null, + purposeDc: r.purpose_dc as string | null, + reqstrNm: r.reqstr_nm as string | null, + reqDtm: r.req_dtm as string | null, + expectedRcvDtm: r.expected_rcv_dtm as string | null, + sttsCd: r.stts_cd as string, + regDtm: r.reg_dtm as string, + }; +} + +export async function listSatRequests(input: ListSatRequestsInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"]; + const params: string[] = []; + let idx = 1; + + if (input.status) { + conditions.push(`STTS_CD = $${idx++}`); + params.push(input.status); + } + + const { rows } = await wingPool.query( + `SELECT SAT_REQ_SN, REQ_CD, ACDNT_SN, LON, LAT, + ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, + RESOLUTION, PURPOSE_DC, REQSTR_NM, + REQ_DTM, EXPECTED_RCV_DTM, STTS_CD, REG_DTM + FROM SAT_REQUEST + WHERE ${conditions.join(' AND ')} + ORDER BY REQ_DTM DESC NULLS LAST`, + params + ); + + return rows.map((r: Record) => rowToSatRequest(r)); +} + +export async function createSatRequest(input: { + reqCd: string; + acdntSn?: number; + lon?: number; + lat?: number; + zoneDc?: string; + coordDc?: string; + zoneAreaKm2?: number; + satNm?: string; + providerNm?: string; + resolution?: string; + purposeDc?: string; + reqstrNm?: string; + reqDtm?: string; + expectedRcvDtm?: string; +}): Promise<{ satReqSn: number }> { + const { rows } = await wingPool.query( + `INSERT INTO SAT_REQUEST ( + REQ_CD, ACDNT_SN, LON, LAT, + GEOM, + ZONE_DC, COORD_DC, ZONE_AREA_KM2, + SAT_NM, PROVIDER_NM, RESOLUTION, + PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM + ) VALUES ( + $1, $2, $3, $4, + CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($3::float, $4::float), 4326) END, + $5, $6, $7, + $8, $9, $10, + $11, $12, $13, $14 + ) RETURNING SAT_REQ_SN`, + [ + input.reqCd, + input.acdntSn || null, + input.lon || null, + input.lat || null, + input.zoneDc || null, + input.coordDc || null, + input.zoneAreaKm2 || null, + input.satNm || null, + input.providerNm || null, + input.resolution || null, + input.purposeDc || null, + input.reqstrNm || null, + input.reqDtm || null, + input.expectedRcvDtm || null, + ] + ); + + return { satReqSn: rows[0].sat_req_sn }; +} + +const VALID_SAT_STATUSES = ['PENDING', 'SHOOTING', 'COMPLETED', 'CANCELLED'] as const; +type SatStatus = typeof VALID_SAT_STATUSES[number]; + +export function isValidSatStatus(value: string): value is SatStatus { + return (VALID_SAT_STATUSES as readonly string[]).includes(value); +} + +export async function updateSatRequestStatus(sn: number, sttsCd: string): Promise { + await wingPool.query( + `UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`, + [sttsCd, sn] + ); +} diff --git a/backend/src/board/boardRouter.ts b/backend/src/board/boardRouter.ts index e854c5c..39f2d39 100644 --- a/backend/src/board/boardRouter.ts +++ b/backend/src/board/boardRouter.ts @@ -1,7 +1,10 @@ import { Router } from 'express' import { requireAuth, requirePermission } from '../auth/authMiddleware.js' import { AuthError } from '../auth/authService.js' -import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js' +import { + listPosts, getPost, createPost, updatePost, deletePost, + listManuals, createManual, updateManual, deleteManual, incrementManualDownload, +} from './boardService.js' const router = Router() @@ -14,8 +17,88 @@ const CATEGORY_RESOURCE: Record = { } // ============================================================ -// GET /api/board — 게시글 목록 +// 매뉴얼 라우트 (/:sn 보다 먼저 등록해야 함) // ============================================================ + +// GET /api/board/manual — 매뉴얼 목록 +router.get('/manual', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => { + try { + const { category, search } = req.query + const items = await listManuals({ + category: category as string | undefined, + search: search as string | undefined, + }) + res.json(items) + } catch (err) { + console.error('[board] 매뉴얼 목록 오류:', err) + res.status(500).json({ error: '매뉴얼 목록 조회 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/board/manual — 매뉴얼 등록 +router.post('/manual', requireAuth, requirePermission('board:manual', 'CREATE'), async (req, res) => { + try { + const { catgNm, title, version, fileTp, fileSz, filePath, authorNm } = req.body + if (!catgNm || !title) { + res.status(400).json({ error: '카테고리와 제목은 필수입니다.' }) + return + } + const result = await createManual({ catgNm, title, version, fileTp, fileSz, filePath, authorNm }) + res.status(201).json(result) + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return } + console.error('[board] 매뉴얼 등록 오류:', err) + res.status(500).json({ error: '매뉴얼 등록 중 오류가 발생했습니다.' }) + } +}) + +// PUT /api/board/manual/:sn — 매뉴얼 수정 +router.put('/manual/:sn', requireAuth, requirePermission('board:manual', 'UPDATE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return } + const { catgNm, title, version, fileTp, fileSz, filePath } = req.body + await updateManual(sn, { catgNm, title, version, fileTp, fileSz, filePath }) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return } + console.error('[board] 매뉴얼 수정 오류:', err) + res.status(500).json({ error: '매뉴얼 수정 중 오류가 발생했습니다.' }) + } +}) + +// DELETE /api/board/manual/:sn — 매뉴얼 삭제 +router.delete('/manual/:sn', requireAuth, requirePermission('board:manual', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return } + await deleteManual(sn) + res.json({ success: true }) + } catch (err) { + if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return } + console.error('[board] 매뉴얼 삭제 오류:', err) + res.status(500).json({ error: '매뉴얼 삭제 중 오류가 발생했습니다.' }) + } +}) + +// POST /api/board/manual/:sn/download — 매뉴얼 다운로드 카운트 증가 +router.post('/manual/:sn/download', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return } + await incrementManualDownload(sn) + res.json({ success: true }) + } catch (err) { + console.error('[board] 다운로드 카운트 오류:', err) + res.status(500).json({ error: '다운로드 처리 중 오류가 발생했습니다.' }) + } +}) + +// ============================================================ +// 게시글 라우트 +// ============================================================ + +// GET /api/board — 게시글 목록 router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => { try { const { categoryCd, search, page, size } = req.query @@ -32,9 +115,7 @@ router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res } }) -// ============================================================ // GET /api/board/:sn — 게시글 상세 -// ============================================================ router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => { try { const sn = parseInt(req.params.sn as string, 10) @@ -54,9 +135,7 @@ router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, } }) -// ============================================================ // POST /api/board — 게시글 작성 (카테고리별 CREATE 권한) -// ============================================================ router.post('/', requireAuth, async (req, res, next) => { const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' requirePermission(resource, 'CREATE')(req, res, next) @@ -87,9 +166,7 @@ router.post('/', requireAuth, async (req, res, next) => { } }) -// ============================================================ // PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서) -// ============================================================ router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => { try { const sn = parseInt(req.params.sn as string, 10) @@ -111,9 +188,7 @@ router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req } }) -// ============================================================ // DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증) -// ============================================================ router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => { try { const sn = parseInt(req.params.sn as string, 10) diff --git a/backend/src/board/boardService.ts b/backend/src/board/boardService.ts index 1b554a7..d09fce5 100644 --- a/backend/src/board/boardService.ts +++ b/backend/src/board/boardService.ts @@ -220,6 +220,163 @@ export async function updatePost( ) } +// ============================================================ +// 매뉴얼 CRUD +// ============================================================ + +interface ManualItem { + manualSn: number + catgNm: string + title: string + version: string | null + fileTp: string | null + fileSz: string | null + filePath: string | null + authorNm: string | null + dwnldCnt: number + regDtm: string +} + +interface ListManualsInput { + category?: string + search?: string +} + +interface CreateManualInput { + catgNm: string + title: string + version?: string + fileTp?: string + fileSz?: string + filePath?: string + authorNm?: string +} + +interface UpdateManualInput { + catgNm?: string + title?: string + version?: string + fileTp?: string + fileSz?: string + filePath?: string +} + +function rowToManual(r: Record): ManualItem { + return { + manualSn: r.manual_sn as number, + catgNm: r.catg_nm as string, + title: r.title as string, + version: r.version as string | null, + fileTp: r.file_tp as string | null, + fileSz: r.file_sz as string | null, + filePath: r.file_path as string | null, + authorNm: r.author_nm as string | null, + dwnldCnt: r.dwnld_cnt as number, + regDtm: r.reg_dtm as string, + } +} + +export async function listManuals(input: ListManualsInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"] + const params: string[] = [] + let idx = 1 + + if (input.category) { + conditions.push(`CATG_NM = $${idx++}`) + params.push(input.category) + } + if (input.search) { + conditions.push(`(TITLE ILIKE $${idx} OR AUTHOR_NM ILIKE $${idx})`) + params.push(`%${input.search}%`) + idx++ + } + + const { rows } = await wingPool.query( + `SELECT MANUAL_SN, CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, + FILE_PATH, AUTHOR_NM, DWNLD_CNT, REG_DTM + FROM MANUAL_FILE + WHERE ${conditions.join(' AND ')} + ORDER BY REG_DTM DESC`, + params + ) + + return rows.map((r: Record) => rowToManual(r)) +} + +export async function createManual(input: CreateManualInput): Promise<{ manualSn: number }> { + if (!input.title || input.title.trim().length === 0) { + throw new AuthError('제목은 필수입니다.', 400) + } + + const { rows } = await wingPool.query( + `INSERT INTO MANUAL_FILE (CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, FILE_PATH, AUTHOR_NM) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING MANUAL_SN`, + [input.catgNm, input.title.trim(), input.version || null, input.fileTp || null, input.fileSz || null, input.filePath || null, input.authorNm || null] + ) + + return { manualSn: rows[0].manual_sn } +} + +export async function updateManual(manualSn: number, input: UpdateManualInput): Promise { + const existing = await wingPool.query( + `SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, + [manualSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404) + } + + const sets: string[] = [] + const params: (string | number | null)[] = [] + let idx = 1 + + if (input.catgNm !== undefined) { sets.push(`CATG_NM = $${idx++}`); params.push(input.catgNm) } + if (input.title !== undefined) { sets.push(`TITLE = $${idx++}`); params.push(input.title.trim()) } + if (input.version !== undefined) { sets.push(`VERSION = $${idx++}`); params.push(input.version) } + if (input.fileTp !== undefined) { sets.push(`FILE_TP = $${idx++}`); params.push(input.fileTp) } + if (input.fileSz !== undefined) { sets.push(`FILE_SZ = $${idx++}`); params.push(input.fileSz) } + if (input.filePath !== undefined) { sets.push(`FILE_PATH = $${idx++}`); params.push(input.filePath) } + + if (sets.length === 0) { + throw new AuthError('수정할 항목이 없습니다.', 400) + } + + sets.push('MDFCN_DTM = NOW()') + params.push(manualSn) + + await wingPool.query( + `UPDATE MANUAL_FILE SET ${sets.join(', ')} WHERE MANUAL_SN = $${idx}`, + params + ) +} + +export async function deleteManual(manualSn: number): Promise { + const existing = await wingPool.query( + `SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, + [manualSn] + ) + if (existing.rows.length === 0) { + throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404) + } + + await wingPool.query( + `UPDATE MANUAL_FILE SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE MANUAL_SN = $1`, + [manualSn] + ) +} + +export async function incrementManualDownload(manualSn: number): Promise { + await wingPool.query( + `UPDATE MANUAL_FILE SET DWNLD_CNT = DWNLD_CNT + 1 WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`, + [manualSn] + ) +} + +// ============================================================ +// 게시글 삭제 +// ============================================================ + export async function deletePost(postSn: number, requesterId: string): Promise { // 게시글 존재 + 작성자 확인 const existing = await wingPool.query( diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 68aff33..fa34ab3 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -1,9 +1,88 @@ import express from 'express' -import { searchSubstances, getSubstanceById } from './hnsService.js' +import { searchSubstances, getSubstanceById, listAnalyses, getAnalysis, createAnalysis, deleteAnalysis } from './hnsService.js' import { isValidNumber } from '../middleware/security.js' +import { requireAuth, requirePermission } from '../auth/authMiddleware.js' const router = express.Router() +// ============================================================ +// HNS 분석 라우트 (/:id 보다 먼저 등록) +// ============================================================ + +// GET /api/hns/analyses — 분석 목록 +router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { + try { + const { status, substance, search } = req.query + const items = await listAnalyses({ + status: status as string | undefined, + substance: substance as string | undefined, + search: search as string | undefined, + }) + res.json(items) + } catch (err) { + console.error('[hns] 분석 목록 오류:', err) + res.status(500).json({ error: 'HNS 분석 목록 조회 실패' }) + } +}) + +// GET /api/hns/analyses/:sn — 분석 상세 +router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 분석 번호' }) + return + } + const item = await getAnalysis(sn) + if (!item) { + res.status(404).json({ error: '분석을 찾을 수 없습니다' }) + return + } + res.json(item) + } catch (err) { + console.error('[hns] 분석 상세 오류:', err) + res.status(500).json({ error: 'HNS 분석 조회 실패' }) + } +}) + +// POST /api/hns/analyses — 분석 생성 +router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => { + try { + const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body + if (!anlysNm) { + res.status(400).json({ error: '분석명은 필수입니다.' }) + return + } + const result = await createAnalysis({ + anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm, + }) + res.status(201).json(result) + } catch (err) { + console.error('[hns] 분석 생성 오류:', err) + res.status(500).json({ error: 'HNS 분석 생성 실패' }) + } +}) + +// DELETE /api/hns/analyses/:sn — 분석 삭제 +router.delete('/analyses/:sn', requireAuth, requirePermission('hns', 'DELETE'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10) + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 분석 번호' }) + return + } + await deleteAnalysis(sn) + res.json({ success: true }) + } catch (err) { + console.error('[hns] 분석 삭제 오류:', err) + res.status(500).json({ error: 'HNS 분석 삭제 실패' }) + } +}) + +// ============================================================ +// HNS 물질 라우트 +// ============================================================ + // HNS 물질 검색 router.get('/', async (req, res) => { try { diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index 5e5fe11..e130524 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -88,6 +88,163 @@ export async function searchSubstances(params: HnsSearchParams) { } } +// ============================================================ +// HNS 분석 CRUD +// ============================================================ + +interface HnsAnalysisItem { + hnsAnlysSn: number + anlysNm: string + acdntDtm: string | null + locNm: string | null + lon: number | null + lat: number | null + sbstNm: string | null + spilQty: number | null + spilUnitCd: string | null + fcstHr: number | null + algoCd: string | null + critMdlCd: string | null + windSpd: number | null + windDir: string | null + execSttsCd: string + riskCd: string | null + analystNm: string | null + rsltData: Record | null + regDtm: string +} + +interface ListAnalysesInput { + status?: string + substance?: string + search?: string +} + +function rowToAnalysis(r: Record): HnsAnalysisItem { + return { + hnsAnlysSn: r.hns_anlys_sn as number, + anlysNm: r.anlys_nm as string, + acdntDtm: r.acdnt_dtm as string | null, + locNm: r.loc_nm as string | null, + lon: r.lon ? parseFloat(r.lon as string) : null, + lat: r.lat ? parseFloat(r.lat as string) : null, + sbstNm: r.sbst_nm as string | null, + spilQty: r.spil_qty ? parseFloat(r.spil_qty as string) : null, + spilUnitCd: r.spil_unit_cd as string | null, + fcstHr: r.fcst_hr as number | null, + algoCd: r.algo_cd as string | null, + critMdlCd: r.crit_mdl_cd as string | null, + windSpd: r.wind_spd ? parseFloat(r.wind_spd as string) : null, + windDir: r.wind_dir as string | null, + execSttsCd: r.exec_stts_cd as string, + riskCd: r.risk_cd as string | null, + analystNm: r.analyst_nm as string | null, + rsltData: (r.rslt_data as Record) ?? null, + regDtm: r.reg_dtm as string, + } +} + +export async function listAnalyses(input: ListAnalysesInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"] + const params: string[] = [] + let idx = 1 + + if (input.status) { + conditions.push(`EXEC_STTS_CD = $${idx++}`) + params.push(input.status) + } + if (input.substance) { + conditions.push(`SBST_NM ILIKE '%' || $${idx++} || '%'`) + params.push(input.substance) + } + if (input.search) { + conditions.push(`(ANLYS_NM ILIKE '%' || $${idx} || '%' OR LOC_NM ILIKE '%' || $${idx} || '%')`) + params.push(input.search) + idx++ + } + + const { rows } = await wingPool.query( + `SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, + WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM, + RSLT_DATA, REG_DTM + FROM HNS_ANALYSIS + WHERE ${conditions.join(' AND ')} + ORDER BY ACDNT_DTM DESC NULLS LAST`, + params + ) + + return rows.map((r: Record) => rowToAnalysis(r)) +} + +export async function getAnalysis(sn: number): Promise { + const { rows } = await wingPool.query( + `SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, + WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, + EXEC_STTS_CD, RISK_CD, ANALYST_NM, + RSLT_DATA, REG_DTM + FROM HNS_ANALYSIS + WHERE HNS_ANLYS_SN = $1 AND USE_YN = 'Y'`, + [sn] + ) + if (rows.length === 0) return null + return rowToAnalysis(rows[0] as Record) +} + +export async function createAnalysis(input: { + anlysNm: string + acdntDtm?: string + locNm?: string + lon?: number + lat?: number + sbstNm?: string + spilQty?: number + spilUnitCd?: string + fcstHr?: number + algoCd?: string + critMdlCd?: string + windSpd?: number + windDir?: string + temp?: number + humid?: number + atmStblCd?: string + analystNm?: string +}): Promise<{ hnsAnlysSn: number }> { + const { rows } = await wingPool.query( + `INSERT INTO HNS_ANALYSIS ( + ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, + GEOM, LOC_DC, + SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, + WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, + ANALYST_NM, EXEC_STTS_CD + ) VALUES ( + $1, $2, $3, $4, $5, + CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END, + CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END, + $6, $7, $8, $9, $10, $11, + $12, $13, $14, $15, $16, + $17, 'PENDING' + ) RETURNING HNS_ANLYS_SN`, + [ + input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null, + input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL', + input.fcstHr || null, input.algoCd || null, input.critMdlCd || null, + input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null, + input.analystNm || null, + ] + ) + + return { hnsAnlysSn: rows[0].hns_anlys_sn } +} + +export async function deleteAnalysis(sn: number): Promise { + await wingPool.query( + `UPDATE HNS_ANALYSIS SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE HNS_ANLYS_SN = $1`, + [sn] + ) +} + export async function getSubstanceById(id: number) { const { rows } = await wingPool.query( `SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts new file mode 100644 index 0000000..c6802e4 --- /dev/null +++ b/backend/src/prediction/predictionRouter.ts @@ -0,0 +1,127 @@ +import express from 'express'; +import { + listAnalyses, getAnalysisDetail, getBacktrack, listBacktracksByAcdnt, + createBacktrack, saveBoomLine, listBoomLines, +} from './predictionService.js'; +import { isValidNumber } from '../middleware/security.js'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; + +const router = express.Router(); + +// GET /api/prediction/analyses — 분석 목록 +router.get('/analyses', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const { search } = req.query; + const items = await listAnalyses({ search: search as string | undefined }); + res.json(items); + } catch (err) { + console.error('[prediction] 분석 목록 오류:', err); + res.status(500).json({ error: '분석 목록 조회 실패' }); + } +}); + +// GET /api/prediction/analyses/:acdntSn — 분석 상세 +router.get('/analyses/:acdntSn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const acdntSn = parseInt(req.params.acdntSn as string, 10); + if (!isValidNumber(acdntSn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const detail = await getAnalysisDetail(acdntSn); + if (!detail) { + res.status(404).json({ error: '분석을 찾을 수 없습니다' }); + return; + } + res.json(detail); + } catch (err) { + console.error('[prediction] 분석 상세 오류:', err); + res.status(500).json({ error: '분석 상세 조회 실패' }); + } +}); + +// GET /api/prediction/backtrack — 사고별 역추적 목록 +router.get('/backtrack', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const acdntSn = parseInt(req.query.acdntSn as string, 10); + if (!isValidNumber(acdntSn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const items = await listBacktracksByAcdnt(acdntSn); + res.json(items); + } catch (err) { + console.error('[prediction] 역추적 목록 오류:', err); + res.status(500).json({ error: '역추적 목록 조회 실패' }); + } +}); + +// GET /api/prediction/backtrack/:sn — 역추적 상세 +router.get('/backtrack/:sn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 역추적 번호' }); + return; + } + const item = await getBacktrack(sn); + if (!item) { + res.status(404).json({ error: '역추적 결과를 찾을 수 없습니다' }); + return; + } + res.json(item); + } catch (err) { + console.error('[prediction] 역추적 상세 오류:', err); + res.status(500).json({ error: '역추적 조회 실패' }); + } +}); + +// POST /api/prediction/backtrack — 역추적 생성 +router.post('/backtrack', requireAuth, requirePermission('prediction', 'CREATE'), async (req, res) => { + try { + const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = req.body; + if (!acdntSn || !lat || !lon) { + res.status(400).json({ error: '사고번호, 위도, 경도는 필수입니다' }); + return; + } + const result = await createBacktrack({ acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm }); + res.status(201).json(result); + } catch (err) { + console.error('[prediction] 역추적 생성 오류:', err); + res.status(500).json({ error: '역추적 생성 실패' }); + } +}); + +// GET /api/prediction/boom/:acdntSn — 오일펜스 목록 +router.get('/boom/:acdntSn', requireAuth, requirePermission('prediction', 'READ'), async (req, res) => { + try { + const acdntSn = parseInt(req.params.acdntSn as string, 10); + if (!isValidNumber(acdntSn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 사고 번호' }); + return; + } + const items = await listBoomLines(acdntSn); + res.json(items); + } catch (err) { + console.error('[prediction] 오일펜스 목록 오류:', err); + res.status(500).json({ error: '오일펜스 목록 조회 실패' }); + } +}); + +// POST /api/prediction/boom — 오일펜스 저장 +router.post('/boom', requireAuth, requirePermission('prediction', 'CREATE'), async (req, res) => { + try { + const { acdntSn, boomNm, priorityOrd, geojson, lengthM, efficiencyPct } = req.body; + if (!acdntSn || !boomNm || !geojson) { + res.status(400).json({ error: '사고번호, 이름, GeoJSON은 필수입니다' }); + return; + } + const result = await saveBoomLine({ acdntSn, boomNm, priorityOrd, geojson, lengthM, efficiencyPct }); + res.status(201).json(result); + } catch (err) { + console.error('[prediction] 오일펜스 저장 오류:', err); + res.status(500).json({ error: '오일펜스 저장 실패' }); + } +}); + +export default router; diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts new file mode 100644 index 0000000..b076730 --- /dev/null +++ b/backend/src/prediction/predictionService.ts @@ -0,0 +1,423 @@ +import { wingPool } from '../db/wingDb.js'; + +interface PredictionAnalysis { + acdntSn: number; + acdntNm: string; + occurredAt: string; + analysisDate: string; + requestor: string; + duration: string; + oilType: string; + volume: number | null; + location: string; + lat: number | null; + lon: number | null; + kospsStatus: string; + poseidonStatus: string; + opendriftStatus: string; + backtrackStatus: string; + analyst: string; + officeName: string; +} + +interface PredictionDetail { + acdnt: { + acdntSn: number; + acdntNm: string; + occurredAt: string; + lat: number | null; + lon: number | null; + location: string; + analyst: string; + officeName: string; + }; + spill: { + oilType: string; + volume: number | null; + unit: string; + fcstHr: number | null; + } | null; + vessels: Array<{ + vesselInfoSn: number; + imoNo: string; + vesselNm: string; + vesselTp: string; + loaM: number | null; + breadthM: number | null; + draftM: number | null; + gt: number | null; + dwt: number | null; + builtYr: number | null; + flagCd: string; + callsign: string; + engineDc: string; + insuranceData: unknown; + }>; + weather: Array<{ + weatherDtm: string; + windSpd: number | null; + windDir: string | null; + waveHgt: number | null; + currentSpd: number | null; + currentDir: string | null; + temp: number | null; + }>; +} + +interface BacktrackResult { + backtrackSn: number; + acdntSn: number; + estSpilDtm: string | null; + anlysRange: string | null; + lon: number | null; + lat: number | null; + srchRadiusNm: number | null; + totalVessels: number | null; + execSttsCd: string; + rsltData: unknown; + regDtm: string; +} + +interface CreateBacktrackInput { + acdntSn: number; + lat: number; + lon: number; + estSpilDtm?: string; + anlysRange?: string; + srchRadiusNm?: number; +} + +interface SaveBoomLineInput { + acdntSn: number; + boomNm: string; + priorityOrd?: number; + geojson: unknown; + lengthM?: number; + efficiencyPct?: number; +} + +interface BoomLineItem { + boomLineSn: number; + acdntSn: number; + boomNm: string; + priorityOrd: number; + geom: unknown; + lengthM: number | null; + efficiencyPct: number | null; + sttsCd: string; + regDtm: string; +} + +interface ListAnalysesInput { + search?: string; +} + +export async function listAnalyses(input: ListAnalysesInput): Promise { + const params: unknown[] = []; + const conditions: string[] = ["A.USE_YN = 'Y'"]; + + if (input.search) { + params.push(`%${input.search}%`); + conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; + + const sql = ` + SELECT + A.ACDNT_SN, + A.ACDNT_NM, + A.OCCRN_DTM, + A.LAT, + A.LNG, + A.LOC_DC, + A.ANALYST_NM, + A.OFFICE_NM, + A.REGION_NM, + S.OIL_TP_CD, + S.SPIL_QTY, + S.SPIL_UNIT_CD, + S.FCST_HR, + P.KOSPS_STATUS, + P.POSEIDON_STATUS, + P.OPENDRIFT_STATUS, + B.BACKTRACK_STATUS + FROM ACDNT A + LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN + LEFT JOIN ( + SELECT + ACDNT_SN, + MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS, + MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS, + MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS + FROM PRED_EXEC + GROUP BY ACDNT_SN + ) P ON P.ACDNT_SN = A.ACDNT_SN + LEFT JOIN ( + SELECT + ACDNT_SN, + MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS + FROM BACKTRACK B + GROUP BY ACDNT_SN + ) B ON B.ACDNT_SN = A.ACDNT_SN + ${whereClause} + ORDER BY A.OCCRN_DTM DESC + `; + + const { rows } = await wingPool.query(sql, params); + + return rows.map((row: Record) => ({ + acdntSn: Number(row['acdnt_sn']), + acdntNm: String(row['acdnt_nm'] ?? ''), + occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', + analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '', + requestor: String(row['analyst_nm'] ?? ''), + duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '', + oilType: String(row['oil_tp_cd'] ?? ''), + volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null, + location: String(row['loc_dc'] ?? ''), + lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null, + lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null, + kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(), + poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(), + opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(), + backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(), + analyst: String(row['analyst_nm'] ?? ''), + officeName: String(row['office_nm'] ?? ''), + })); +} + +export async function getAnalysisDetail(acdntSn: number): Promise { + const acdntSql = ` + SELECT + A.ACDNT_SN, + A.ACDNT_NM, + A.OCCRN_DTM, + A.LAT, + A.LNG, + A.LOC_DC, + A.ANALYST_NM, + A.OFFICE_NM + FROM ACDNT A + WHERE A.ACDNT_SN = $1 + AND A.USE_YN = 'Y' + `; + const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]); + if (acdntRows.length === 0) return null; + + const a = acdntRows[0] as Record; + + const spillSql = ` + SELECT + OIL_TP_CD, + SPIL_QTY, + SPIL_UNIT_CD, + FCST_HR + FROM SPIL_DATA + WHERE ACDNT_SN = $1 + ORDER BY SPIL_DATA_SN ASC + LIMIT 1 + `; + const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]); + + const vesselSql = ` + SELECT + VESSEL_INFO_SN, + IMO_NO, + VESSEL_NM, + VESSEL_TP, + LOA_M, + BREADTH_M, + DRAFT_M, + GT, + DWT, + BUILT_YR, + FLAG_CD, + CALLSIGN, + ENGINE_DC, + INSURANCE_DATA + FROM VESSEL_INFO + WHERE ACDNT_SN = $1 + ORDER BY VESSEL_INFO_SN ASC + `; + const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]); + + const weatherSql = ` + SELECT + WEATHER_DTM, + WIND_SPD, + WIND_DIR, + WAVE_HGT, + CURRENT_SPD, + CURRENT_DIR, + TEMP + FROM ACDNT_WEATHER + WHERE ACDNT_SN = $1 + ORDER BY WEATHER_DTM ASC + `; + const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]); + + const spill = + spillRows.length > 0 + ? (() => { + const s = spillRows[0] as Record; + return { + oilType: String(s['oil_tp_cd'] ?? ''), + volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null, + unit: String(s['spil_unit_cd'] ?? ''), + fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null, + }; + })() + : null; + + const vessels = vesselRows.map((v: Record) => ({ + vesselInfoSn: Number(v['vessel_info_sn']), + imoNo: String(v['imo_no'] ?? ''), + vesselNm: String(v['vessel_nm'] ?? ''), + vesselTp: String(v['vessel_tp'] ?? ''), + loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null, + breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null, + draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null, + gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null, + dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null, + builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null, + flagCd: String(v['flag_cd'] ?? ''), + callsign: String(v['callsign'] ?? ''), + engineDc: String(v['engine_dc'] ?? ''), + insuranceData: v['insurance_data'] ?? null, + })); + + const weather = weatherRows.map((w: Record) => ({ + weatherDtm: String(w['weather_dtm'] ?? ''), + windSpd: w['wind_spd'] != null ? parseFloat(String(w['wind_spd'])) : null, + windDir: w['wind_dir'] != null ? String(w['wind_dir']) : null, + waveHgt: w['wave_hgt'] != null ? parseFloat(String(w['wave_hgt'])) : null, + currentSpd: w['current_spd'] != null ? parseFloat(String(w['current_spd'])) : null, + currentDir: w['current_dir'] != null ? String(w['current_dir']) : null, + temp: w['temp'] != null ? parseFloat(String(w['temp'])) : null, + })); + + return { + acdnt: { + acdntSn: Number(a['acdnt_sn']), + acdntNm: String(a['acdnt_nm'] ?? ''), + occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '', + lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null, + lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null, + location: String(a['loc_dc'] ?? ''), + analyst: String(a['analyst_nm'] ?? ''), + officeName: String(a['office_nm'] ?? ''), + }, + spill, + vessels, + weather, + }; +} + +export async function getBacktrack(sn: number): Promise { + const sql = ` + SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, + LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, + EXEC_STTS_CD, RSLT_DATA, REG_DTM + FROM BACKTRACK + WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y' + `; + const { rows } = await wingPool.query(sql, [sn]); + if (rows.length === 0) return null; + return rowToBacktrack(rows[0] as Record); +} + +export async function listBacktracksByAcdnt(acdntSn: number): Promise { + const sql = ` + SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, + LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS, + EXEC_STTS_CD, RSLT_DATA, REG_DTM + FROM BACKTRACK + WHERE ACDNT_SN = $1 AND USE_YN = 'Y' + ORDER BY REG_DTM DESC + `; + const { rows } = await wingPool.query(sql, [acdntSn]); + return rows.map((r: Record) => rowToBacktrack(r)); +} + +function rowToBacktrack(r: Record): BacktrackResult { + return { + backtrackSn: Number(r['backtrack_sn']), + acdntSn: Number(r['acdnt_sn']), + estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null, + anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null, + lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null, + lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null, + srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null, + totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null, + execSttsCd: String(r['exec_stts_cd'] ?? ''), + rsltData: r['rslt_data'] ?? null, + regDtm: String(r['reg_dtm'] ?? ''), + }; +} + +export async function createBacktrack( + input: CreateBacktrackInput, +): Promise<{ backtrackSn: number }> { + const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input; + + const sql = ` + INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD) + VALUES ( + $1, $2, $3, + ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326), + $3 || ' + ' || $2, + $4, $5, $6, 'PENDING' + ) + RETURNING BACKTRACK_SN + `; + + const { rows } = await wingPool.query(sql, [ + acdntSn, lat, lon, + estSpilDtm || null, anlysRange || null, srchRadiusNm || null, + ]); + + return { backtrackSn: Number((rows[0] as Record)['backtrack_sn']) }; +} + +export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> { + const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input; + + const sql = ` + INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT) + VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6) + RETURNING BOOM_LINE_SN + `; + + const { rows } = await wingPool.query(sql, [ + acdntSn, boomNm, priorityOrd, + JSON.stringify(geojson), + lengthM || null, efficiencyPct || null, + ]); + + return { boomLineSn: Number((rows[0] as Record)['boom_line_sn']) }; +} + +export async function listBoomLines(acdntSn: number): Promise { + const sql = ` + SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD, + ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM + FROM BOOM_LINE + WHERE ACDNT_SN = $1 AND USE_YN = 'Y' + ORDER BY PRIORITY_ORD ASC + `; + + const { rows } = await wingPool.query(sql, [acdntSn]); + + return rows.map((r: Record) => ({ + boomLineSn: Number(r['boom_line_sn']), + acdntSn: Number(r['acdnt_sn']), + boomNm: String(r['boom_nm'] ?? ''), + priorityOrd: Number(r['priority_ord'] ?? 0), + geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null, + lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null, + efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null, + sttsCd: String(r['stts_cd'] ?? 'PLANNED'), + regDtm: String(r['reg_dtm'] ?? ''), + })); +} diff --git a/backend/src/rescue/rescueRouter.ts b/backend/src/rescue/rescueRouter.ts new file mode 100644 index 0000000..ba984d8 --- /dev/null +++ b/backend/src/rescue/rescueRouter.ts @@ -0,0 +1,66 @@ +import express from 'express'; +import { listOps, getOps, listScenarios } from './rescueService.js'; +import { isValidNumber } from '../middleware/security.js'; +import { requireAuth, requirePermission } from '../auth/authMiddleware.js'; + +const router = express.Router(); + +// ============================================================ +// GET /api/rescue/ops — 구조 작전 목록 +// ============================================================ +router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { + try { + const { sttsCd, acdntTpCd, search } = req.query; + const items = await listOps({ + sttsCd: sttsCd as string | undefined, + acdntTpCd: acdntTpCd as string | undefined, + search: search as string | undefined, + }); + res.json(items); + } catch (err) { + console.error('[rescue] 구조 작전 목록 오류:', err); + res.status(500).json({ error: '구조 작전 목록 조회 실패' }); + } +}); + +// ============================================================ +// GET /api/rescue/ops/:sn — 구조 작전 단건 상세 +// ============================================================ +router.get('/ops/:sn', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 구조 작전 번호' }); + return; + } + const item = await getOps(sn); + if (!item) { + res.status(404).json({ error: '구조 작전을 찾을 수 없습니다.' }); + return; + } + res.json(item); + } catch (err) { + console.error('[rescue] 구조 작전 상세 오류:', err); + res.status(500).json({ error: '구조 작전 상세 조회 실패' }); + } +}); + +// ============================================================ +// GET /api/rescue/ops/:sn/scenarios — 시나리오 목록 +// ============================================================ +router.get('/ops/:sn/scenarios', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { + try { + const sn = parseInt(req.params.sn as string, 10); + if (!isValidNumber(sn, 1, 999999)) { + res.status(400).json({ error: '유효하지 않은 구조 작전 번호' }); + return; + } + const scenarios = await listScenarios(sn); + res.json(scenarios); + } catch (err) { + console.error('[rescue] 시나리오 목록 오류:', err); + res.status(500).json({ error: '시나리오 목록 조회 실패' }); + } +}); + +export default router; diff --git a/backend/src/rescue/rescueService.ts b/backend/src/rescue/rescueService.ts new file mode 100644 index 0000000..7ca4cb2 --- /dev/null +++ b/backend/src/rescue/rescueService.ts @@ -0,0 +1,217 @@ +import { wingPool } from '../db/wingDb.js'; + +// ============================================================ +// 인터페이스 +// ============================================================ + +interface RescueOpsListItem { + rescueOpsSn: number; + acdntSn: number | null; + opsCd: string; + acdntTpCd: string | null; + vesselNm: string | null; + commanderNm: string | null; + lon: number | null; + lat: number | null; + locDc: string | null; + depthM: number | null; + currentDc: string | null; + gmM: number | null; + listDeg: number | null; + trimM: number | null; + buoyancyPct: number | null; + oilRateLpm: number | null; + bmRatioPct: number | null; + totalCrew: number | null; + survivors: number | null; + missing: number | null; + sttsCd: string; + regDtm: string; + mdfcnDtm: string; +} + +interface RescueOpsDetail extends RescueOpsListItem { + hydroData: Record | null; + gmdssData: Record | null; +} + +interface RescueScenarioItem { + scenarioSn: number; + rescueOpsSn: number; + timeStep: string; + scenarioDtm: string | null; + svrtCd: string | null; + gmM: number | null; + listDeg: number | null; + trimM: number | null; + buoyancyPct: number | null; + oilRateLpm: number | null; + bmRatioPct: number | null; + description: string | null; + compartments: unknown[] | null; + assessment: unknown[] | null; + actions: unknown[] | null; + sortOrd: number; + regDtm: string; +} + +interface ListOpsInput { + sttsCd?: string; + acdntTpCd?: string; + search?: string; +} + +// ============================================================ +// 구조 작전 목록 조회 +// ============================================================ + +export async function listOps(input?: ListOpsInput): Promise { + const conditions: string[] = ["USE_YN = 'Y'"]; + const params: unknown[] = []; + let idx = 1; + + if (input?.sttsCd) { + conditions.push(`STTS_CD = $${idx++}`); + params.push(input.sttsCd); + } + if (input?.acdntTpCd) { + conditions.push(`ACDNT_TP_CD = $${idx++}`); + params.push(input.acdntTpCd); + } + if (input?.search) { + conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`); + params.push(input.search); + } + + const where = 'WHERE ' + conditions.join(' AND '); + + const sql = ` + SELECT + RESCUE_OPS_SN, ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM, + LON, LAT, LOC_DC, DEPTH_M, CURRENT_DC, + GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, + TOTAL_CREW, SURVIVORS, MISSING, + STTS_CD, REG_DTM, MDFCN_DTM + FROM wing.RESCUE_OPS + ${where} + ORDER BY REG_DTM DESC + `; + + const { rows } = await wingPool.query(sql, params); + + return rows.map((r: Record) => ({ + rescueOpsSn: r.rescue_ops_sn as number, + acdntSn: (r.acdnt_sn as number) ?? null, + opsCd: r.ops_cd as string, + acdntTpCd: (r.acdnt_tp_cd as string) ?? null, + vesselNm: (r.vessel_nm as string) ?? null, + commanderNm: (r.commander_nm as string) ?? null, + lon: r.lon != null ? parseFloat(r.lon as string) : null, + lat: r.lat != null ? parseFloat(r.lat as string) : null, + locDc: (r.loc_dc as string) ?? null, + depthM: r.depth_m != null ? parseFloat(r.depth_m as string) : null, + currentDc: (r.current_dc as string) ?? null, + gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null, + listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null, + trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null, + buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null, + oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null, + bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null, + totalCrew: (r.total_crew as number) ?? null, + survivors: (r.survivors as number) ?? null, + missing: (r.missing as number) ?? null, + sttsCd: r.stts_cd as string, + regDtm: (r.reg_dtm as Date).toISOString(), + mdfcnDtm: (r.mdfcn_dtm as Date).toISOString(), + })); +} + +// ============================================================ +// 구조 작전 단건 상세 조회 +// ============================================================ + +export async function getOps(sn: number): Promise { + const sql = ` + SELECT + RESCUE_OPS_SN, ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM, + LON, LAT, LOC_DC, DEPTH_M, CURRENT_DC, + GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, + TOTAL_CREW, SURVIVORS, MISSING, + HYDRO_DATA, GMDSS_DATA, + STTS_CD, REG_DTM, MDFCN_DTM + FROM wing.RESCUE_OPS + WHERE RESCUE_OPS_SN = $1 AND USE_YN = 'Y' + `; + + const { rows } = await wingPool.query(sql, [sn]); + if (rows.length === 0) return null; + + const r = rows[0] as Record; + + return { + rescueOpsSn: r.rescue_ops_sn as number, + acdntSn: (r.acdnt_sn as number) ?? null, + opsCd: r.ops_cd as string, + acdntTpCd: (r.acdnt_tp_cd as string) ?? null, + vesselNm: (r.vessel_nm as string) ?? null, + commanderNm: (r.commander_nm as string) ?? null, + lon: r.lon != null ? parseFloat(r.lon as string) : null, + lat: r.lat != null ? parseFloat(r.lat as string) : null, + locDc: (r.loc_dc as string) ?? null, + depthM: r.depth_m != null ? parseFloat(r.depth_m as string) : null, + currentDc: (r.current_dc as string) ?? null, + gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null, + listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null, + trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null, + buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null, + oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null, + bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null, + totalCrew: (r.total_crew as number) ?? null, + survivors: (r.survivors as number) ?? null, + missing: (r.missing as number) ?? null, + hydroData: (r.hydro_data as Record) ?? null, + gmdssData: (r.gmdss_data as Record) ?? null, + sttsCd: r.stts_cd as string, + regDtm: (r.reg_dtm as Date).toISOString(), + mdfcnDtm: (r.mdfcn_dtm as Date).toISOString(), + }; +} + +// ============================================================ +// 시나리오 목록 조회 +// ============================================================ + +export async function listScenarios(rescueOpsSn: number): Promise { + const sql = ` + SELECT + SCENARIO_SN, RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD, + GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, + DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, + SORT_ORD, REG_DTM + FROM wing.RESCUE_SCENARIO + WHERE RESCUE_OPS_SN = $1 + ORDER BY SORT_ORD ASC + `; + + const { rows } = await wingPool.query(sql, [rescueOpsSn]); + + return rows.map((r: Record) => ({ + scenarioSn: r.scenario_sn as number, + rescueOpsSn: r.rescue_ops_sn as number, + timeStep: r.time_step as string, + scenarioDtm: r.scenario_dtm != null ? (r.scenario_dtm as Date).toISOString() : null, + svrtCd: (r.svrt_cd as string) ?? null, + gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null, + listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null, + trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null, + buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null, + oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null, + bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null, + description: (r.description as string) ?? null, + compartments: (r.compartments as unknown[]) ?? null, + assessment: (r.assessment as unknown[]) ?? null, + actions: (r.actions as unknown[]) ?? null, + sortOrd: r.sort_ord as number, + regDtm: (r.reg_dtm as Date).toISOString(), + })); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 219f700..25e2865 100755 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -19,6 +19,9 @@ import reportsRouter from './reports/reportsRouter.js' import assetsRouter from './assets/assetsRouter.js' import incidentsRouter from './incidents/incidentsRouter.js' import scatRouter from './scat/scatRouter.js' +import predictionRouter from './prediction/predictionRouter.js' +import aerialRouter from './aerial/aerialRouter.js' +import rescueRouter from './rescue/rescueRouter.js' import { sanitizeBody, sanitizeQuery, @@ -149,6 +152,9 @@ app.use('/api/reports', reportsRouter) app.use('/api/assets', assetsRouter) app.use('/api/incidents', incidentsRouter) app.use('/api/scat', scatRouter) +app.use('/api/prediction', predictionRouter) +app.use('/api/aerial', aerialRouter) +app.use('/api/rescue', rescueRouter) // 헬스 체크 app.get('/health', (_req, res) => { diff --git a/database/migration/012_board_ext.sql b/database/migration/012_board_ext.sql new file mode 100644 index 0000000..9f07cf9 --- /dev/null +++ b/database/migration/012_board_ext.sql @@ -0,0 +1,51 @@ +-- ============================================================ +-- 012_board_ext.sql +-- MANUAL_FILE (해경매뉴얼) + BOARD_ATTACH (게시판 첨부파일) +-- ============================================================ + +-- 매뉴얼 파일 +CREATE TABLE IF NOT EXISTS MANUAL_FILE ( + MANUAL_SN SERIAL PRIMARY KEY, + CATG_NM VARCHAR(50), + TITLE VARCHAR(200) NOT NULL, + VERSION VARCHAR(20), + FILE_TP VARCHAR(20), + FILE_SZ VARCHAR(20), + FILE_PATH VARCHAR(500), + AUTHOR_NM VARCHAR(50), + DWNLD_CNT INTEGER DEFAULT 0, + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() +); + +-- 게시판 첨부파일 +CREATE TABLE IF NOT EXISTS BOARD_ATTACH ( + ATTACH_SN SERIAL PRIMARY KEY, + POST_SN INTEGER NOT NULL REFERENCES BOARD_POST(POST_SN) ON DELETE CASCADE, + FILE_NM VARCHAR(200) NOT NULL, + ORGNL_NM VARCHAR(200), + FILE_PATH VARCHAR(500), + FILE_SZ INTEGER, + FILE_EXT VARCHAR(10), + SORT_ORD INTEGER DEFAULT 0, + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); + +-- ============================================================ +-- MANUAL_FILE 시드 데이터 (12건) +-- ============================================================ + +INSERT INTO MANUAL_FILE (CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, AUTHOR_NM, DWNLD_CNT, REG_DTM) VALUES +('방제매뉴얼', '해양오염방제 업무매뉴얼 (2026 개정판)', 'v4.2', 'PDF', '28.5 MB', '해양오염대응국', 1245, '2026-02-15'::timestamptz), +('방제매뉴얼', '해양오염 방제자원 운용 지침서', 'v3.1', 'PDF', '15.2 MB', '방제과', 892, '2026-01-20'::timestamptz), +('방제매뉴얼', '오일펜스 전개 · 회수 표준절차서', 'v2.8', 'PDF', '12.7 MB', '방제과', 1567, '2025-12-10'::timestamptz), +('대응매뉴얼', '해양오염사고 초동대응 매뉴얼', 'v5.0', 'PDF', '22.1 MB', '해양오염대응국', 2103, '2026-02-01'::timestamptz), +('대응매뉴얼', 'HNS 해양사고 대응 가이드라인', 'v2.3', 'PDF', '18.9 MB', '해양오염대응국', 734, '2025-11-15'::timestamptz), +('대응매뉴얼', '대량 유출유 방제 대응 체계 매뉴얼', 'v3.5', 'PDF', '31.4 MB', '방제과', 1089, '2025-10-20'::timestamptz), +('교육자료', '방제요원 교육훈련 교재 (기본과정)', 'v6.1', 'PDF', '45.3 MB', '교육훈련과', 567, '2026-01-10'::timestamptz), +('교육자료', '방제요원 교육훈련 교재 (심화과정)', 'v4.0', 'PDF', '52.8 MB', '교육훈련과', 423, '2025-12-05'::timestamptz), +('교육자료', '유류오염 식별 및 샘플링 실무 교재', 'v2.0', 'PDF', '9.6 MB', '교육훈련과', 312, '2025-09-18'::timestamptz), +('법령·규정', '해양환경관리법 시행규칙 (방제 관련)', '2026', 'PDF', '5.4 MB', '법무담당관', 645, '2026-02-10'::timestamptz), +('법령·규정', '해양오염방제 자재·약제 검정 기준', '2025', 'PDF', '3.8 MB', '법무담당관', 389, '2025-08-22'::timestamptz), +('법령·규정', '방제선·방제정 운용 및 관리 규정', '2026', 'PDF', '7.2 MB', '장비관리과', 478, '2026-01-05'::timestamptz); diff --git a/database/migration/013_hns_analysis.sql b/database/migration/013_hns_analysis.sql new file mode 100644 index 0000000..0a05240 --- /dev/null +++ b/database/migration/013_hns_analysis.sql @@ -0,0 +1,130 @@ +-- ============================================================ +-- 013_hns_analysis.sql +-- HNS 대기확산 분석 테이블 + 시드 데이터 +-- ============================================================ + +SET search_path TO wing, public; + +CREATE TABLE IF NOT EXISTS HNS_ANALYSIS ( + HNS_ANLYS_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER REFERENCES ACDNT(ACDNT_SN), + ANLYS_NM VARCHAR(200) NOT NULL, + ACDNT_DTM TIMESTAMPTZ, + LOC_NM VARCHAR(200), + -- 위치 (표준: LON/LAT/GEOM) + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + LOC_DC VARCHAR(100), + -- 물질 정보 + SBST_SN INTEGER, + SBST_NM VARCHAR(100), + UN_NO VARCHAR(10), + CAS_NO VARCHAR(20), + SPIL_QTY NUMERIC(10,2), + SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', + SPIL_TP_CD VARCHAR(20), + FCST_HR INTEGER, + ALGO_CD VARCHAR(20), + CRIT_MDL_CD VARCHAR(10), + -- 기상 조건 + WIND_SPD NUMERIC(5,1), + WIND_DIR VARCHAR(10), + TEMP NUMERIC(4,1), + HUMID NUMERIC(4,1), + ATM_STBL_CD VARCHAR(10), + -- 실행 상태 + EXEC_STTS_CD VARCHAR(20) DEFAULT 'COMPLETED', + RISK_CD VARCHAR(20), + ANALYST_NM VARCHAR(50), + -- 결과 + RSLT_DATA JSONB, + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT CK_HNS_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED')) +); + +-- ============================================================ +-- HNS_ANALYSIS 시드 데이터 (8건 — 기존 mock 데이터 기반) +-- ============================================================ + +INSERT INTO HNS_ANALYSIS ( + ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, GEOM, LOC_DC, + SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, + WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, + EXEC_STTS_CD, RISK_CD, ANALYST_NM, + RSLT_DATA, REG_DTM +) VALUES +( + '울산 온산항 톨루엔 누출', '2025-02-11 14:15'::timestamptz, + '부산항 신항', 129.067, 35.078, ST_SetSRID(ST_MakePoint(129.067, 35.078), 4326), '129.067 + 35.078', + '톨루엔 (Toluene)', 12.0, 'KL', 24, 'ALOHA', 'AEGL', + 5.2, 'SW', 18.5, 65, 'D', + 'COMPLETED', 'HIGH', '운영팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "1.8 km", "zones": [{"level":"AEGL-3","radius":500},{"level":"AEGL-2","radius":1000},{"level":"AEGL-1","radius":1500}]}'::jsonb, + '2025-02-11 14:15'::timestamptz +), +( + '여수 엠프시아 누출', '2025-02-09 08:40'::timestamptz, + '여수항', 127.662, 34.740, ST_SetSRID(ST_MakePoint(127.662, 34.740), 4326), '127.662 + 34.740', + '벤젠 (Benzene)', 5.0, 'TON', 12, 'ALOHA', 'AEGL', + 4.1, 'NE', 12.3, 58, 'C', + 'COMPLETED', 'HIGH', '남해팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "2.4 km", "zones": [{"level":"AEGL-3","radius":600},{"level":"AEGL-2","radius":1200},{"level":"AEGL-1","radius":1800}]}'::jsonb, + '2025-02-09 08:40'::timestamptz +), +( + '부산 수소 추진연 폭발', '2025-02-07 12:15'::timestamptz, + '부산항', 129.043, 35.097, ST_SetSRID(ST_MakePoint(129.043, 35.097), 4326), '129.043 + 35.097', + '수소 (Hydrogen)', 0.8, 'TON', 6, 'CAMEO', 'AEGL', + 6.5, 'W', 15.0, 55, 'D', + 'COMPLETED', 'CRITICAL', '남해팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "0.22 km", "zones": [{"level":"AEGL-3","radius":100},{"level":"AEGL-2","radius":150},{"level":"AEGL-1","radius":220}]}'::jsonb, + '2025-02-07 12:15'::timestamptz +), +( + '인천항 메탄올 유출', '2025-02-03 16:50'::timestamptz, + '인천항', 126.598, 37.449, ST_SetSRID(ST_MakePoint(126.598, 37.449), 4326), '126.598 + 37.449', + '메탄올 (Methanol)', 8.5, 'KL', 24, 'ALOHA', 'AEGL', + 3.8, 'SE', 8.2, 72, 'E', + 'COMPLETED', 'MEDIUM', '중부팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "1.2 km", "zones": [{"level":"AEGL-3","radius":300},{"level":"AEGL-2","radius":700},{"level":"AEGL-1","radius":1200}]}'::jsonb, + '2025-02-03 16:50'::timestamptz +), +( + '평택항 LPG 누출', '2025-01-28 09:20'::timestamptz, + '평택항', 126.822, 36.969, ST_SetSRID(ST_MakePoint(126.822, 36.969), 4326), '126.822 + 36.969', + 'LPG', 3.2, 'TON', 12, 'CAMEO', 'AEGL', + 4.5, 'N', 5.5, 68, 'D', + 'COMPLETED', 'MEDIUM', '중부팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "0.95 km", "zones": [{"level":"AEGL-3","radius":250},{"level":"AEGL-2","radius":550},{"level":"AEGL-1","radius":950}]}'::jsonb, + '2025-01-28 09:20'::timestamptz +), +( + '광양항 벤젠 누출', '2025-01-22 11:30'::timestamptz, + '광양항', 127.736, 34.930, ST_SetSRID(ST_MakePoint(127.736, 34.930), 4326), '127.736 + 34.930', + '벤젠 (Benzene)', 6.0, 'KL', 24, 'ALOHA', 'AEGL', + 3.2, 'S', 10.1, 60, 'C', + 'COMPLETED', 'LOW', '남해팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "1.5 km", "zones": [{"level":"AEGL-3","radius":400},{"level":"AEGL-2","radius":800},{"level":"AEGL-1","radius":1500}]}'::jsonb, + '2025-01-22 11:30'::timestamptz +), +( + '목포항 염소스가 유출', '2025-01-15 14:10'::timestamptz, + '서구항', 126.392, 34.793, ST_SetSRID(ST_MakePoint(126.392, 34.793), 4326), '126.392 + 34.793', + '염소 (Chlorine)', 2.0, 'TON', 12, 'CAMEO', 'AEGL', + 5.0, 'NW', 7.5, 62, 'D', + 'COMPLETED', 'LOW', '서해팀, 방재팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "1.0 km", "zones": [{"level":"AEGL-3","radius":300},{"level":"AEGL-2","radius":600},{"level":"AEGL-1","radius":1000}]}'::jsonb, + '2025-01-15 14:10'::timestamptz +), +( + '제주 에탄올링 탱크 파열', '2025-01-08 07:55'::timestamptz, + '제주항', 126.528, 33.519, ST_SetSRID(ST_MakePoint(126.528, 33.519), 4326), '126.528 + 33.519', + '에탄올 (Ethanol)', 1.5, 'TON', 6, 'CAMEO', 'AEGL', + 6.8, 'NE', 12.0, 55, 'C', + 'COMPLETED', 'LOW', '제주팀, 일대팀', + '{"aegl3": true, "aegl2": true, "aegl1": true, "damageRadius": "0.8 km", "zones": [{"level":"AEGL-3","radius":200},{"level":"AEGL-2","radius":450},{"level":"AEGL-1","radius":800}]}'::jsonb, + '2025-01-08 07:55'::timestamptz +); diff --git a/database/migration/014_prediction.sql b/database/migration/014_prediction.sql new file mode 100644 index 0000000..d4a99f6 --- /dev/null +++ b/database/migration/014_prediction.sql @@ -0,0 +1,111 @@ +-- 014_prediction.sql: BACKTRACK + VESSEL_INFO + BOOM_LINE 테이블 +-- Phase 4 Round 3: Prediction 탭 Mock→API 전환 + +SET search_path TO wing, public; + +-- ============================================================ +-- 사고 선박 정보 (VESSEL_INFO) +-- ============================================================ +CREATE TABLE IF NOT EXISTS VESSEL_INFO ( + VESSEL_INFO_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER NOT NULL REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + IMO_NO VARCHAR(10), + MMSI_NO VARCHAR(10), + VESSEL_NM VARCHAR(100), + VESSEL_TP VARCHAR(30), + LOA_M NUMERIC(6,1), + BREADTH_M NUMERIC(5,1), + DRAFT_M NUMERIC(5,1), + GT NUMERIC(10,0), + DWT NUMERIC(10,0), + BUILT_YR SMALLINT, + FLAG_CD VARCHAR(5), + CALLSIGN VARCHAR(10), + ENGINE_DC VARCHAR(100), + INSURANCE_DATA JSONB, + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_vessel_acdnt ON VESSEL_INFO(ACDNT_SN); + +-- ============================================================ +-- 역추적 분석 (BACKTRACK) +-- ============================================================ +CREATE TABLE IF NOT EXISTS BACKTRACK ( + BACKTRACK_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER NOT NULL REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + EST_SPIL_DTM TIMESTAMPTZ, + ANLYS_RANGE VARCHAR(20), + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + LOC_DC VARCHAR(100), + SRCH_RADIUS_NM NUMERIC(5,1), + TOTAL_VESSELS INTEGER, + EXEC_STTS_CD VARCHAR(20) DEFAULT 'PENDING', + RSLT_DATA JSONB, + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT CK_BACKTRACK_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED')) +); + +CREATE INDEX IF NOT EXISTS idx_backtrack_acdnt ON BACKTRACK(ACDNT_SN); +CREATE INDEX IF NOT EXISTS idx_backtrack_geom ON BACKTRACK USING GIST(GEOM); + +-- ============================================================ +-- 오일펜스 배치 (BOOM_LINE) +-- ============================================================ +CREATE TABLE IF NOT EXISTS BOOM_LINE ( + BOOM_LINE_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER NOT NULL REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE, + BOOM_NM VARCHAR(100), + PRIORITY_ORD INTEGER DEFAULT 0, + GEOM GEOMETRY(LineString, 4326), + LENGTH_M NUMERIC(8,1), + EFFICIENCY_PCT NUMERIC(5,1), + DEPLOY_DTM TIMESTAMPTZ, + STTS_CD VARCHAR(20) DEFAULT 'PLANNED', + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT CK_BOOM_STTS CHECK (STTS_CD IN ('PLANNED','DEPLOYED','RETRIEVED')) +); + +CREATE INDEX IF NOT EXISTS idx_boom_acdnt ON BOOM_LINE(ACDNT_SN); +CREATE INDEX IF NOT EXISTS idx_boom_geom ON BOOM_LINE USING GIST(GEOM); + +-- ============================================================ +-- 시드 데이터 — 여수 유조선 충돌 사고(ACDNT_SN=1) 기준 +-- ============================================================ + +-- 선박 정보 +INSERT INTO VESSEL_INFO (ACDNT_SN, IMO_NO, MMSI_NO, VESSEL_NM, VESSEL_TP, LOA_M, BREADTH_M, DRAFT_M, GT, DWT, BUILT_YR, FLAG_CD, CALLSIGN, ENGINE_DC, INSURANCE_DATA) +VALUES +(1, '9412856', '440123456', 'ORIENTAL GLORY', '유조선', 183.0, 32.2, 11.2, 29876, 46823, 2009, 'KR', 'HLOG7', 'MAN B&W 6S50MC-C', '[{"type":"P&I","insurer":"한국P&I클럽","value":"500M","currency":"USD"},{"type":"H&M","insurer":"삼성화재해상","value":"89.77M","currency":"SDR"},{"type":"CLC","insurer":"삼성화재해상","value":"89.77M","currency":"SDR"}]'::jsonb), +(1, '9538721', '413987654', 'HAI FENG 168', '벌크선', 225.0, 32.3, 14.5, 43210, 82150, 2012, 'CN', 'BQHR3', 'MAN B&W 7S50MC', '[]'::jsonb); + +-- 역추적 결과 +INSERT INTO BACKTRACK (ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE, LON, LAT, GEOM, LOC_DC, SRCH_RADIUS_NM, TOTAL_VESSELS, EXEC_STTS_CD, RSLT_DATA) +VALUES ( + 1, + '2025-02-18 06:30:00+09', + '±12시간', + 127.6845, 34.7312, + ST_SetSRID(ST_MakePoint(127.6845, 34.7312), 4326), + '127.6845 + 34.7312', + 10.0, + 47, + 'COMPLETED', + '{ + "vessels": [ + {"rank":1,"name":"ORIENTAL GLORY","imo":"9412856","type":"유조선","flag":"🇰🇷","flagCountry":"대한민국","probability":96.7,"closestTime":"06:28","closestDistance":0.02,"speedChange":"급감속","aisStatus":"충돌신호","description":"06:28 HAI FENG 168과 충돌 → 06:30 No.1P 탱크 파공 → 벙커C유 유출 개시.","color":"#ef4444"}, + {"rank":2,"name":"HAI FENG 168","imo":"9538721","type":"벌크선","flag":"🇨🇳","flagCountry":"중국","probability":23.4,"closestTime":"06:28","closestDistance":0.02,"speedChange":"급감속","aisStatus":"미확인","description":"충돌 당사선. 구상선수 손상으로 연료유탱크 미세 누유 가능성.","color":"#f97316"}, + {"rank":3,"name":"DONG JIN STAR","imo":"9287403","type":"케미컬탱커","flag":"🇰🇷","flagCountry":"대한민국","probability":4.1,"closestTime":"05:45","closestDistance":1.8,"speedChange":"정상","aisStatus":"정상","description":"","color":"#64788c"} + ], + "replayShips": [ + {"vesselName":"ORIENTAL GLORY","color":"#ef4444","path":[{"lat":34.82,"lon":127.58},{"lat":34.80,"lon":127.60},{"lat":34.78,"lon":127.62},{"lat":34.76,"lon":127.64},{"lat":34.75,"lon":127.66},{"lat":34.74,"lon":127.67},{"lat":34.73,"lon":127.68},{"lat":34.7312,"lon":127.6845},{"lat":34.7312,"lon":127.6845}],"speedLabels":["8.3 kts · 215°","8.3 kts · 215°","8.3 kts · 215°","8.3 kts · 215°","8.3 kts · 215°","8.3 kts · 215°","2.1 kts · 215°","0.2 kts · 정지","0.2 kts · 정지"]}, + {"vesselName":"HAI FENG 168","color":"#f97316","path":[{"lat":34.64,"lon":127.78},{"lat":34.66,"lon":127.76},{"lat":34.68,"lon":127.74},{"lat":34.70,"lon":127.72},{"lat":34.71,"lon":127.71},{"lat":34.72,"lon":127.70},{"lat":34.73,"lon":127.69},{"lat":34.7312,"lon":127.6845},{"lat":34.7315,"lon":127.6840}],"speedLabels":["11.2 kts · 038°","11.2 kts · 038°","11.2 kts · 038°","11.2 kts · 038°","11.2 kts · 038°","11.2 kts · 038°","3.4 kts · 038°","0.5 kts · 정지","0.5 kts · 정지"]}, + {"vesselName":"DONG JIN STAR","color":"#64788c","path":[{"lat":34.82,"lon":127.52},{"lat":34.80,"lon":127.53},{"lat":34.78,"lon":127.54},{"lat":34.76,"lon":127.55},{"lat":34.74,"lon":127.56},{"lat":34.72,"lon":127.57},{"lat":34.70,"lon":127.58},{"lat":34.68,"lon":127.59},{"lat":34.66,"lon":127.60}],"speedLabels":["10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°","10.5 kts · 180°"]} + ], + "collisionEvent": {"position":{"lat":34.7312,"lon":127.6845},"timeLabel":"06:28 충돌","progressPercent":75} + }'::jsonb +); diff --git a/database/migration/015_aerial.sql b/database/migration/015_aerial.sql new file mode 100644 index 0000000..db37731 --- /dev/null +++ b/database/migration/015_aerial.sql @@ -0,0 +1,128 @@ +-- ============================================================ +-- 015: 항공 방제 테이블 생성 + 시드 데이터 +-- AERIAL_MEDIA (15건), CCTV_CAMERA (12건), SAT_REQUEST (7건) +-- ============================================================ + +SET search_path TO wing, public; + +-- ============================================================ +-- 1. AERIAL_MEDIA — 항공·위성 미디어 메타정보 +-- ============================================================ +CREATE TABLE IF NOT EXISTS AERIAL_MEDIA ( + AERIAL_MEDIA_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER REFERENCES ACDNT(ACDNT_SN), + FILE_NM VARCHAR(200) NOT NULL, + ORGNL_NM VARCHAR(200), + FILE_PATH VARCHAR(500), + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + LOC_DC VARCHAR(100), + EQUIP_TP_CD VARCHAR(20), + EQUIP_NM VARCHAR(50), + MEDIA_TP_CD VARCHAR(20), + TAKNG_DTM TIMESTAMPTZ, + FILE_SZ VARCHAR(20), + RESOLUTION VARCHAR(30), + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_aerial_media_acdnt ON AERIAL_MEDIA(ACDNT_SN); +CREATE INDEX IF NOT EXISTS idx_aerial_media_geom ON AERIAL_MEDIA USING GIST(GEOM); + +-- ============================================================ +-- 2. CCTV_CAMERA — CCTV 카메라 정보 +-- ============================================================ +CREATE TABLE IF NOT EXISTS CCTV_CAMERA ( + CCTV_SN SERIAL PRIMARY KEY, + CAMERA_NM VARCHAR(100) NOT NULL, + REGION_NM VARCHAR(20), + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + LOC_DC VARCHAR(200), + COORD_DC VARCHAR(50), + STTS_CD VARCHAR(20) DEFAULT 'LIVE', + PTZ_YN CHAR(1) DEFAULT 'N', + SOURCE_NM VARCHAR(50), + STREAM_URL VARCHAR(500), + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_cctv_geom ON CCTV_CAMERA USING GIST(GEOM); + +-- ============================================================ +-- 3. SAT_REQUEST — 위성 촬영 요청 +-- ============================================================ +CREATE TABLE IF NOT EXISTS SAT_REQUEST ( + SAT_REQ_SN SERIAL PRIMARY KEY, + REQ_CD VARCHAR(20) NOT NULL UNIQUE, + ACDNT_SN INTEGER REFERENCES ACDNT(ACDNT_SN), + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + ZONE_DC VARCHAR(200), + COORD_DC VARCHAR(50), + ZONE_AREA_KM2 NUMERIC(8,2), + SAT_NM VARCHAR(50), + PROVIDER_NM VARCHAR(50), + RESOLUTION VARCHAR(20), + PURPOSE_DC VARCHAR(200), + REQSTR_NM VARCHAR(50), + REQ_DTM TIMESTAMPTZ, + EXPECTED_RCV_DTM TIMESTAMPTZ, + STTS_CD VARCHAR(20) DEFAULT 'PENDING', + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT CK_SAT_STTS CHECK (STTS_CD IN ('PENDING','SHOOTING','COMPLETED','CANCELLED')) +); +CREATE INDEX IF NOT EXISTS idx_sat_req_geom ON SAT_REQUEST USING GIST(GEOM); + +-- ============================================================ +-- 4. AERIAL_MEDIA 시드 데이터 (15건) +-- ============================================================ +INSERT INTO AERIAL_MEDIA (ACDNT_SN, FILE_NM, ORGNL_NM, LON, LAT, GEOM, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD, TAKNG_DTM, FILE_SZ, RESOLUTION) VALUES +(1, '여수항_드론_001.jpg', '여수항_드론_001.jpg', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'drone', 'DJI M300', '사진', '2025-02-18 07:30:00+09', '12.4 MB', '5472×3648'), +(1, '여수항_드론_002.jpg', '여수항_드론_002.jpg', 127.6850, 34.7315, ST_SetSRID(ST_MakePoint(127.6850::float, 34.7315::float), 4326), '127.685 + 34.7315', 'drone', 'DJI M300', '사진', '2025-02-18 08:15:00+09', '11.8 MB', '5472×3648'), +(1, '여수항_열화상_001.tif', '여수항_열화상_001.tif', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'drone', 'DJI M300 IR', '적외선', '2025-02-18 07:45:00+09', '45.2 MB', '640×512'), +(1, '여수항_드론_003.jpg', '여수항_드론_003.jpg', 127.6860, 34.7300, ST_SetSRID(ST_MakePoint(127.6860::float, 34.7300::float), 4326), '127.686 + 34.73', 'drone', 'DJI M300', '사진', '2025-02-18 10:30:00+09', '13.1 MB', '5472×3648'), +(1, '여수항_영상_001.mp4', '여수항_영상_001.mp4', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'drone', 'DJI M300', '영상', '2025-02-18 09:00:00+09', '1.2 GB', '4K 30fps'), +(1, '여수항_드론_004.jpg', '여수항_드론_004.jpg', 127.6870, 34.7290, ST_SetSRID(ST_MakePoint(127.6870::float, 34.7290::float), 4326), '127.687 + 34.729', 'drone', 'DJI Mavic 3E', '사진', '2025-02-18 11:20:00+09', '8.7 MB', '5280×3956'), +(1, '여수항_드론_005.jpg', '여수항_드론_005.jpg', 127.6855, 34.7295, ST_SetSRID(ST_MakePoint(127.6855::float, 34.7295::float), 4326), '127.6855 + 34.7295', 'drone', 'DJI Mavic 3E', '사진', '2025-02-18 11:35:00+09', '9.1 MB', '5280×3956'), +(1, '여수항_드론_006.jpg', '여수항_드론_006.jpg', 127.6840, 34.7320, ST_SetSRID(ST_MakePoint(127.6840::float, 34.7320::float), 4326), '127.684 + 34.732', 'drone', 'DJI M300', '사진', '2025-02-18 14:00:00+09', '14.2 MB', '5472×3648'), +(1, '여수항_항공_001.jpg', '여수항_항공_001.jpg', 127.6850, 34.7310, ST_SetSRID(ST_MakePoint(127.6850::float, 34.7310::float), 4326), '127.685 + 34.731', 'plane', 'CN-235', '사진', '2025-02-18 09:30:00+09', '28.5 MB', '8256×5504'), +(1, '여수항_항공_002.jpg', '여수항_항공_002.jpg', 127.6860, 34.7305, ST_SetSRID(ST_MakePoint(127.6860::float, 34.7305::float), 4326), '127.686 + 34.7305', 'plane', 'CN-235', '사진', '2025-02-18 10:00:00+09', '26.3 MB', '8256×5504'), +(1, '여수항_항공영상_001.mp4', '여수항_항공영상_001.mp4', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'plane', 'B737 해감', '영상', '2025-02-18 11:00:00+09', '3.8 GB', '4K 60fps'), +(1, '여수항_위성_001.tif', '여수항_위성_001.tif', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'satellite', 'KOMPSAT-3A', '가시광', '2025-02-18 10:45:00+09', '156 MB', '0.5m GSD'), +(1, '여수항_SAR_001.tif', '여수항_SAR_001.tif', 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '127.6845 + 34.7312', 'satellite', 'Sentinel-1', 'SAR', '2025-02-18 18:30:00+09', '234 MB', '10m'), +(2, '통영항_드론_001.jpg', '통영항_드론_001.jpg', 128.4331, 34.8342, ST_SetSRID(ST_MakePoint(128.4331::float, 34.8342::float), 4326), '128.4331 + 34.8342', 'drone', 'DJI M300', '사진', '2025-02-08 15:00:00+09', '11.5 MB', '5472×3648'), +(3, '군산항_드론_001.jpg', '군산항_드론_001.jpg', 126.5650, 35.9838, ST_SetSRID(ST_MakePoint(126.5650::float, 35.9838::float), 4326), '126.565 + 35.9838', 'drone', 'DJI Mavic 3E', '사진', '2025-02-09 10:00:00+09', '8.9 MB', '5280×3956'); + +-- ============================================================ +-- 5. CCTV_CAMERA 시드 데이터 (12건) +-- ============================================================ +INSERT INTO CCTV_CAMERA (CAMERA_NM, REGION_NM, LON, LAT, GEOM, LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM) VALUES +('서귀포항 동측', '제주', 126.57, 33.24, ST_SetSRID(ST_MakePoint(126.57::float, 33.24::float), 4326), '제주 서귀포시 서귀동', '33.24°N 126.57°E', 'LIVE', 'Y', 'TAGO'), +('제주항 외항', '제주', 126.53, 33.52, ST_SetSRID(ST_MakePoint(126.53::float, 33.52::float), 4326), '제주 제주시 건입동', '33.52°N 126.53°E', 'LIVE', 'Y', 'TAGO'), +('성산포항', '제주', 126.93, 33.46, ST_SetSRID(ST_MakePoint(126.93::float, 33.46::float), 4326), '제주 서귀포시 성산읍', '33.46°N 126.93°E', 'LIVE', 'N', 'KBS'), +('한림항', '제주', 126.27, 33.41, ST_SetSRID(ST_MakePoint(126.27::float, 33.41::float), 4326), '제주 제주시 한림읍', '33.41°N 126.27°E', 'LIVE', 'N', 'TAGO'), +('여수 돌산대교', '남해', 127.75, 34.74, ST_SetSRID(ST_MakePoint(127.75::float, 34.74::float), 4326), '전남 여수시 돌산읍', '34.74°N 127.75°E', 'LIVE', 'Y', 'KBS'), +('통영 해상공원', '남해', 128.42, 34.84, ST_SetSRID(ST_MakePoint(128.42::float, 34.84::float), 4326), '경남 통영시 동호동', '34.84°N 128.42°E', 'LIVE', 'N', 'TAGO'), +('거제 장승포항', '남해', 128.69, 34.87, ST_SetSRID(ST_MakePoint(128.69::float, 34.87::float), 4326), '경남 거제시 장승포동', '34.87°N 128.69°E', 'OFFLINE', 'N', 'TAGO'), +('목포 영산강', '서해', 126.39, 34.79, ST_SetSRID(ST_MakePoint(126.39::float, 34.79::float), 4326), '전남 목포시 산정동', '34.79°N 126.39°E', 'LIVE', 'Y', 'KBS'), +('인천 송도', '서해', 126.64, 37.38, ST_SetSRID(ST_MakePoint(126.64::float, 37.38::float), 4326), '인천 연수구 송도동', '37.38°N 126.64°E', 'LIVE', 'N', 'TAGO'), +('태안 만리포', '서해', 126.14, 36.79, ST_SetSRID(ST_MakePoint(126.14::float, 36.79::float), 4326), '충남 태안군 소원면', '36.79°N 126.14°E', 'LIVE', 'N', 'TAGO'), +('포항 영일대', '동해', 129.38, 36.06, ST_SetSRID(ST_MakePoint(129.38::float, 36.06::float), 4326), '경북 포항시 북구', '36.06°N 129.38°E', 'LIVE', 'Y', 'KBS'), +('울산 대왕암', '동해', 129.43, 35.49, ST_SetSRID(ST_MakePoint(129.43::float, 35.49::float), 4326), '울산 동구 일산동', '35.49°N 129.43°E', 'LIVE', 'N', 'TAGO'); + +-- ============================================================ +-- 6. SAT_REQUEST 시드 데이터 (7건) +-- ============================================================ +INSERT INTO SAT_REQUEST (REQ_CD, ACDNT_SN, LON, LAT, GEOM, ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM, RESOLUTION, PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM, STTS_CD) VALUES +('SAT-007', 1, 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '여수 돌산 해상 (유출 중심)', '34.73°N 127.68°E', 25.0, 'KOMPSAT-3A', 'KARI', '0.5m', '유출유 확산 현황 촬영', '방제과 김해양', '2025-02-18 06:45:00+09', '2025-02-18 10:45:00+09', 'SHOOTING'), +('SAT-006', 1, 127.7200, 34.6800, ST_SetSRID(ST_MakePoint(127.7200::float, 34.6800::float), 4326), '여수 남방 확산 예측 구역', '34.68°N 127.72°E', 40.0, 'Sentinel-2', 'ESA Copernicus', '10m', '광역 확산 현황 파악', '방제과 이현정', '2025-02-18 07:00:00+09', '2025-02-18 18:30:00+09', 'PENDING'), +('SAT-005', 1, 127.6500, 34.7500, ST_SetSRID(ST_MakePoint(127.6500::float, 34.7500::float), 4326), '여수 돌산 북측', '34.75°N 127.65°E', 15.0, 'KOMPSAT-3A', 'KARI', '0.5m', '연안 부착 유류 감지', '방제과 김해양', '2025-02-17 09:00:00+09', '2025-02-17 14:00:00+09', 'COMPLETED'), +('SAT-004', 2, 128.4331, 34.8342, ST_SetSRID(ST_MakePoint(128.4331::float, 34.8342::float), 4326), '통영항 동방 해역', '34.83°N 128.43°E', 20.0, 'KOMPSAT-5', 'KARI', '1m SAR', '야간 유막 탐지', '통영지 박정수', '2025-02-08 15:00:00+09', '2025-02-09 03:00:00+09', 'COMPLETED'), +('SAT-003', 3, 126.5650, 35.9838, ST_SetSRID(ST_MakePoint(126.5650::float, 35.9838::float), 4326), '군산항 내항', '35.98°N 126.57°E', 10.0, 'KOMPSAT-3A', 'KARI', '0.5m', '송유관 파열 지점 정밀 촬영', '군산지 최영호', '2025-02-09 10:00:00+09', '2025-02-09 13:30:00+09', 'COMPLETED'), +('SAT-002', 1, 127.6845, 34.7312, ST_SetSRID(ST_MakePoint(127.6845::float, 34.7312::float), 4326), '여수 돌산 해상 (2차)', '34.73°N 127.68°E', 25.0, 'WorldView-3', 'DigitalGlobe', '0.3m', '고해상도 유막 두께 분석', '방제과 김해양', '2025-02-18 08:30:00+09', '2025-02-19 06:00:00+09', 'PENDING'), +('SAT-001', 1, 127.7500, 34.6500, ST_SetSRID(ST_MakePoint(127.7500::float, 34.6500::float), 4326), '여수 남방 광역', '34.65°N 127.75°E', 100.0, 'Sentinel-1', 'ESA Copernicus', '10m SAR', '광역 SAR 유막 탐지', '방제과 이현정', '2025-02-18 09:00:00+09', '2025-02-18 21:00:00+09', 'PENDING'); diff --git a/database/migration/016_rescue.sql b/database/migration/016_rescue.sql new file mode 100644 index 0000000..5e8bd3c --- /dev/null +++ b/database/migration/016_rescue.sql @@ -0,0 +1,182 @@ +-- ============================================================ +-- 016_rescue.sql — 구조 시나리오(Rescue) 탭 테이블 + 초기 데이터 +-- RESCUE_OPS (5건), RESCUE_SCENARIO (5건) +-- ============================================================ + +SET search_path TO wing, public; + +-- ============================================================ +-- 1. RESCUE_OPS — 구조 작전 정보 +-- ============================================================ +CREATE TABLE IF NOT EXISTS RESCUE_OPS ( + RESCUE_OPS_SN SERIAL PRIMARY KEY, + ACDNT_SN INTEGER REFERENCES ACDNT(ACDNT_SN), + OPS_CD VARCHAR(20) NOT NULL UNIQUE, + ACDNT_TP_CD VARCHAR(20), + VESSEL_NM VARCHAR(100), + COMMANDER_NM VARCHAR(50), + LON NUMERIC(10,6), + LAT NUMERIC(9,6), + GEOM GEOMETRY(Point, 4326), + LOC_DC VARCHAR(100), + DEPTH_M NUMERIC(6,1), + CURRENT_DC VARCHAR(50), + GM_M NUMERIC(5,2), + LIST_DEG NUMERIC(5,1), + TRIM_M NUMERIC(5,2), + BUOYANCY_PCT NUMERIC(5,1), + OIL_RATE_LPM NUMERIC(8,1), + BM_RATIO_PCT NUMERIC(5,1), + TOTAL_CREW INTEGER, + SURVIVORS INTEGER, + MISSING INTEGER, + HYDRO_DATA JSONB, + GMDSS_DATA JSONB, + STTS_CD VARCHAR(20) DEFAULT 'ACTIVE', + USE_YN CHAR(1) DEFAULT 'Y', + REG_DTM TIMESTAMPTZ DEFAULT NOW(), + MDFCN_DTM TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_rescue_ops_acdnt ON RESCUE_OPS(ACDNT_SN); +CREATE INDEX IF NOT EXISTS idx_rescue_ops_geom ON RESCUE_OPS USING GIST(GEOM); + +-- ============================================================ +-- 2. RESCUE_SCENARIO — 시나리오 타임스텝 +-- ============================================================ +CREATE TABLE IF NOT EXISTS RESCUE_SCENARIO ( + SCENARIO_SN SERIAL PRIMARY KEY, + RESCUE_OPS_SN INTEGER NOT NULL REFERENCES RESCUE_OPS(RESCUE_OPS_SN) ON DELETE CASCADE, + TIME_STEP VARCHAR(10) NOT NULL, + SCENARIO_DTM TIMESTAMPTZ, + SVRT_CD VARCHAR(20), + GM_M NUMERIC(5,2), + LIST_DEG NUMERIC(5,1), + TRIM_M NUMERIC(5,2), + BUOYANCY_PCT NUMERIC(5,1), + OIL_RATE_LPM NUMERIC(8,1), + BM_RATIO_PCT NUMERIC(5,1), + DESCRIPTION TEXT, + COMPARTMENTS JSONB, + ASSESSMENT JSONB, + ACTIONS JSONB, + SORT_ORD INTEGER DEFAULT 0, + REG_DTM TIMESTAMPTZ DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_rescue_scenario_ops ON RESCUE_SCENARIO(RESCUE_OPS_SN); + +-- ============================================================ +-- 3. RESCUE_OPS 시드 데이터 (5건) +-- ============================================================ +INSERT INTO RESCUE_OPS ( + ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM, + LON, LAT, GEOM, LOC_DC, DEPTH_M, CURRENT_DC, + GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, + TOTAL_CREW, SURVIVORS, MISSING, + HYDRO_DATA, GMDSS_DATA, + STTS_CD, USE_YN +) VALUES +( + 1, 'RSC-2026-001', 'collision', 'M/V SEA GUARDIAN', NULL, + 126.25, 37.467, + ST_SetSRID(ST_MakePoint(126.25::float, 37.467::float), 4326), + '37°28''N, 126°15''E', 25.0, '2.5kn NE', + 0.8, 15.0, 2.5, 30.0, 100.0, 92.0, + 20, 15, 5, + '{"displacement":"8,420t","draft":"5.8m","kg":"6.2m","km":"6.9m","tpc":"18.5","mtc":"195"}', + '{"mmsi":"440234567","dscAlert":"VHF Ch.16 자동발신"}', + 'ACTIVE', 'Y' +), +( + 2, 'RSC-2026-002', 'grounding', 'M/V OCEAN BREEZE', NULL, + 128.62, 35.09, + ST_SetSRID(ST_MakePoint(128.62::float, 35.09::float), 4326), + '35°05''N, 128°37''E', 12.0, '1.8kn SW', + 0.3, 22.0, 4.8, 15.0, 250.0, 78.0, + 18, 18, 0, + NULL, NULL, + 'ACTIVE', 'Y' +), +( + 3, 'RSC-2026-003', 'flooding', 'F/V DONG JIN', NULL, + 129.08, 35.15, + ST_SetSRID(ST_MakePoint(129.08::float, 35.15::float), 4326), + '35°09''N, 129°05''E', 30.0, '3.0kn N', + 0.5, 8.0, 1.5, 45.0, 50.0, 85.0, + 8, 7, 1, + NULL, NULL, + 'ACTIVE', 'Y' +), +( + 4, 'RSC-2025-045', 'capsizing', 'M/V PACIFIC STAR', NULL, + 126.57, 33.24, + ST_SetSRID(ST_MakePoint(126.57::float, 33.24::float), 4326), + '33°14''N, 126°34''E', 45.0, '2.0kn SE', + 0.1, 35.0, 6.0, 10.0, 300.0, 65.0, + 25, 20, 5, + NULL, NULL, + 'RESOLVED', 'Y' +), +( + 5, 'RSC-2025-040', 'turning', 'M/V GOLDEN WAVE', NULL, + 127.68, 34.73, + ST_SetSRID(ST_MakePoint(127.68::float, 34.73::float), 4326), + '34°44''N, 127°41''E', 18.0, '1.5kn W', + 1.2, 5.0, 0.8, 65.0, 20.0, 95.0, + 12, 12, 0, + NULL, NULL, + 'RESOLVED', 'Y' +); + +-- ============================================================ +-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준) +-- ============================================================ +INSERT INTO RESCUE_SCENARIO ( + RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD, + GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT, + DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD +) VALUES +( + 1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL', + 0.8, 15.0, 2.5, 30.0, 100.0, 92.0, + '좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]', + '[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]', + 1 +), +( + 1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH', + 0.6, 18.0, 3.2, 25.0, 150.0, 88.0, + '침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]', + '[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]', + 2 +), +( + 1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH', + 0.4, 12.0, 2.8, 35.0, 80.0, 90.0, + '평형수 이동으로 경사 일부 복원. 유출률 감소 추세.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]', + '[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]', + '[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]', + 3 +), +( + 1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM', + 0.6, 8.0, 1.5, 50.0, 30.0, 94.0, + '예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.', + '[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]', + '[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]', + 4 +), +( + 1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED', + 1.2, 3.0, 0.5, 75.0, 5.0, 98.0, + '목포항 도착, 선체 안정. 잔류유 이적 완료.', + '[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]', + '[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]', + '[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]', + 5 +); diff --git a/frontend/src/common/mock/backtrackMockData.ts b/frontend/src/common/mock/backtrackMockData.ts deleted file mode 100755 index 668cd3f..0000000 --- a/frontend/src/common/mock/backtrackMockData.ts +++ /dev/null @@ -1,126 +0,0 @@ -import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack' - -export const MOCK_CONDITIONS: BacktrackConditions = { - estimatedSpillTime: '02-10 06:30', - analysisRange: '±12시간', - searchRadius: '10 NM', - spillLocation: { lat: 34.7312, lon: 127.6845 }, - totalVessels: 47, -} - -export const MOCK_VESSELS: BacktrackVessel[] = [ - { - rank: 1, - name: 'ORIENTAL GLORY', - imo: '9412856', - type: '유조선', - flag: '🇰🇷', - flagCountry: '대한민국', - probability: 96.7, - closestTime: '06:28', - closestDistance: 0.02, - speedChange: '급감속', - aisStatus: '충돌신호', - description: '06:28 HAI FENG 168과 충돌 → 06:30 No.1P 탱크 파공 → 벙커C유 유출 개시. AIS 충돌경보 발신, 속력 8.3kts→0.2kts 급감속.', - color: '#ef4444', - }, - { - rank: 2, - name: 'HAI FENG 168', - imo: '9538721', - type: '벌크선', - flag: '🇨🇳', - flagCountry: '중국', - probability: 23.4, - closestTime: '06:28', - closestDistance: 0.02, - speedChange: '급감속', - aisStatus: '미확인', - description: '충돌 당사선. 구상선수 손상으로 연료유탱크 미세 누유 가능성. 자체 연료(벙커C) 1,200톤 적재.', - color: '#f97316', - }, - { - rank: 3, - name: 'DONG JIN STAR', - imo: '9287403', - type: '케미컬탱커', - flag: '🇰🇷', - flagCountry: '대한민국', - probability: 4.1, - closestTime: '05:45', - closestDistance: 1.8, - speedChange: '정상', - aisStatus: '정상', - description: '', - color: '#64788c', - }, -] - -export const MOCK_REPLAY_SHIPS: ReplayShip[] = [ - { - vesselName: 'ORIENTAL GLORY', - color: '#ef4444', - path: [ - { lat: 34.82, lon: 127.58 }, - { lat: 34.80, lon: 127.60 }, - { lat: 34.78, lon: 127.62 }, - { lat: 34.76, lon: 127.64 }, - { lat: 34.75, lon: 127.66 }, - { lat: 34.74, lon: 127.67 }, - { lat: 34.73, lon: 127.68 }, - { lat: 34.7312, lon: 127.6845 }, - { lat: 34.7312, lon: 127.6845 }, - ], - speedLabels: [ - '8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°', - '8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°', - '2.1 kts · 215°', '0.2 kts · 정지', '0.2 kts · 정지', - ], - }, - { - vesselName: 'HAI FENG 168', - color: '#f97316', - path: [ - { lat: 34.64, lon: 127.78 }, - { lat: 34.66, lon: 127.76 }, - { lat: 34.68, lon: 127.74 }, - { lat: 34.70, lon: 127.72 }, - { lat: 34.71, lon: 127.71 }, - { lat: 34.72, lon: 127.70 }, - { lat: 34.73, lon: 127.69 }, - { lat: 34.7312, lon: 127.6845 }, - { lat: 34.7315, lon: 127.6840 }, - ], - speedLabels: [ - '11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°', - '11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°', - '3.4 kts · 038°', '0.5 kts · 정지', '0.5 kts · 정지', - ], - }, - { - vesselName: 'DONG JIN STAR', - color: '#64788c', - path: [ - { lat: 34.82, lon: 127.52 }, - { lat: 34.80, lon: 127.53 }, - { lat: 34.78, lon: 127.54 }, - { lat: 34.76, lon: 127.55 }, - { lat: 34.74, lon: 127.56 }, - { lat: 34.72, lon: 127.57 }, - { lat: 34.70, lon: 127.58 }, - { lat: 34.68, lon: 127.59 }, - { lat: 34.66, lon: 127.60 }, - ], - speedLabels: [ - '10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°', - '10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°', - '10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°', - ], - }, -] - -export const MOCK_COLLISION: CollisionEvent = { - position: { lat: 34.7312, lon: 127.6845 }, - timeLabel: '06:28 충돌', - progressPercent: 75, -} diff --git a/frontend/src/tabs/aerial/components/CctvView.tsx b/frontend/src/tabs/aerial/components/CctvView.tsx index bb6802b..3061509 100644 --- a/frontend/src/tabs/aerial/components/CctvView.tsx +++ b/frontend/src/tabs/aerial/components/CctvView.tsx @@ -1,30 +1,6 @@ -import { useState } from 'react' - -interface CctvCamera { - id: number - name: string - region: '제주' | '남해' | '서해' | '동해' - location: string - coord: string - status: 'live' | 'offline' - ptz: boolean - source: string -} - -const cctvCameras: CctvCamera[] = [ - { id: 1, name: '서귀포항 동측', region: '제주', location: '제주 서귀포시 서귀동', coord: '33.24°N 126.57°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 2, name: '제주항 입구', region: '제주', location: '제주 제주시 건입동', coord: '33.52°N 126.53°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 3, name: '성산포항', region: '제주', location: '제주 서귀포시 성산읍', coord: '33.46°N 126.93°E', status: 'live', ptz: false, source: 'TAGO' }, - { id: 4, name: '모슬포항', region: '제주', location: '제주 서귀포시 대정읍', coord: '33.21°N 126.25°E', status: 'live', ptz: false, source: 'KBS' }, - { id: 5, name: '여수 신항', region: '남해', location: '전남 여수시 웅천동', coord: '34.73°N 127.68°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 6, name: '통영항', region: '남해', location: '경남 통영시 항남동', coord: '34.84°N 128.43°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 7, name: '부산 감천항', region: '남해', location: '부산 서구 암남동', coord: '35.08°N 129.01°E', status: 'live', ptz: false, source: 'KBS' }, - { id: 8, name: '목포 내항', region: '서해', location: '전남 목포시 항동', coord: '34.79°N 126.38°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 9, name: '군산 외항', region: '서해', location: '전북 군산시 소룡동', coord: '35.97°N 126.72°E', status: 'live', ptz: false, source: 'TAGO' }, - { id: 10, name: '인천항 연안', region: '서해', location: '인천 중구 항동', coord: '37.45°N 126.60°E', status: 'offline', ptz: false, source: 'KBS' }, - { id: 11, name: '동해항', region: '동해', location: '강원 동해시 송정동', coord: '37.52°N 129.12°E', status: 'live', ptz: true, source: 'TAGO' }, - { id: 12, name: '포항 영일만', region: '동해', location: '경북 포항시 남구', coord: '36.02°N 129.38°E', status: 'live', ptz: false, source: 'TAGO' }, -] +import { useState, useCallback, useEffect } from 'react' +import { fetchCctvCameras } from '../services/aerialApi' +import type { CctvCameraItem } from '../services/aerialApi' const cctvFavorites = [ { name: '서귀포항 동측', reason: '유출 사고 인접' }, @@ -33,28 +9,46 @@ const cctvFavorites = [ ] export function CctvView() { + const [cameras, setCameras] = useState([]) + const [loading, setLoading] = useState(true) const [searchTerm, setSearchTerm] = useState('') const [regionFilter, setRegionFilter] = useState('전체') - const [selectedCamera, setSelectedCamera] = useState(null) + const [selectedCamera, setSelectedCamera] = useState(null) const [gridMode, setGridMode] = useState(1) - const [activeCells, setActiveCells] = useState([]) + const [activeCells, setActiveCells] = useState([]) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const items = await fetchCctvCameras() + setCameras(items) + } catch (err) { + console.error('[aerial] CCTV 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadData() + }, [loadData]) const regions = ['전체', '제주', '남해', '서해', '동해'] const regionIcons: Record = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' } - const filtered = cctvCameras.filter(c => { - if (regionFilter !== '전체' && c.region !== regionFilter) return false - if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false + const filtered = cameras.filter(c => { + if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false + if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) return false return true }) - const handleSelectCamera = (cam: CctvCamera) => { + const handleSelectCamera = (cam: CctvCameraItem) => { setSelectedCamera(cam) if (gridMode === 1) { setActiveCells([cam]) } else { setActiveCells(prev => { - if (prev.length < gridMode && !prev.find(c => c.id === cam.id)) return [...prev, cam] + if (prev.length < gridMode && !prev.find(c => c.cctvSn === cam.cctvSn)) return [...prev, cam] return prev }) } @@ -114,31 +108,33 @@ export function CctvView() { {/* 카메라 목록 */}
- {filtered.map(cam => ( + {loading ? ( +
불러오는 중...
+ ) : filtered.map(cam => (
handleSelectCamera(cam)} className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors" style={{ borderColor: 'rgba(255,255,255,.04)', - background: selectedCamera?.id === cam.id ? 'rgba(6,182,212,.08)' : 'transparent', + background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent', }} >
📹
-
+
-
{cam.name}
-
{cam.location}
+
{cam.cameraNm}
+
{cam.locDc ?? ''}
- {cam.status === 'live' ? ( + {cam.sttsCd === 'LIVE' ? ( LIVE ) : ( OFF )} - {cam.ptz && PTZ} + {cam.ptzYn === 'Y' && PTZ}
))} @@ -151,9 +147,9 @@ export function CctvView() {
- {selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'} + {selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
- {selectedCamera?.status === 'live' && ( + {selectedCamera?.sttsCd === 'LIVE' && (
LIVE
@@ -161,7 +157,7 @@ export function CctvView() {
{/* PTZ 컨트롤 */} - {selectedCamera?.ptz && ( + {selectedCamera?.ptzYn === 'Y' && (
PTZ {['◀', '▲', '▼', '▶'].map((d, i) => ( @@ -213,11 +209,11 @@ export function CctvView() {
📹
- {cam.name} + {cam.cameraNm} ● REC
- {cam.coord} · {cam.source} + {cam.coordDc ?? ''} · {cam.sourceNm ?? ''}
CCTV 스트리밍 영역 @@ -233,9 +229,9 @@ export function CctvView() { {/* 하단 정보 바 */}
-
선택: {selectedCamera?.name ?? '–'}
-
위치: {selectedCamera?.location ?? '–'}
-
좌표: {selectedCamera?.coord ?? '–'}
+
선택: {selectedCamera?.cameraNm ?? '–'}
+
위치: {selectedCamera?.locDc ?? '–'}
+
좌표: {selectedCamera?.coordDc ?? '–'}
API: 국립해양조사원 TAGO 해양 CCTV
@@ -275,13 +271,13 @@ export function CctvView() { {selectedCamera ? (
{[ - ['카메라명', selectedCamera.name], - ['지역', selectedCamera.region], - ['위치', selectedCamera.location], - ['좌표', selectedCamera.coord], - ['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'], - ['PTZ', selectedCamera.ptz ? '지원' : '미지원'], - ['출처', selectedCamera.source], + ['카메라명', selectedCamera.cameraNm], + ['지역', selectedCamera.regionNm], + ['위치', selectedCamera.locDc ?? '—'], + ['좌표', selectedCamera.coordDc ?? '—'], + ['상태', selectedCamera.sttsCd === 'LIVE' ? '● 송출중' : '● 오프라인'], + ['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'], + ['출처', selectedCamera.sourceNm ?? '—'], ].map(([k, v], i) => (
{k} diff --git a/frontend/src/tabs/aerial/components/MediaManagement.tsx b/frontend/src/tabs/aerial/components/MediaManagement.tsx index 9f0b9b8..e5d9940 100644 --- a/frontend/src/tabs/aerial/components/MediaManagement.tsx +++ b/frontend/src/tabs/aerial/components/MediaManagement.tsx @@ -1,38 +1,15 @@ -import { useState, useRef, useEffect } from 'react' +import { useState, useCallback, useRef, useEffect } from 'react' +import { fetchAerialMedia } from '../services/aerialApi' +import type { AerialMediaItem } from '../services/aerialApi' -// ── Types & Mock Data ── +// ── Helpers ── -interface MediaFile { - id: number - incident: string - location: string - filename: string - equipment: string - equipType: 'drone' | 'plane' | 'satellite' - mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학' - datetime: string - size: string - resolution: string +function formatDtm(dtm: string | null): string { + if (!dtm) return '—' + const d = new Date(dtm) + return d.toISOString().slice(0, 16).replace('T', ' ') } -const mediaFiles: MediaFile[] = [ - { id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' }, - { id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' }, - { id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' }, - { id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' }, - { id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' }, - { id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' }, - { id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' }, - { id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' }, - { id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' }, - { id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' }, - { id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' }, - { id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' }, - { id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' }, - { id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' }, - { id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' }, -] - const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰' const equipTagCls = (t: string) => @@ -63,6 +40,8 @@ const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean; // ── Component ── export function MediaManagement() { + const [mediaItems, setMediaItems] = useState([]) + const [loading, setLoading] = useState(true) const [selectedIds, setSelectedIds] = useState>(new Set()) const [equipFilter, setEquipFilter] = useState('all') const [typeFilter, setTypeFilter] = useState>(new Set()) @@ -71,6 +50,22 @@ export function MediaManagement() { const [showUpload, setShowUpload] = useState(false) const modalRef = useRef(null) + const loadData = useCallback(async () => { + setLoading(true) + try { + const items = await fetchAerialMedia() + setMediaItems(items) + } catch (err) { + console.error('[aerial] 미디어 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + loadData() + }, [loadData]) + useEffect(() => { const handler = (e: MouseEvent) => { if (modalRef.current && !modalRef.current.contains(e.target as Node)) { @@ -81,22 +76,22 @@ export function MediaManagement() { return () => document.removeEventListener('mousedown', handler) }, [showUpload]) - const filtered = mediaFiles.filter(f => { - if (equipFilter !== 'all' && f.equipType !== equipFilter) return false + const filtered = mediaItems.filter(f => { + if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false if (typeFilter.size > 0) { - const isPhoto = !['영상'].includes(f.mediaType) - const isVideo = f.mediaType === '영상' + const isPhoto = f.mediaTpCd !== '영상' + const isVideo = f.mediaTpCd === '영상' if (typeFilter.has('photo') && !isPhoto) return false if (typeFilter.has('video') && !isVideo) return false } - if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false + if (searchTerm && !f.fileNm.toLowerCase().includes(searchTerm.toLowerCase())) return false return true }) const sorted = [...filtered].sort((a, b) => { - if (sortBy === 'name') return a.filename.localeCompare(b.filename) - if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size) - return b.datetime.localeCompare(a.datetime) + if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm) + if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0') + return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '') }) const toggleId = (id: number) => { @@ -111,7 +106,7 @@ export function MediaManagement() { if (selectedIds.size === sorted.length) { setSelectedIds(new Set()) } else { - setSelectedIds(new Set(sorted.map(f => f.id))) + setSelectedIds(new Set(sorted.map(f => f.aerialMediaSn))) } } @@ -123,9 +118,9 @@ export function MediaManagement() { }) } - const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length - const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length - const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length + const droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length + const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length + const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length return (
@@ -165,11 +160,11 @@ export function MediaManagement() { {/* Summary Stats */}
{[ - { icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' }, - { icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' }, - { icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' }, - { icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' }, - { icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' }, + { icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-primary-cyan' }, + { icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-text-1' }, + { icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-text-1' }, + { icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-text-1' }, + { icon: '💾', value: '—', label: '총 용량', color: 'text-text-1' }, ].map((s, i) => (
{s.icon} @@ -221,39 +216,43 @@ export function MediaManagement() { - {sorted.map(f => ( + {loading ? ( + + 불러오는 중... + + ) : sorted.map(f => ( toggleId(f.id)} + key={f.aerialMediaSn} + onClick={() => toggleId(f.aerialMediaSn)} className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ - selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : '' + selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : '' }`} > e.stopPropagation()}> toggleId(f.id)} + checked={selectedIds.has(f.aerialMediaSn)} + onChange={() => toggleId(f.aerialMediaSn)} className="accent-primary-blue" /> - {equipIcon(f.equipType)} - {f.incident} - {f.location} - {f.filename} + {equipIcon(f.equipTpCd)} + {f.acdntSn != null ? String(f.acdntSn) : '—'} + {f.locDc ?? '—'} + {f.fileNm} - - {f.equipment} + + {f.equipNm} - - {f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} + + {f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd} - {f.datetime} - {f.size} - {f.resolution} + {formatDtm(f.takngDtm)} + {f.fileSz ?? '—'} + {f.resolution ?? '—'} e.stopPropagation()}> -
- {file.author} - {file.uploadDate} + {file.authorNm} + {new Date(file.regDtm).toLocaleDateString('ko-KR')}
- ⬇ {file.downloads} + ⬇ {file.dwnldCnt} -
+ )} - {filteredManuals.length === 0 && ( + {!manualLoading && filteredManuals.length === 0 && (
📘

검색 결과가 없습니다.

@@ -422,10 +433,10 @@ export function BoardView() { input.type = 'file' input.accept = '.pdf,.doc,.docx,.hwp,.xlsx' input.onchange = (ev) => { - const file = (ev.target as HTMLInputElement).files?.[0] - if (file) { - const sizeMB = (file.size / (1024 * 1024)).toFixed(1) - setUploadForm(prev => ({ ...prev, fileName: file.name, fileSize: `${sizeMB} MB` })) + const f = (ev.target as HTMLInputElement).files?.[0] + if (f) { + const sizeMB = (f.size / (1024 * 1024)).toFixed(1) + setUploadForm(prev => ({ ...prev, fileName: f.name, fileSize: `${sizeMB} MB` })) } } input.click() @@ -455,37 +466,35 @@ export function BoardView() { style={{ padding: '8px 20px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'var(--bg3)', border: '1px solid var(--bd)', color: 'var(--t3)', fontFamily: 'var(--fK)', cursor: 'pointer' }}> 취소 -
{/* Right Panel */} - {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} />} + {activeSubTab === 'analysis' && setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />} {/* 재계산 모달 */} setBacktrackModalOpen(false)} phase={backtrackPhase} - conditions={MOCK_CONDITIONS} + conditions={backtrackConditions} vessels={backtrackVessels} onRunAnalysis={handleRunBacktrackAnalysis} onStartReplay={handleStartReplay} diff --git a/frontend/src/tabs/prediction/components/RightPanel.tsx b/frontend/src/tabs/prediction/components/RightPanel.tsx index d21d661..853bd2f 100755 --- a/frontend/src/tabs/prediction/components/RightPanel.tsx +++ b/frontend/src/tabs/prediction/components/RightPanel.tsx @@ -1,6 +1,11 @@ import { useState } from 'react' +import type { PredictionDetail } from '../services/predictionApi' -export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void }) { +export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null }) { + const vessel = detail?.vessels?.[0] + const vessel2 = detail?.vessels?.[1] + const spill = detail?.spill + const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null const [shipExpanded, setShipExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false) @@ -38,7 +43,7 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on {/* 오염 종합 상황 */}
- + @@ -98,35 +103,37 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on fontSize: '15px' }}>🚢
-
ORIENTAL GLORY
-
IMO 9412856 · MMSI 440123456 · 유조선
+
{vessel?.vesselNm || '—'}
+
IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}
사고
{/* 제원 */}
- - - + + +
- - - - - - + + + + + +
{/* 충돌 상대 */} + {vessel2 && (
-
⚠ 충돌 상대: HAI FENG 168
+
⚠ 충돌 상대: {vessel2.vesselNm}
- 🇨🇳 중국 벌크선 52,340GT · 좌현 35° 충돌 · No.1P 파공 1.2m×0.8m + {vessel2.flagCd} {vessel2.vesselTp} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
+ )}
@@ -137,48 +144,30 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on onToggle={() => setInsuranceExpanded(!insuranceExpanded)} >
-
- - - -
- - {/* 선체보험 */} - - - {/* 화물보험 */} - - - {/* 유류오염배상 */} - + {insurance && insurance.length > 0 ? ( + <> + {insurance.filter(ins => ins.type === 'P&I').map((ins, i) => ( + + ))} + {insurance.filter(ins => ins.type === 'H&M').map((ins, i) => ( + + ))} + {insurance.filter(ins => ins.type === 'CLC').map((ins, i) => ( + + ))} + + ) : ( +
보험 정보가 없습니다.
+ )}
diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts new file mode 100644 index 0000000..53b4651 --- /dev/null +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -0,0 +1,115 @@ +import { api } from '@common/services/api'; + +export interface PredictionAnalysis { + acdntSn: number; + acdntNm: string; + occurredAt: string; + analysisDate: string; + requestor: string; + duration: string; + oilType: string; + volume: number | null; + location: string; + lat: number | null; + lon: number | null; + kospsStatus: string; + poseidonStatus: string; + opendriftStatus: string; + backtrackStatus: string; + analyst: string; + officeName: string; +} + +export interface PredictionDetail { + acdnt: { + acdntSn: number; + acdntNm: string; + occurredAt: string; + lat: number | null; + lon: number | null; + location: string; + analyst: string; + officeName: string; + }; + spill: { + oilType: string; + volume: number | null; + unit: string; + fcstHr: number | null; + } | null; + vessels: Array<{ + vesselInfoSn: number; + imoNo: string; + vesselNm: string; + vesselTp: string; + loaM: number | null; + breadthM: number | null; + draftM: number | null; + gt: number | null; + dwt: number | null; + builtYr: number | null; + flagCd: string; + callsign: string; + engineDc: string; + insuranceData: unknown; + }>; + weather: Array<{ + weatherDtm: string; + windSpd: number | null; + windDir: string | null; + waveHgt: number | null; + currentSpd: number | null; + currentDir: string | null; + temp: number | null; + }>; +} + +export interface BacktrackResult { + backtrackSn: number; + acdntSn: number; + estSpilDtm: string | null; + anlysRange: string | null; + lon: number | null; + lat: number | null; + srchRadiusNm: number | null; + totalVessels: number | null; + execSttsCd: string; + rsltData: Record | null; +} + +export const fetchPredictionAnalyses = async (params?: { + search?: string; +}): Promise => { + const response = await api.get('/prediction/analyses', { params }); + return response.data; +}; + +export const fetchPredictionDetail = async (acdntSn: number): Promise => { + const response = await api.get(`/prediction/analyses/${acdntSn}`); + return response.data; +}; + +export const fetchBacktrack = async (sn: number): Promise => { + const response = await api.get(`/prediction/backtrack/${sn}`); + return response.data; +}; + +export const fetchBacktrackByAcdnt = async ( + acdntSn: number, +): Promise => { + const response = await api.get('/prediction/backtrack', { + params: { acdntSn }, + }); + return response.data.length > 0 ? response.data[0] : null; +}; + +export const createBacktrack = async (input: { + acdntSn: number; + lon: number; + lat: number; + srchRadiusNm?: number; + anlysRange?: string; +}): Promise<{ backtrackSn: number }> => { + const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input); + return response.data; +}; diff --git a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx index 20d05e2..0d781e7 100755 --- a/frontend/src/tabs/rescue/components/RescueScenarioView.tsx +++ b/frontend/src/tabs/rescue/components/RescueScenarioView.tsx @@ -1,4 +1,6 @@ -import { useState, useRef } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' +import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi' +import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi' /* ─── Types ─── */ type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED' @@ -29,151 +31,6 @@ const SEV_STYLE: Record RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'RESOLVED' }, } -/* ─── 사고 목록 ─── */ -const INCIDENTS = [ - 'RSC-2024-0127 · M/V SEA GUARDIAN (충돌/좌초)', - 'RSC-2024-0125 · M/V PACIFIC STAR (기관 고장)', - 'RSC-2024-0118 · F/V DONG JIN (침수/전복위험)', -] - -/* ─── Mock 시나리오 데이터 ─── */ -const MOCK_SCENARIOS: RescueScenario[] = [ - { - id: 'S-01', name: '사고 발생 직후', severity: 'CRITICAL', - timeStep: 'T+0h', datetime: '2024.10.27 10:30 KST', - gm: '0.8', list: '15', trim: '2.5', buoyancy: 30, oilRate: '100 L/min', bmRatio: '92%', - description: '선수 #1·좌현 #3 침수. 복원력 급격 저하. 전복 위험 경고.', - compartments: [ - { name: '선수 #1 Hold', status: '완전 침수 (100%)', color: 'var(--red)' }, - { name: '좌현 #3 DB Tank', status: '완전 침수 (100%)', color: 'var(--red)' }, - { name: '기관실 하부', status: '일부 침수 (30%)', color: 'var(--orange)' }, - { name: '우현 #2 DB Tank', status: '정상', color: 'var(--green)' }, - { name: '선미 Void', status: '정상', color: 'var(--green)' }, - ], - assessment: [ - { label: '전복 위험', value: 'CRITICAL — GM 0.8m 미만', color: 'var(--red)' }, - { label: '침몰 위험', value: 'HIGH — 잔존부력 30%', color: 'var(--orange)' }, - { label: '구조적 파손', value: 'MEDIUM — BM 92% 한계 근접', color: 'var(--yellow)' }, - { label: '유류오염', value: 'HIGH — 100 L/min 유출 중', color: 'var(--orange)' }, - ], - actions: [ - { time: '10:30', text: 'SOS 발신, 해경 통보', color: 'var(--red)' }, - { time: '10:35', text: '구조헬기(B-703) 출동 명령', color: 'var(--orange)' }, - { time: '10:40', text: '전 승조원 구명조끼 착용 지시', color: 'var(--yellow)' }, - { time: '10:45', text: '비상배수펌프 가동', color: 'var(--cyan)' }, - ], - }, - { - id: 'S-02', name: '침수 확대 단계', severity: 'CRITICAL', - timeStep: 'T+2h', datetime: '2024.10.27 12:30 KST', - gm: '0.4', list: '22', trim: '3.8', buoyancy: 18, oilRate: '180 L/min', bmRatio: '105%', - description: 'DB탱크 추가 침수. GM 0.4m 하락. 전복 임박 경고.', - compartments: [ - { name: '선수 #1 Hold', status: '완전 침수 (100%)', color: 'var(--red)' }, - { name: '좌현 #3 DB Tank', status: '완전 침수 (100%)', color: 'var(--red)' }, - { name: '기관실 하부', status: '대부분 침수 (70%)', color: 'var(--red)' }, - { name: '우현 #2 DB Tank', status: '일부 침수 (40%)', color: 'var(--orange)' }, - { name: '선미 Void', status: '정상', color: 'var(--green)' }, - ], - assessment: [ - { label: '전복 위험', value: 'CRITICAL — GM 0.4m, 전복 임박', color: 'var(--red)' }, - { label: '침몰 위험', value: 'CRITICAL — 잔존부력 18%', color: 'var(--red)' }, - { label: '구조적 파손', value: 'HIGH — BM 105% 초과', color: 'var(--red)' }, - { label: '유류오염', value: 'CRITICAL — 180 L/min 유출', color: 'var(--red)' }, - ], - actions: [ - { time: '11:00', text: '밸러스트 이동 시도 (우현→좌현)', color: 'var(--cyan)' }, - { time: '11:30', text: '예인선 2척 출동 요청', color: 'var(--orange)' }, - { time: '12:00', text: '승조원 부분 퇴선 실시', color: 'var(--red)' }, - { time: '12:20', text: '비상배수 추가 투입', color: 'var(--cyan)' }, - ], - }, - { - id: 'S-03', name: '응급조치 적용', severity: 'HIGH', - timeStep: 'T+6h', datetime: '2024.10.27 16:30 KST', - gm: '1.1', list: '12', trim: '2.0', buoyancy: 35, oilRate: '60 L/min', bmRatio: '78%', - description: '밸러스트 이동+배출 완료. 임시 패치 적용. GM 부분 회복.', - compartments: [ - { name: '선수 #1 Hold', status: '침수 유지 (90%)', color: 'var(--red)' }, - { name: '좌현 #3 DB Tank', status: '배수 진행 (60%)', color: 'var(--orange)' }, - { name: '기관실 하부', status: '배수 진행 (40%)', color: 'var(--orange)' }, - { name: '우현 #2 DB Tank', status: '밸러스트 주입 (80%)', color: 'var(--cyan)' }, - { name: '선미 Void', status: '정상', color: 'var(--green)' }, - ], - assessment: [ - { label: '전복 위험', value: 'MEDIUM — GM 1.1m 부분 회복', color: 'var(--yellow)' }, - { label: '침몰 위험', value: 'HIGH — 잔존부력 35%', color: 'var(--orange)' }, - { label: '구조적 파손', value: 'LOW — BM 78% 안정', color: 'var(--green)' }, - { label: '유류오염', value: 'MEDIUM — 60 L/min', color: 'var(--yellow)' }, - ], - actions: [ - { time: '13:00', text: '밸러스트 이동 완료 (좌현 경사 보정)', color: 'var(--green)' }, - { time: '14:00', text: '임시 패치(수중 용접) 적용', color: 'var(--cyan)' }, - { time: '15:00', text: '오일펜스 전개 완료', color: 'var(--orange)' }, - { time: '16:00', text: '배수 펌프 풀가동 → GM 회복', color: 'var(--green)' }, - ], - }, - { - id: 'S-04', name: '예인 개시', severity: 'MEDIUM', - timeStep: 'T+12h', datetime: '2024.10.27 22:30 KST', - gm: '1.2', list: '8', trim: '1.5', buoyancy: 40, oilRate: '25 L/min', bmRatio: '68%', - description: '예인선 2척 도착. 예인 줄 연결 완료. 3kn 속도로 예인 개시.', - compartments: [ - { name: '선수 #1 Hold', status: '침수 유지 (85%)', color: 'var(--red)' }, - { name: '좌현 #3 DB Tank', status: '배수 완료 (20%)', color: 'var(--yellow)' }, - { name: '기관실 하부', status: '배수 완료 (15%)', color: 'var(--green)' }, - { name: '우현 #2 DB Tank', status: '밸러스트 (80%)', color: 'var(--cyan)' }, - { name: '선미 Void', status: '정상', color: 'var(--green)' }, - ], - assessment: [ - { label: '전복 위험', value: 'LOW — GM 1.2m 안정', color: 'var(--green)' }, - { label: '침몰 위험', value: 'MEDIUM — 잔존부력 40%', color: 'var(--yellow)' }, - { label: '구조적 파손', value: 'LOW — BM 68% 안정', color: 'var(--green)' }, - { label: '유류오염', value: 'LOW — 25 L/min (감소 추세)', color: 'var(--green)' }, - ], - actions: [ - { time: '20:00', text: '예인선 2척 현장 도착', color: 'var(--cyan)' }, - { time: '21:00', text: '예인 줄 연결 완료', color: 'var(--green)' }, - { time: '22:00', text: '3kn 속도 예인 개시', color: 'var(--green)' }, - { time: '22:30', text: '야간 항해등 점등, 경계 유지', color: 'var(--yellow)' }, - ], - }, - { - id: 'S-05', name: '항만 입항·구난 완료', severity: 'RESOLVED', - timeStep: 'T+24h', datetime: '2024.10.28 10:30 KST', - gm: '1.5', list: '3', trim: '0.8', buoyancy: 55, oilRate: '0 L/min', bmRatio: '52%', - description: '인천항 안벽 접안 완료. 실종자 전원 구조. 구난 작전 종료.', - compartments: [ - { name: '선수 #1 Hold', status: '배수 진행 (50%)', color: 'var(--orange)' }, - { name: '좌현 #3 DB Tank', status: '배수 완료 (5%)', color: 'var(--green)' }, - { name: '기관실 하부', status: '배수 완료 (0%)', color: 'var(--green)' }, - { name: '우현 #2 DB Tank', status: '정상', color: 'var(--green)' }, - { name: '선미 Void', status: '정상', color: 'var(--green)' }, - ], - assessment: [ - { label: '전복 위험', value: 'RESOLVED — GM 1.5m 안정', color: 'var(--green)' }, - { label: '침몰 위험', value: 'RESOLVED — 잔존부력 55%', color: 'var(--green)' }, - { label: '구조적 파손', value: 'RESOLVED — 접안 완료', color: 'var(--green)' }, - { label: '유류오염', value: 'RESOLVED — 유출 차단', color: 'var(--green)' }, - ], - actions: [ - { time: '06:00', text: '인천항 진입 허가', color: 'var(--green)' }, - { time: '08:00', text: '도선사 승선', color: 'var(--cyan)' }, - { time: '09:30', text: '안벽 접안 완료', color: 'var(--green)' }, - { time: '10:30', text: '실종자 전원 구조 확인 — 작전 종료', color: 'var(--green)' }, - ], - }, -] - -/* ─── Chart Data ─── */ -const CHART_DATA = [ - { id: 'S-01', label: 'T+0h', gm: 0.8, list: 15, buoy: 30, oil: 100, bm: 92, severity: 'CRITICAL' as Severity }, - { id: 'S-02', label: 'T+2h', gm: 0.4, list: 22, buoy: 18, oil: 180, bm: 105, severity: 'CRITICAL' as Severity }, - { id: 'S-03', label: 'T+6h', gm: 1.1, list: 12, buoy: 35, oil: 60, bm: 78, severity: 'HIGH' as Severity }, - { id: 'S-04', label: 'T+12h', gm: 1.2, list: 8, buoy: 40, oil: 25, bm: 68, severity: 'MEDIUM' as Severity }, - { id: 'S-05', label: 'T+24h', gm: 1.5, list: 3, buoy: 55, oil: 0, bm: 52, severity: 'RESOLVED' as Severity }, -] - const SEV_COLOR: Record = { CRITICAL: '#f87171', HIGH: '#fb923c', MEDIUM: '#fbbf24', RESOLVED: '#22c55e' } /* ─── Color helpers ─── */ @@ -182,19 +39,118 @@ function listColor(v: number) { return v > 20 ? 'var(--red)' : v > 10 ? 'var(--y function buoyColor(v: number) { return v < 30 ? 'var(--red)' : v < 50 ? 'var(--yellow)' : 'var(--green)' } function oilColor(v: number) { return v > 100 ? 'var(--red)' : v > 30 ? 'var(--orange)' : v > 0 ? 'var(--yellow)' : 'var(--green)' } +/* ─── API 시나리오 → 로컬 타입 변환 ─── */ +function toRescueScenario(s: RescueScenarioItem, i: number): RescueScenario { + return { + id: `S-${String(i + 1).padStart(2, '0')}`, + name: s.description?.split('.')[0] ?? s.timeStep, + severity: s.svrtCd as Severity, + timeStep: s.timeStep, + datetime: s.scenarioDtm + ? new Date(s.scenarioDtm).toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + ' KST' + : '—', + gm: String(s.gmM ?? 0), + list: String(s.listDeg ?? 0), + trim: String(s.trimM ?? 0), + buoyancy: s.buoyancyPct ?? 0, + oilRate: s.oilRateLpm != null ? `${s.oilRateLpm} L/min` : '— L/min', + bmRatio: s.bmRatioPct != null ? `${s.bmRatioPct}%` : '—%', + description: s.description ?? '', + compartments: s.compartments ?? [], + assessment: s.assessment ?? [], + actions: s.actions ?? [], + } +} + +/* ─── ChartData 타입 ─── */ +interface ChartDataItem { + id: string + label: string + gm: number + list: number + buoy: number + oil: number + bm: number + severity: Severity +} + /* ═══════════════════════════════════════════════════════════════════ RescueScenarioView ═══════════════════════════════════════════════════════════════════ */ export function RescueScenarioView() { + const [ops, setOps] = useState([]) + const [apiScenarios, setApiScenarios] = useState([]) + const [loading, setLoading] = useState(true) const [selectedIncident, setSelectedIncident] = useState(0) - const [scenarios] = useState(MOCK_SCENARIOS) - const [checked, setChecked] = useState>(new Set(['S-01', 'S-02', 'S-03', 'S-04', 'S-05'])) - const [selectedId, setSelectedId] = useState('S-01') + const [checked, setChecked] = useState>(new Set()) + const [selectedId, setSelectedId] = useState('') const [sortBy, setSortBy] = useState<'time' | 'risk'>('time') const [detailView, setDetailView] = useState(0) const [newScnModalOpen, setNewScnModalOpen] = useState(false) - const selected = scenarios.find(s => s.id === selectedId)! + const loadScenarios = useCallback(async (opsSn: number) => { + setLoading(true) + try { + const items = await fetchRescueScenarios(opsSn) + setApiScenarios(items) + } catch (err) { + console.error('[rescue] 시나리오 조회 실패:', err) + } finally { + setLoading(false) + } + }, []) + + const loadOps = useCallback(async () => { + try { + const items = await fetchRescueOps() + setOps(items) + if (items.length > 0) { + loadScenarios(items[0].rescueOpsSn) + } else { + setLoading(false) + } + } catch (err) { + console.error('[rescue] 구난 작전 목록 조회 실패:', err) + setLoading(false) + } + }, [loadScenarios]) + + useEffect(() => { loadOps() }, [loadOps]) + + useEffect(() => { + if (ops.length > 0 && ops[selectedIncident]) { + loadScenarios(ops[selectedIncident].rescueOpsSn) + } + }, [selectedIncident, ops, loadScenarios]) + + /* API 시나리오 → 로컬 타입 변환 */ + const scenarios: RescueScenario[] = apiScenarios.map(toRescueScenario) + + /* checked / selectedId: apiScenarios 변경 시 초기화 */ + useEffect(() => { + setChecked(new Set(scenarios.map(s => s.id))) + if (scenarios.length > 0) setSelectedId(scenarios[0].id) + }, [apiScenarios]) // eslint-disable-line react-hooks/exhaustive-deps + + /* chartData: scenarios에서 파생 */ + const chartData: ChartDataItem[] = scenarios.map(s => ({ + id: s.id, + label: s.timeStep, + gm: parseFloat(s.gm), + list: parseFloat(s.list), + buoy: s.buoyancy, + oil: parseFloat(s.oilRate), + bm: parseFloat(s.bmRatio), + severity: s.severity, + })) + + const selected = scenarios.find(s => s.id === selectedId) const sorted = [...scenarios].sort((a, b) => { if (sortBy === 'risk') { @@ -225,7 +181,7 @@ export function RescueScenarioView() {
@@ -248,6 +204,9 @@ export function RescueScenarioView() { {/* Card list */}
+ {loading && scenarios.length === 0 && ( +
시나리오 로딩 중...
+ )} {sorted.map(sc => { const isSel = selectedId === sc.id const sev = SEV_STYLE[sc.severity] @@ -383,7 +342,7 @@ export function RescueScenarioView() { )} {/* ─── VIEW 1: 비교 차트 ─── */} - {detailView === 1 && } + {detailView === 1 && } {/* ─── VIEW 2: 지도 오버레이 ─── */} {detailView === 2 && ( @@ -393,7 +352,7 @@ export function RescueScenarioView() {
GIS 기반 시나리오 비교
선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다.
- {MOCK_SCENARIOS.map(sc => ( + {scenarios.map(sc => (
{sc.id} {sc.name} @@ -411,13 +370,13 @@ export function RescueScenarioView() {
{/* ═══ 신규 시나리오 모달 ═══ */} - {newScnModalOpen && setNewScnModalOpen(false)} />} + {newScnModalOpen && setNewScnModalOpen(false)} />}
) } /* ═══ 신규 시나리오 생성 모달 ═══ */ -function NewScenarioModal({ onClose }: { onClose: () => void }) { +function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) { const overlayRef = useRef(null) const [submitting, setSubmitting] = useState(false) const [done, setDone] = useState(false) @@ -471,7 +430,7 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) {
@@ -725,10 +684,18 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) { } /* ═══ 비교 차트 컴포넌트 ═══ */ -function ScenarioComparison() { +function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) { const W = 480, H = 180, PX = 50, PY = 20 const pw = W - PX * 2, ph = H - PY * 2 - const xStep = pw / (CHART_DATA.length - 1) + const xStep = chartData.length > 1 ? pw / (chartData.length - 1) : pw + + if (chartData.length === 0) { + return ( +
+ 비교할 시나리오 데이터가 없습니다. +
+ ) + } return (
@@ -745,10 +712,10 @@ function ScenarioComparison() { GM=1.0 위험 {/* Area */} - `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} ${PX + (CHART_DATA.length - 1) * xStep},${PY + ph}`} fill="rgba(6,182,212,.08)" /> + `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} ${PX + (chartData.length - 1) * xStep},${PY + ph}`} fill="rgba(6,182,212,.08)" /> {/* Line + dots */} - `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} fill="none" stroke="var(--cyan)" strokeWidth={2} /> - {CHART_DATA.map((d, i) => ( + `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} fill="none" stroke="var(--cyan)" strokeWidth={2} /> + {chartData.map((d, i) => ( {d.label} @@ -769,8 +736,8 @@ function ScenarioComparison() { return {v} })} - `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} /> - {CHART_DATA.map((d, i) => ( + `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} /> + {chartData.map((d, i) => ( {d.label} @@ -787,7 +754,7 @@ function ScenarioComparison() { const y = PY + ph - (v / 200) * ph return {v} })} - {CHART_DATA.map((d, i) => { + {chartData.map((d, i) => { const barW = xStep * 0.5 const barH = (d.oil / 200) * ph return ( @@ -809,23 +776,23 @@ function ScenarioComparison() { 지표 - {CHART_DATA.map(d => ( + {chartData.map(d => ( {d.id}
{d.label} ))} {[ - { label: 'GM (m)', key: 'gm', fmt: (d: typeof CHART_DATA[0]) => d.gm.toFixed(1), clr: (d: typeof CHART_DATA[0]) => gmColor(d.gm) }, - { label: '횡경사 (°)', key: 'list', fmt: (d: typeof CHART_DATA[0]) => `${d.list}°`, clr: (d: typeof CHART_DATA[0]) => listColor(d.list) }, - { label: '잔존부력 (%)', key: 'buoy', fmt: (d: typeof CHART_DATA[0]) => `${d.buoy}%`, clr: (d: typeof CHART_DATA[0]) => buoyColor(d.buoy) }, - { label: '유출률 (L/min)', key: 'oil', fmt: (d: typeof CHART_DATA[0]) => `${d.oil}`, clr: (d: typeof CHART_DATA[0]) => oilColor(d.oil) }, - { label: 'BM 비율 (%)', key: 'bm', fmt: (d: typeof CHART_DATA[0]) => `${d.bm}%`, clr: (d: typeof CHART_DATA[0]) => d.bm > 100 ? 'var(--red)' : d.bm > 85 ? 'var(--orange)' : 'var(--green)' }, - { label: '위험 등급', key: 'sev', fmt: (d: typeof CHART_DATA[0]) => d.severity, clr: (d: typeof CHART_DATA[0]) => SEV_COLOR[d.severity] }, + { label: 'GM (m)', key: 'gm', fmt: (d: ChartDataItem) => d.gm.toFixed(1), clr: (d: ChartDataItem) => gmColor(d.gm) }, + { label: '횡경사 (°)', key: 'list', fmt: (d: ChartDataItem) => `${d.list}°`, clr: (d: ChartDataItem) => listColor(d.list) }, + { label: '잔존부력 (%)', key: 'buoy', fmt: (d: ChartDataItem) => `${d.buoy}%`, clr: (d: ChartDataItem) => buoyColor(d.buoy) }, + { label: '유출률 (L/min)', key: 'oil', fmt: (d: ChartDataItem) => `${d.oil}`, clr: (d: ChartDataItem) => oilColor(d.oil) }, + { label: 'BM 비율 (%)', key: 'bm', fmt: (d: ChartDataItem) => `${d.bm}%`, clr: (d: ChartDataItem) => d.bm > 100 ? 'var(--red)' : d.bm > 85 ? 'var(--orange)' : 'var(--green)' }, + { label: '위험 등급', key: 'sev', fmt: (d: ChartDataItem) => d.severity, clr: (d: ChartDataItem) => SEV_COLOR[d.severity] }, ].map(row => ( {row.label} - {CHART_DATA.map(d => ( + {chartData.map(d => ( {row.fmt(d)} ))} diff --git a/frontend/src/tabs/rescue/components/RescueView.tsx b/frontend/src/tabs/rescue/components/RescueView.tsx index 2f890f2..7cc033f 100755 --- a/frontend/src/tabs/rescue/components/RescueView.tsx +++ b/frontend/src/tabs/rescue/components/RescueView.tsx @@ -1,7 +1,9 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback } from 'react' import { useSubMenu } from '@common/hooks/useSubMenu' import { RescueTheoryView } from './RescueTheoryView' import { RescueScenarioView } from './RescueScenarioView' +import { fetchRescueOps } from '../services/rescueApi' +import type { RescueOpsItem } from '../services/rescueApi' /* ─── Types ─── */ type AccidentType = 'collision' | 'grounding' | 'turning' | 'capsizing' | 'sharpTurn' | 'flooding' | 'sinking' @@ -812,26 +814,66 @@ function MetricCard({ label, value, unit, color, sub, subColor }: { /* ─── 긴급구난 목록 탭 ─── */ function RescueListView() { - const listData = [ - { status: '대응중', statusColor: 'var(--red)', no: 'RSC-2026-001', vessel: 'M/V SEA GUARDIAN', type: '충돌/좌초', date: '2026-02-17 10:30', location: '37°28\'N 126°15\'E', crew: '15/20' }, - { status: '대응중', statusColor: 'var(--orange)', no: 'RSC-2026-002', vessel: 'M/V EASTERN GLORY', type: '침수/전복', date: '2026-02-15 14:20', location: '35°05\'N 129°02\'E', crew: '22/28' }, - { status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-048', vessel: 'M/V PACIFIC WAVE', type: '충돌', date: '2025-12-03 08:15', location: '34°45\'N 128°30\'E', crew: '18/18' }, - { status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-047', vessel: 'M/V HARMONY', type: '좌초', date: '2025-11-20 22:40', location: '36°12\'N 126°50\'E', crew: '25/25' }, - { status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-046', vessel: 'M/V GRAND FORTUNE', type: '침몰', date: '2025-10-08 05:30', location: '33°30\'N 127°15\'E', crew: '10/22' }, - ] + const [opsList, setOpsList] = useState([]) + const [loading, setLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState('') + + const loadOps = useCallback(async () => { + setLoading(true) + try { + const items = await fetchRescueOps({ search: searchTerm || undefined }) + setOpsList(items) + } catch (err) { + console.error('[rescue] 구난 작전 목록 조회 실패:', err) + } finally { + setLoading(false) + } + }, [searchTerm]) + + useEffect(() => { + loadOps() + }, [loadOps]) + + const getStatusLabel = (sttsCd: string) => { + switch (sttsCd) { + case 'ACTIVE': return { label: '대응중', color: 'var(--red)' } + case 'STANDBY': return { label: '대기', color: 'var(--orange)' } + case 'COMPLETED': return { label: '종료', color: 'var(--green)' } + default: return { label: sttsCd, color: 'var(--t3)' } + } + } + + const getTypeLabel = (tpCd: string) => { + const map: Record = { + collision: '충돌', grounding: '좌초', turning: '선회', + capsizing: '전복', sharpTurn: '급선회', flooding: '침수', sinking: '침몰', + } + return map[tpCd] ?? tpCd + } return (
긴급구난 사고 목록
- + setSearchTerm(e.target.value)} + className="px-3 py-1.5 bg-bg-0 border border-border rounded-md text-text-2 font-korean text-[11px] w-[200px] outline-none focus:border-[var(--cyan)]" + />
+ {loading ? ( +
로딩 중...
+ ) : opsList.length === 0 ? ( +
구난 작전 데이터가 없습니다.
+ ) : ( @@ -841,23 +883,27 @@ function RescueListView() { - {listData.map((r, i) => ( - - - - - - - - - - ))} + {opsList.map((r) => { + const status = getStatusLabel(r.sttsCd) + return ( + + + + + + + + + + ) + })}
- {r.status} - {r.no}{r.vessel}{r.type}{r.date}{r.location}{r.crew}
+ {status.label} + {r.opsCd}{r.vesselNm}{getTypeLabel(r.acdntTpCd)}{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}{r.locDc ?? '—'}{r.survivors ?? 0}/{r.totalCrew ?? 0}
+ )}
) diff --git a/frontend/src/tabs/rescue/services/rescueApi.ts b/frontend/src/tabs/rescue/services/rescueApi.ts new file mode 100644 index 0000000..b48bf5e --- /dev/null +++ b/frontend/src/tabs/rescue/services/rescueApi.ts @@ -0,0 +1,72 @@ +import { api } from '@common/services/api'; + +// ============================================================ +// 구조 시나리오 API +// ============================================================ + +// === RESCUE_OPS === +export interface RescueOpsItem { + rescueOpsSn: number; + acdntSn: number | null; + opsCd: string; + acdntTpCd: string; + vesselNm: string; + commanderNm: string | null; + lon: number | null; + lat: number | null; + locDc: string | null; + depthM: number | null; + currentDc: string | null; + gmM: number | null; + listDeg: number | null; + trimM: number | null; + buoyancyPct: number | null; + oilRateLpm: number | null; + bmRatioPct: number | null; + totalCrew: number | null; + survivors: number | null; + missing: number | null; + hydroData: Record | null; + gmdssData: Record | null; + sttsCd: string; + regDtm: string; +} + +// === RESCUE_SCENARIO === +export interface RescueScenarioItem { + scenarioSn: number; + rescueOpsSn: number; + timeStep: string; + scenarioDtm: string | null; + svrtCd: string; // CRITICAL/HIGH/MEDIUM/RESOLVED + gmM: number | null; + listDeg: number | null; + trimM: number | null; + buoyancyPct: number | null; + oilRateLpm: number | null; + bmRatioPct: number | null; + description: string | null; + compartments: Array<{ name: string; status: string; color: string }> | null; + assessment: Array<{ label: string; value: string; color: string }> | null; + actions: Array<{ time: string; text: string; color: string }> | null; + sortOrd: number; +} + +export async function fetchRescueOps(params?: { + sttsCd?: string; + acdntTpCd?: string; + search?: string; +}): Promise { + const response = await api.get('/rescue/ops', { params }); + return response.data; +} + +export async function fetchRescueOpsDetail(sn: number): Promise { + const response = await api.get(`/rescue/ops/${sn}`); + return response.data; +} + +export async function fetchRescueScenarios(rescueOpsSn: number): Promise { + const response = await api.get(`/rescue/ops/${rescueOpsSn}/scenarios`); + return response.data; +} -- 2.45.2