Merge pull request 'feat(phase4): Board/HNS/Prediction/Aerial/Rescue Mock → API 전환' (#43) from feature/scat-api-conversion into develop

Reviewed-on: #43
This commit is contained in:
htlee 2026-03-01 01:22:45 +09:00
커밋 481c93e249
34개의 변경된 파일3590개의 추가작업 그리고 1026개의 파일을 삭제

파일 보기

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

파일 보기

@ -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<string, unknown>): 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<AerialMediaItem[]> {
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<string, unknown>) => 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<string, unknown>): 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<CctvCameraItem[]> {
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<string, unknown>) => 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<string, unknown>): 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<SatRequestItem[]> {
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<string, unknown>) => 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<void> {
await wingPool.query(
`UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`,
[sttsCd, sn]
);
}

파일 보기

@ -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<string, string> = {
}
// ============================================================
// 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)

파일 보기

@ -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<string, unknown>): 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<ManualItem[]> {
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<string, unknown>) => 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<void> {
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<void> {
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<void> {
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<void> {
// 게시글 존재 + 작성자 확인
const existing = await wingPool.query(

파일 보기

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

파일 보기

@ -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<string, unknown> | null
regDtm: string
}
interface ListAnalysesInput {
status?: string
substance?: string
search?: string
}
function rowToAnalysis(r: Record<string, unknown>): 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<string, unknown>) ?? null,
regDtm: r.reg_dtm as string,
}
}
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
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<string, unknown>) => rowToAnalysis(r))
}
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
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<string, unknown>)
}
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<void> {
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

파일 보기

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

파일 보기

@ -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<PredictionAnalysis[]> {
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<string, unknown>) => ({
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<PredictionDetail | null> {
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<string, unknown>;
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<string, unknown>;
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<string, unknown>) => ({
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<string, unknown>) => ({
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<BacktrackResult | null> {
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<string, unknown>);
}
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
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<string, unknown>) => rowToBacktrack(r));
}
function rowToBacktrack(r: Record<string, unknown>): 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<string, unknown>)['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<string, unknown>)['boom_line_sn']) };
}
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
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<string, unknown>) => ({
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'] ?? ''),
}));
}

파일 보기

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

파일 보기

@ -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<string, unknown> | null;
gmdssData: Record<string, unknown> | 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<RescueOpsListItem[]> {
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<string, unknown>) => ({
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<RescueOpsDetail | null> {
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<string, unknown>;
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<string, unknown>) ?? null,
gmdssData: (r.gmdss_data as Record<string, unknown>) ?? 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<RescueScenarioItem[]> {
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<string, unknown>) => ({
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(),
}));
}

파일 보기

@ -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) => {

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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,
}

파일 보기

@ -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<CctvCameraItem[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [regionFilter, setRegionFilter] = useState('전체')
const [selectedCamera, setSelectedCamera] = useState<CctvCamera | null>(null)
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
const [gridMode, setGridMode] = useState(1)
const [activeCells, setActiveCells] = useState<CctvCamera[]>([])
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
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<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
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() {
{/* 카메라 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{filtered.map(cam => (
{loading ? (
<div className="px-3.5 py-4 text-[11px] text-text-3 font-korean"> ...</div>
) : filtered.map(cam => (
<div
key={cam.id}
key={cam.cctvSn}
onClick={() => 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',
}}
>
<div className="relative shrink-0">
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.status === 'live' ? 'var(--green)' : 'var(--t3)' }} />
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.name}</div>
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div>
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.cameraNm}</div>
<div className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? ''}</div>
</div>
<div className="flex flex-col items-end gap-0.5 shrink-0">
{cam.status === 'live' ? (
{cam.sttsCd === 'LIVE' ? (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
) : (
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
)}
{cam.ptz && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
{cam.ptzYn === 'Y' && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
</div>
</div>
))}
@ -151,9 +147,9 @@ export function CctvView() {
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
<div className="flex items-center gap-2 min-w-0">
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'}
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
</div>
{selectedCamera?.status === 'live' && (
{selectedCamera?.sttsCd === 'LIVE' && (
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
</div>
@ -161,7 +157,7 @@ export function CctvView() {
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* PTZ 컨트롤 */}
{selectedCamera?.ptz && (
{selectedCamera?.ptzYn === 'Y' && (
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
{['◀', '▲', '▼', '▶'].map((d, i) => (
@ -213,11 +209,11 @@ export function CctvView() {
<div className="text-4xl opacity-20">📹</div>
</div>
<div className="absolute top-2 left-2 flex items-center gap-1.5">
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.name}</span>
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.cameraNm}</span>
<span className="text-[8px] font-bold px-1 py-0.5 rounded" style={{ background: 'rgba(239,68,68,.3)', color: '#f87171' }}> REC</span>
</div>
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t3)' }}>
{cam.coord} · {cam.source}
{cam.coordDc ?? ''} · {cam.sourceNm ?? ''}
</div>
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
CCTV
@ -233,9 +229,9 @@ export function CctvView() {
{/* 하단 정보 바 */}
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
<div className="text-[10px] text-text-3 font-korean">: <b className="text-text-1">{selectedCamera?.name ?? ''}</b></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-text-2">{selectedCamera?.location ?? ''}</span></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coord ?? ''}</span></div>
<div className="text-[10px] text-text-3 font-korean">: <b className="text-text-1">{selectedCamera?.cameraNm ?? ''}</b></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-text-2">{selectedCamera?.locDc ?? ''}</span></div>
<div className="text-[10px] text-text-3 font-korean">: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coordDc ?? ''}</span></div>
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO CCTV</div>
</div>
</div>
@ -275,13 +271,13 @@ export function CctvView() {
{selectedCamera ? (
<div className="flex flex-col gap-1.5">
{[
['카메라명', 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) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
<span className="text-text-3 font-korean">{k}</span>

파일 보기

@ -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<AerialMediaItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [equipFilter, setEquipFilter] = useState<string>('all')
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
@ -71,6 +50,22 @@ export function MediaManagement() {
const [showUpload, setShowUpload] = useState(false)
const modalRef = useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
@ -165,11 +160,11 @@ export function MediaManagement() {
{/* Summary Stats */}
<div className="flex gap-2.5 mb-4">
{[
{ 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) => (
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
<span className="text-xl">{s.icon}</span>
@ -221,39 +216,43 @@ export function MediaManagement() {
</tr>
</thead>
<tbody>
{sorted.map(f => (
{loading ? (
<tr>
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-text-3 font-korean"> ...</td>
</tr>
) : sorted.map(f => (
<tr
key={f.id}
onClick={() => 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)]' : ''
}`}
>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<input
type="checkbox"
checked={selectedIds.has(f.id)}
onChange={() => toggleId(f.id)}
checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.aerialMediaSn)}
className="accent-primary-blue"
/>
</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.locDc ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.fileNm}</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
{f.equipment}
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
{f.equipNm}
</span>
</td>
<td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}>
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span>
</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
📥

파일 보기

@ -0,0 +1,105 @@
import { api } from '@common/services/api';
// ============================================================
// 항공 방제 API
// ============================================================
// === AERIAL_MEDIA ===
export 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; // drone/plane/satellite
equipNm: string;
mediaTpCd: string; // 사진/영상/적외선/SAR/가시광/광학
takngDtm: string | null;
fileSz: string | null;
resolution: string | null;
}
// === CCTV_CAMERA ===
export interface CctvCameraItem {
cctvSn: number;
cameraNm: string;
regionNm: string;
lon: number | null;
lat: number | null;
locDc: string | null;
coordDc: string | null;
sttsCd: string; // LIVE/OFFLINE/MAINT
ptzYn: string;
sourceNm: string | null;
streamUrl: string | null;
}
// === SAT_REQUEST ===
export 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; // PENDING/SHOOTING/COMPLETED/CANCELLED
}
export interface CreateSatRequestInput {
reqCd: string;
acdntSn?: number;
lon?: number;
lat?: number;
zoneDc?: string;
zoneAreaKm2?: number;
satNm?: string;
providerNm?: string;
resolution?: string;
purposeDc?: string;
reqstrNm?: string;
expectedRcvDtm?: string;
}
export async function fetchAerialMedia(params?: {
equipType?: string;
mediaType?: string;
acdntSn?: number;
search?: string;
}): Promise<AerialMediaItem[]> {
const response = await api.get<AerialMediaItem[]>('/aerial/media', { params });
return response.data;
}
export async function fetchCctvCameras(params?: {
region?: string;
status?: string;
}): Promise<CctvCameraItem[]> {
const response = await api.get<CctvCameraItem[]>('/aerial/cctv', { params });
return response.data;
}
export async function fetchSatRequests(params?: {
status?: string;
}): Promise<SatRequestItem[]> {
const response = await api.get<SatRequestItem[]>('/aerial/sat-requests', { params });
return response.data;
}
export async function createSatRequest(
input: CreateSatRequestInput,
): Promise<{ satReqSn: number }> {
const response = await api.post<{ satReqSn: number }>('/aerial/sat-requests', input);
return response.data;
}

파일 보기

@ -6,7 +6,13 @@ import { BoardDetailView } from './BoardDetailView'
import {
fetchBoardPosts,
deleteBoardPost,
fetchManuals,
createManual,
updateManual,
deleteManual,
incrementManualDownload,
type BoardPostItem,
type ManualItem,
} from '../services/boardApi'
type ViewMode = 'list' | 'detail' | 'write'
@ -37,34 +43,6 @@ const CATEGORY_COLORS: Record<string, string> = {
const PAGE_SIZE = 20
/* ── 해경매뉴얼 Mock 데이터 (향후 API 전환 예정) ── */
interface ManualFile {
id: number
category: string
title: string
version: string
fileType: string
fileSize: string
uploadDate: string
author: string
downloads: number
}
const manualFiles: ManualFile[] = [
{ id: 1, category: '방제매뉴얼', title: '해양오염방제 업무매뉴얼 (2026 개정판)', version: 'v4.2', fileType: 'PDF', fileSize: '28.5 MB', uploadDate: '2026-02-15', author: '해양오염대응국', downloads: 1245 },
{ id: 2, category: '방제매뉴얼', title: '해양오염 방제자원 운용 지침서', version: 'v3.1', fileType: 'PDF', fileSize: '15.2 MB', uploadDate: '2026-01-20', author: '방제과', downloads: 892 },
{ id: 3, category: '방제매뉴얼', title: '오일펜스 전개 · 회수 표준절차서', version: 'v2.8', fileType: 'PDF', fileSize: '12.7 MB', uploadDate: '2025-12-10', author: '방제과', downloads: 1567 },
{ id: 4, category: '대응매뉴얼', title: '해양오염사고 초동대응 매뉴얼', version: 'v5.0', fileType: 'PDF', fileSize: '22.1 MB', uploadDate: '2026-02-01', author: '해양오염대응국', downloads: 2103 },
{ id: 5, category: '대응매뉴얼', title: 'HNS 해양사고 대응 가이드라인', version: 'v2.3', fileType: 'PDF', fileSize: '18.9 MB', uploadDate: '2025-11-15', author: '해양오염대응국', downloads: 734 },
{ id: 6, category: '대응매뉴얼', title: '대량 유출유 방제 대응 체계 매뉴얼', version: 'v3.5', fileType: 'PDF', fileSize: '31.4 MB', uploadDate: '2025-10-20', author: '방제과', downloads: 1089 },
{ id: 7, category: '교육자료', title: '방제요원 교육훈련 교재 (기본과정)', version: 'v6.1', fileType: 'PDF', fileSize: '45.3 MB', uploadDate: '2026-01-10', author: '교육훈련과', downloads: 567 },
{ id: 8, category: '교육자료', title: '방제요원 교육훈련 교재 (심화과정)', version: 'v4.0', fileType: 'PDF', fileSize: '52.8 MB', uploadDate: '2025-12-05', author: '교육훈련과', downloads: 423 },
{ id: 9, category: '교육자료', title: '유류오염 식별 및 샘플링 실무 교재', version: 'v2.0', fileType: 'PDF', fileSize: '9.6 MB', uploadDate: '2025-09-18', author: '교육훈련과', downloads: 312 },
{ id: 10, category: '법령·규정', title: '해양환경관리법 시행규칙 (방제 관련)', version: '2026', fileType: 'PDF', fileSize: '5.4 MB', uploadDate: '2026-02-10', author: '법무담당관', downloads: 645 },
{ id: 11, category: '법령·규정', title: '해양오염방제 자재·약제 검정 기준', version: '2025', fileType: 'PDF', fileSize: '3.8 MB', uploadDate: '2025-08-22', author: '법무담당관', downloads: 389 },
{ id: 12, category: '법령·규정', title: '방제선·방제정 운용 및 관리 규정', version: '2026', fileType: 'PDF', fileSize: '7.2 MB', uploadDate: '2026-01-05', author: '장비관리과', downloads: 478 },
]
export function BoardView() {
const { activeSubTab } = useSubMenu('board')
const hasPermission = useAuthStore((s) => s.hasPermission)
@ -181,17 +159,36 @@ export function BoardView() {
*/
const [manualCategory, setManualCategory] = useState<string>('전체')
const [manualSearch, setManualSearch] = useState('')
const [manualList, setManualList] = useState<ManualFile[]>(manualFiles)
const [manualList, setManualList] = useState<ManualItem[]>([])
const [manualLoading, setManualLoading] = useState(false)
const [showUploadModal, setShowUploadModal] = useState(false)
const [editingManualId, setEditingManualId] = useState<number | null>(null)
const [uploadForm, setUploadForm] = useState({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
const manualCategories = ['전체', '방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정']
const filteredManuals = manualList.filter(f => {
const matchCat = manualCategory === '전체' || f.category === manualCategory
const matchSearch = f.title.toLowerCase().includes(manualSearch.toLowerCase()) || f.author.toLowerCase().includes(manualSearch.toLowerCase())
return matchCat && matchSearch
})
const loadManuals = useCallback(async () => {
setManualLoading(true)
try {
const items = await fetchManuals({
category: manualCategory !== '전체' ? manualCategory : undefined,
search: manualSearch || undefined,
})
setManualList(items)
} catch (err) {
console.error('[board] 매뉴얼 목록 조회 실패:', err)
} finally {
setManualLoading(false)
}
}, [manualCategory, manualSearch])
useEffect(() => {
if (activeSubTab === 'manual') {
loadManuals()
}
}, [loadManuals, activeSubTab])
const filteredManuals = manualList
const catColor = (cat: string) => {
switch (cat) {
@ -244,11 +241,16 @@ export function BoardView() {
{/* 그리드 */}
<div className="flex-1 overflow-auto px-8 py-6">
{manualLoading ? (
<div className="text-center py-20">
<p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}> ...</p>
</div>
) : (
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
{filteredManuals.map(file => {
const cc = catColor(file.category)
const cc = catColor(file.catgNm)
return (
<div key={file.id} className="rounded-xl p-4 transition-all" style={{
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
background: 'var(--bg3)', border: '1px solid var(--bd)',
cursor: 'pointer',
}}
@ -257,7 +259,7 @@ export function BoardView() {
>
<div className="flex items-center justify-between mb-3">
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
{file.category}
{file.catgNm}
</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
{file.version}
@ -269,20 +271,20 @@ export function BoardView() {
<div className="flex items-center gap-2 mb-3">
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
<span style={{ fontSize: 12 }}>📄</span>
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileType}</span>
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSize}</span>
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
</div>
<div className="flex items-center justify-end gap-1 mb-2">
<button onClick={(e) => {
e.stopPropagation()
setEditingManualId(file.id)
setEditingManualId(file.manualSn)
setUploadForm({
category: file.category,
category: file.catgNm,
title: file.title,
version: file.version,
fileName: `${file.title}.${file.fileType.toLowerCase()}`,
fileSize: file.fileSize,
version: file.version || '',
fileName: `${file.title}.${(file.fileTp || 'pdf').toLowerCase()}`,
fileSize: file.fileSz || '',
})
setShowUploadModal(true)
}}
@ -291,10 +293,15 @@ export function BoardView() {
title="수정">
</button>
<button onClick={(e) => {
<button onClick={async (e) => {
e.stopPropagation()
if (window.confirm(`"${file.title}" 매뉴얼을 삭제하시겠습니까?`)) {
setManualList(prev => prev.filter(f => f.id !== file.id))
try {
await deleteManual(file.manualSn)
loadManuals()
} catch (err) {
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
}
}
}}
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
@ -305,26 +312,29 @@ export function BoardView() {
</div>
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
<div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
<span>{file.author}</span>
<span>{file.uploadDate}</span>
<span>{file.authorNm}</span>
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
{file.downloads}
{file.dwnldCnt}
</span>
<button onClick={(e) => {
<button onClick={async (e) => {
e.stopPropagation()
setManualList(prev => prev.map(f => f.id === file.id ? { ...f, downloads: f.downloads + 1 } : f))
try {
await incrementManualDownload(file.manualSn)
setManualList(prev => prev.map(f => f.manualSn === file.manualSn ? { ...f, dwnldCnt: f.dwnldCnt + 1 } : f))
} catch { /* ignore */ }
const content = [
`═══════════════════════════════════════════`,
` ${file.title}`,
`═══════════════════════════════════════════`,
``,
` 카테고리: ${file.category}`,
` 카테고리: ${file.catgNm}`,
` 버전: ${file.version}`,
` 작성자: ${file.author}`,
` 등록일: ${file.uploadDate}`,
` 파일크기: ${file.fileSize}`,
` 작성자: ${file.authorNm}`,
` 등록일: ${new Date(file.regDtm).toLocaleDateString('ko-KR')}`,
` 파일크기: ${file.fileSz}`,
``,
`───────────────────────────────────────────`,
` 본 문서는 해양경찰청 WING 시스템에서`,
@ -353,8 +363,9 @@ export function BoardView() {
)
})}
</div>
)}
{filteredManuals.length === 0 && (
{!manualLoading && filteredManuals.length === 0 && (
<div className="text-center py-20">
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
<p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}> .</p>
@ -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' }}>
</button>
<button onClick={() => {
<button onClick={async () => {
if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return }
if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return }
const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF'
if (editingManualId) {
setManualList(prev => prev.map(f => f.id === editingManualId ? {
...f,
category: uploadForm.category,
title: uploadForm.title,
version: uploadForm.version || f.version,
fileType: ext,
fileSize: uploadForm.fileSize || f.fileSize,
uploadDate: new Date().toISOString().split('T')[0],
} : f))
} else {
const newFile: ManualFile = {
id: Math.max(...manualList.map(f => f.id)) + 1,
category: uploadForm.category,
title: uploadForm.title,
version: uploadForm.version || 'v1.0',
fileType: ext,
fileSize: uploadForm.fileSize,
uploadDate: new Date().toISOString().split('T')[0],
author: '현재 사용자',
downloads: 0,
if (!editingManualId && !uploadForm.fileName) { alert('파일을 선택하세요.'); return }
const ext = uploadForm.fileName ? uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' : undefined
try {
if (editingManualId) {
await updateManual(editingManualId, {
catgNm: uploadForm.category,
title: uploadForm.title,
version: uploadForm.version || undefined,
fileTp: ext,
fileSz: uploadForm.fileSize || undefined,
})
} else {
await createManual({
catgNm: uploadForm.category,
title: uploadForm.title,
version: uploadForm.version || 'v1.0',
fileTp: ext,
fileSz: uploadForm.fileSize,
})
}
setManualList(prev => [newFile, ...prev])
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
setEditingManualId(null)
setShowUploadModal(false)
loadManuals()
} catch (err) {
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
}
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
setEditingManualId(null)
setShowUploadModal(false)
}}
style={{ padding: '8px 24px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee', fontFamily: 'var(--fK)', cursor: 'pointer' }}>
{editingManualId ? '✏️ 수정' : '📤 업로드'}

파일 보기

@ -73,3 +73,64 @@ export async function updateBoardPost(sn: number, input: UpdateBoardPostInput):
export async function deleteBoardPost(sn: number): Promise<void> {
await api.delete(`/board/${sn}`);
}
// ============================================================
// 매뉴얼 API
// ============================================================
export 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;
}
export interface CreateManualInput {
catgNm: string;
title: string;
version?: string;
fileTp?: string;
fileSz?: string;
filePath?: string;
authorNm?: string;
}
export interface UpdateManualInput {
catgNm?: string;
title?: string;
version?: string;
fileTp?: string;
fileSz?: string;
filePath?: string;
}
export async function fetchManuals(params?: {
category?: string;
search?: string;
}): Promise<ManualItem[]> {
const response = await api.get<ManualItem[]>('/board/manual', { params });
return response.data;
}
export async function createManual(input: CreateManualInput): Promise<{ manualSn: number }> {
const response = await api.post<{ manualSn: number }>('/board/manual', input);
return response.data;
}
export async function updateManual(sn: number, input: UpdateManualInput): Promise<void> {
await api.put(`/board/manual/${sn}`, input);
}
export async function deleteManual(sn: number): Promise<void> {
await api.delete(`/board/manual/${sn}`);
}
export async function incrementManualDownload(sn: number): Promise<void> {
await api.post(`/board/manual/${sn}/download`);
}

파일 보기

@ -1,156 +1,58 @@
import { Dispatch, SetStateAction } from 'react'
import { useState, useEffect, useCallback } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
interface HNSAnalysisListTableProps {
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
}
const RISK_LABEL: Record<string, string> = {
CRITICAL: '심각',
HIGH: '위험',
MEDIUM: '경고',
LOW: '관찰',
}
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: 'var(--red)' },
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--red)' },
MEDIUM: { bg: 'rgba(249,115,22,0.15)', color: 'var(--orange)' },
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--green)' },
}
function formatDate(dtm: string | null, mode: 'full' | 'date') {
if (!dtm) return '—'
const d = new Date(dtm)
if (mode === 'date') return d.toISOString().slice(0, 10)
return d.toISOString().slice(0, 16).replace('T', ' ')
}
function substanceTag(sbstNm: string | null): string {
if (!sbstNm) return '—'
const match = sbstNm.match(/\(([^)]+)\)/)
if (match) return match[1]
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm
}
export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps) {
const mockData = [
{
id: 1,
name: '울산 온산항 톨루엔 누출',
substance: '톨루엔 (Toluene)',
substanceTag: '톨루엔',
datetime: '2025-02-11 14:15',
dateOnly: '2025-02-11',
location: '부산항 신항',
amount: '12.0 kL',
algorithm: 'ALOHA',
predictionTime: '24H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '위험',
damageRadius: '1.8 km',
analyst: '운영팀, 방재팀'
},
{
id: 2,
name: '여수 엠프시아 누출',
substance: '벤젠 (Benzene)',
substanceTag: '벤젠',
datetime: '2025-02-09 08:40',
dateOnly: '2025-02-09',
location: '여수항',
amount: '5.0 톤',
algorithm: 'ALOHA',
predictionTime: '12H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '위험',
damageRadius: '2.4 km',
analyst: '남해팀, 방재팀'
},
{
id: 3,
name: '부산 수소 추진연 폭발',
substance: '수소 (Hydrogen)',
substanceTag: 'H₂',
datetime: '2025-02-07 12:15',
dateOnly: '2025-02-07',
location: '부산항',
amount: '0.8 톤',
algorithm: 'CAMEO',
predictionTime: '6H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '심각',
damageRadius: '0.22 km',
analyst: '남해팀, 방재팀'
},
{
id: 4,
name: '인천항 메탄올 유출',
substance: '메탄올 (Methanol)',
substanceTag: 'MeOH',
datetime: '2025-02-03 16:50',
dateOnly: '2025-02-03',
location: '인천항',
amount: '8.5 kL',
algorithm: 'ALOHA',
predictionTime: '24H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '경고',
damageRadius: '1.2 km',
analyst: '중부팀, 방재팀'
},
{
id: 5,
name: '평택항 LPG 누출',
substance: 'LPG',
substanceTag: 'LPG',
datetime: '2025-01-28 09:20',
dateOnly: '2025-01-28',
location: '평택항',
amount: '3.2 톤',
algorithm: 'CAMEO',
predictionTime: '12H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '경고',
damageRadius: '0.95 km',
analyst: '중부팀, 방재팀'
},
{
id: 6,
name: '광양항 벤젠 누출',
substance: '벤젠 (Benzene)',
substanceTag: '벤젠',
datetime: '2025-01-22 11:30',
dateOnly: '2025-01-22',
location: '광양항',
amount: '6.0 kL',
algorithm: 'ALOHA',
predictionTime: '24H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '관찰',
damageRadius: '1.5 km',
analyst: '남해팀, 방재팀'
},
{
id: 7,
name: '목포항 염소스가 유출',
substance: '염소 (Chlorine)',
substanceTag: 'Cl₂',
datetime: '2025-01-15 14:10',
dateOnly: '2025-01-15',
location: '서구항',
amount: '2.0 톤',
algorithm: 'CAMEO',
predictionTime: '12H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '관찰',
damageRadius: '1.0 km',
analyst: '서해팀, 방재팀'
},
{
id: 8,
name: '제주 에탄올링 탱크 파열',
substance: '에탄올 (Ethanol)',
substanceTag: 'C₂H₆',
datetime: '2025-01-08 07:55',
dateOnly: '2025-01-08',
location: '제주항, 일대팀',
amount: '1.5 톤',
algorithm: 'CAMEO',
predictionTime: '6H',
aegl3: true,
aegl2: true,
aegl1: true,
riskLevel: '관찰',
damageRadius: '0.8 km',
analyst: '제주팀, 일대팀'
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await fetchHnsAnalyses()
setAnalyses(items)
} catch (err) {
console.error('[hns] 분석 목록 조회 실패:', err)
} finally {
setLoading(false)
}
]
}, [])
useEffect(() => {
loadData()
}, [loadData])
return (
<div style={{
@ -189,7 +91,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
borderRadius: '12px',
fontFamily: 'var(--fM)'
}}>
{mockData.length}
{analyses.length}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
@ -231,6 +133,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
{/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}> ...</div>
) : (
<table style={{
width: '100%',
borderCollapse: 'collapse',
@ -274,9 +179,19 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
</tr>
</thead>
<tbody>
{mockData.map((item, index) => (
{analyses.map((item, index) => {
const rslt = item.rsltData as Record<string, unknown> | null
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
const aegl3 = rslt?.aegl3 as boolean | undefined
const aegl2 = rslt?.aegl2 as boolean | undefined
const aegl1 = rslt?.aegl1 as boolean | undefined
const damageRadius = (rslt?.damageRadius as string) || '—'
const amount = item.spilQty != null ? `${item.spilQty} ${item.spilUnitCd || 'KL'}` : '—'
return (
<tr
key={item.id}
key={item.hnsAnlysSn}
style={{
borderBottom: '1px solid var(--bd)',
cursor: 'pointer',
@ -286,8 +201,8 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={(e) => e.currentTarget.style.background = index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'}
>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{item.id}</td>
<td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.name}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{item.hnsAnlysSn}</td>
<td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.anlysNm}</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
padding: '4px 8px',
@ -297,13 +212,13 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
background: 'rgba(249,115,22,0.12)',
color: 'var(--orange)'
}}>
{item.substanceTag}
{substanceTag(item.sbstNm)}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{item.datetime}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{item.dateOnly}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)' }}>{item.location}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.amount}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{formatDate(item.acdntDtm, 'full')}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{formatDate(item.regDtm, 'date')}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)' }}>{item.locNm || '—'}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{amount}</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{
padding: '4px 8px',
@ -313,18 +228,18 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
background: 'rgba(6,182,212,0.12)',
color: 'var(--cyan)'
}}>
{item.algorithm}
{item.algoCd || '—'}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.predictionTime}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.fcstHr ? `${item.fcstHr}H` : '—'}</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: item.aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
background: aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
margin: '0 auto',
border: item.aegl3 ? 'none' : '1px solid var(--bd)'
border: aegl3 ? 'none' : '1px solid var(--bd)'
}} />
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -332,9 +247,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
width: '24px',
height: '24px',
borderRadius: '50%',
background: item.aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
background: aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
margin: '0 auto',
border: item.aegl2 ? 'none' : '1px solid var(--bd)'
border: aegl2 ? 'none' : '1px solid var(--bd)'
}} />
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -342,9 +257,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
width: '24px',
height: '24px',
borderRadius: '50%',
background: item.aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
background: aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
margin: '0 auto',
border: item.aegl1 ? 'none' : '1px solid var(--bd)'
border: aegl1 ? 'none' : '1px solid var(--bd)'
}} />
</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -353,26 +268,24 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
borderRadius: '4px',
fontSize: '9px',
fontWeight: 600,
background:
item.riskLevel === '위험' ? 'rgba(239,68,68,0.15)' :
item.riskLevel === '심각' ? 'rgba(239,68,68,0.2)' :
item.riskLevel === '경고' ? 'rgba(249,115,22,0.15)' :
'rgba(34,197,94,0.15)',
color:
item.riskLevel === '위험' ? 'var(--red)' :
item.riskLevel === '심각' ? 'var(--red)' :
item.riskLevel === '경고' ? 'var(--orange)' :
'var(--green)'
background: riskStyle.bg,
color: riskStyle.color,
}}>
{item.riskLevel}
{riskLabel}
</span>
</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.damageRadius}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '10px' }}>{item.analyst}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{damageRadius}</td>
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '10px' }}>{item.analystNm || '—'}</td>
</tr>
))}
)
})}
</tbody>
</table>
)}
{!loading && analyses.length === 0 && (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}> .</div>
)}
</div>
</div>
)

파일 보기

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { createHnsAnalysis } from '../services/hnsApi'
interface HNSRecalcModalProps {
isOpen: boolean
@ -44,15 +45,33 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
return () => document.removeEventListener('mousedown', handler)
}, [isOpen, onClose])
const handleRun = () => {
const handleRun = async () => {
setPhase('running')
setTimeout(() => {
try {
await createHnsAnalysis({
anlysNm: `HNS 재계산 — ${substance}`,
lon,
lat,
sbstNm: substance,
spilQty: amount,
spilUnitCd: unit,
fcstHr: predTime,
algoCd: model,
windSpd: windSpeed,
windDir,
temp,
atmStblCd: stability.charAt(0),
})
setPhase('done')
setTimeout(() => {
onSubmit()
onClose()
}, 1000)
}, 2500)
}, 800)
} catch (err) {
console.error('[hns] 재계산 실패:', err)
setPhase('editing')
alert('재계산 실행에 실패했습니다.')
}
}
if (!isOpen) return null

파일 보기

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react'
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
// ─── Types ──────────────────────────────────────────────
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED'
@ -36,14 +37,7 @@ const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e' },
}
// ─── Mock Data ──────────────────────────────────────────
const INCIDENTS = [
'HNS-2024-041 · 울산 온산항 톨루엔 유출',
'HNS-2024-039 · 여수 암모니아 누출',
'HNS-2024-035 · 부산 수소 추진선 폭발',
'HNS-2024-033 · 인천항 메탄올 유출',
]
// ─── Mock Data (시나리오 시뮬레이션 엔진 미구현 — 프론트 상수 유지) ──
const MOCK_SCENARIOS: HnsScenario[] = [
{
id: 'S-01', name: '유출 직후 (초기 확산)', severity: 'CRITICAL',
@ -109,6 +103,7 @@ const MATERIALS: HnsMaterial[] = [
// ─── Main Component ─────────────────────────────────────
export function HNSScenarioView() {
const [incidents, setIncidents] = useState<HnsAnalysisItem[]>([])
const [selectedIncident, setSelectedIncident] = useState(0)
const [scenarios, setScenarios] = useState(MOCK_SCENARIOS)
const [selectedIdx, setSelectedIdx] = useState(0)
@ -116,6 +111,14 @@ export function HNSScenarioView() {
const [activeView, setActiveView] = useState<ViewTab>(0)
const [modalOpen, setModalOpen] = useState(false)
useEffect(() => {
let cancelled = false
fetchHnsAnalyses()
.then(items => { if (!cancelled) setIncidents(items) })
.catch(err => console.error('[hns] 사고 목록 조회 실패:', err))
return () => { cancelled = true }
}, [])
const selected = scenarios[selectedIdx]
const toggleCheck = (idx: number) => {
@ -151,7 +154,14 @@ export function HNSScenarioView() {
className="prd-i"
style={{ width: '280px', fontSize: '11px' }}
>
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
{incidents.length === 0
? <option value={0}> </option>
: incidents.map((inc, i) => (
<option key={inc.hnsAnlysSn} value={i}>
HNS-{String(inc.hnsAnlysSn).padStart(3, '0')} · {inc.anlysNm}
</option>
))
}
</select>
<button
onClick={() => setModalOpen(true)}

파일 보기

@ -8,6 +8,7 @@ import { HNSSubstanceView } from './HNSSubstanceView'
import { HNSScenarioView } from './HNSScenarioView'
import { HNSRecalcModal } from './HNSRecalcModal'
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
import { createHnsAnalysis } from '../services/hnsApi'
/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */
function HNSManualViewer() {
@ -268,15 +269,17 @@ export function HNSView() {
setIsRunningPrediction(true)
try {
console.log('대기확산 예측 실행 요청:', {
location: incidentCoord
const { hnsAnlysSn } = await createHnsAnalysis({
anlysNm: `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
locNm: `${incidentCoord.lon.toFixed(4)}, ${incidentCoord.lat.toFixed(4)}`,
})
// TODO: 백엔드 API 호출
await new Promise(resolve => setTimeout(resolve, 2000))
// 시뮬레이션 엔진 미구현 — 프론트 임시 결과 생성
const windAngle = 225
const result = {
hnsAnlysSn,
zones: [
{ level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: 500, angle: windAngle },
{ level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: 1000, angle: windAngle },
@ -289,7 +292,6 @@ export function HNSView() {
}
setDispersionResult(result)
console.log('대기확산 예측 완료:', result)
} catch (error) {
console.error('대기확산 예측 오류:', error)
alert('대기확산 예측 중 오류가 발생했습니다.')

파일 보기

@ -0,0 +1,70 @@
import { api } from '@common/services/api';
// ============================================================
// HNS 분석 API
// ============================================================
export 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<string, unknown> | null;
regDtm: string;
}
export interface CreateHnsAnalysisInput {
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;
}
export async function fetchHnsAnalyses(params?: {
status?: string;
substance?: string;
search?: string;
}): Promise<HnsAnalysisItem[]> {
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
return response.data;
}
export async function fetchHnsAnalysis(sn: number): Promise<HnsAnalysisItem> {
const response = await api.get<HnsAnalysisItem>(`/hns/analyses/${sn}`);
return response.data;
}
export async function createHnsAnalysis(input: CreateHnsAnalysisInput): Promise<{ hnsAnlysSn: number }> {
const response = await api.post<{ hnsAnlysSn: number }>('/hns/analyses', input);
return response.data;
}
export async function deleteHnsAnalysis(sn: number): Promise<void> {
await api.delete(`/hns/analyses/${sn}`);
}

파일 보기

@ -1,198 +1,8 @@
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { fetchPredictionAnalyses } from '../services/predictionApi'
import type { PredictionAnalysis } from '../services/predictionApi'
export interface Analysis {
id: number
name: string
occurredAt: string
analysisDate: string
requestor: string
duration: string
oilType: string
volume: number
incidentStatus: string
kospsStatus: 'completed' | 'running' | 'pending' | 'error'
poseidonStatus: 'completed' | 'running' | 'pending' | 'error'
opendriftStatus: 'completed' | 'running' | 'pending' | 'error'
backtracking: 'completed' | 'running' | 'pending' | 'error'
analyst: string
lat: number
lon: number
location: string
}
// Mock 데이터
const mockAnalyses: Analysis[] = [
{
id: 1,
name: '여수 유조선 충돌',
occurredAt: '2025-02-18 06:30',
analysisDate: '2025-02-18',
requestor: '김정훈',
duration: '72H',
oilType: 'BUNKER_C',
volume: 350.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'completed',
backtracking: 'completed',
analyst: '남해청, 방재과',
lat: 34.7312, lon: 127.6845, location: '여수 돌산 남방 5NM',
},
{
id: 2,
name: '통영 화물선 파손',
occurredAt: '2025-02-08 14:20',
analysisDate: '2025-02-08',
requestor: '박민수',
duration: '48H',
oilType: 'DIESEL',
volume: 120.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'running',
opendriftStatus: 'completed',
backtracking: 'completed',
analyst: '남해청, 통영지',
lat: 34.8342, lon: 128.4331, location: '통영항 동방 3NM',
},
{
id: 3,
name: '군산항 송유관 파열',
occurredAt: '2025-02-09 09:15',
analysisDate: '2025-02-09',
requestor: '이승호',
duration: '72H',
oilType: 'CRUDE_OIL',
volume: 580.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'running',
backtracking: 'completed',
analyst: '서해청, 군산지',
lat: 35.9838, lon: 126.5650, location: '군산항 내항 부두',
},
{
id: 4,
name: '인천항 기름선 파손',
occurredAt: '2025-02-05 11:40',
analysisDate: '2025-02-05',
requestor: '최영진',
duration: '48H',
oilType: 'BUNKER_C',
volume: 85.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'completed',
backtracking: 'running',
analyst: '중부청, 인천지',
lat: 37.4563, lon: 126.5922, location: '인천항 남방 2NM',
},
{
id: 5,
name: '제주 담배 해양사',
occurredAt: '2025-01-28 07:50',
analysisDate: '2025-01-28',
requestor: '한지원',
duration: '24H',
oilType: 'DIESEL',
volume: 45.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'error',
backtracking: 'running',
analyst: '제주청, 제주지',
lat: 33.5097, lon: 126.5312, location: '제주항 북방 1NM',
},
{
id: 6,
name: '포항 영일만 탱커',
occurredAt: '2025-01-25 16:00',
analysisDate: '2025-01-25',
requestor: '정우성',
duration: '72H',
oilType: 'CRUDE_OIL',
volume: 220.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'completed',
backtracking: 'completed',
analyst: '동해청, 포항지',
lat: 36.0190, lon: 129.3650, location: '영일만 입구 동방 4NM',
},
{
id: 7,
name: '목포 벙커링 유출',
occurredAt: '2025-01-20 13:10',
analysisDate: '2025-01-20',
requestor: '송태호',
duration: '48H',
oilType: 'BUNKER_C',
volume: 95.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'completed',
backtracking: 'completed',
analyst: '서해청, 목포지',
lat: 34.7936, lon: 126.3815, location: '목포항 외항 남방',
},
{
id: 8,
name: '부산 감천항 충돌',
occurredAt: '2025-01-15 22:10',
analysisDate: '2025-01-14',
requestor: '윤서연',
duration: '12H',
oilType: 'BUNKER_C',
volume: 28.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'running',
backtracking: 'running',
analyst: '남해청, 부산지',
lat: 35.0761, lon: 129.0148, location: '감천항 내항',
},
{
id: 9,
name: '태안 해역 유출',
occurredAt: '2025-01-12 04:45',
analysisDate: '2025-01-12',
requestor: '강민재',
duration: '72H',
oilType: 'CRUDE_OIL',
volume: 1200.00,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'completed',
opendriftStatus: 'completed',
backtracking: 'completed',
analyst: '중부청, 태안지',
lat: 36.7765, lon: 126.1320, location: '태안 만리포 서방 8NM',
},
{
id: 10,
name: '울산항 윤활유 유출',
occurredAt: '2025-01-08 10:30',
analysisDate: '2025-01-08',
requestor: '오현수',
duration: '24H',
oilType: 'LUBE_OIL',
volume: 12.50,
incidentStatus: '진행중',
kospsStatus: 'completed',
poseidonStatus: 'error',
opendriftStatus: 'completed',
backtracking: 'running',
analyst: '남해청, 울산지',
lat: 35.5040, lon: 129.3870, location: '울산항 남방 1NM',
},
]
export type Analysis = PredictionAnalysis
interface AnalysisListTableProps {
onTabChange: (tab: string) => void
@ -200,11 +10,28 @@ interface AnalysisListTableProps {
}
export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisListTableProps) {
const [analyses] = useState<Analysis[]>(mockAnalyses)
const [analyses, setAnalyses] = useState<Analysis[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
const loadData = useCallback(async () => {
setLoading(true)
try {
const items = await fetchPredictionAnalyses({ search: searchTerm || undefined })
setAnalyses(items)
} catch (err) {
console.error('[prediction] 분석 목록 조회 실패:', err)
} finally {
setLoading(false)
}
}, [searchTerm])
useEffect(() => {
loadData()
}, [loadData])
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
@ -317,32 +144,33 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 테이블 */}
<div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center py-20 text-text-3 text-sm"> ...</div>
) : (
<table className="w-full">
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
<tr>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">KOSPS</th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">POSEIDON</th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">OpenDrift</th>
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{currentAnalyses.map((analysis) => (
<tr
key={analysis.id}
key={analysis.acdntSn}
className="hover:bg-bg-2 transition-colors cursor-pointer group"
>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.id}</td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.acdntSn}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
@ -355,30 +183,31 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
}
}}
>
{analysis.name}
{analysis.acdntNm}
</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.analysisDate}</td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.duration}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.oilType}</td>
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
{analysis.volume.toFixed(2)}
</td>
<td className="px-4 py-3 text-center">
<span className="w-2 h-2 rounded-full bg-status-red inline-block animate-pulse" />
{analysis.volume != null ? analysis.volume.toFixed(2) : '—'}
</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.poseidonStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.opendriftStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtracking)}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.requestor}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtrackStatus)}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.analyst}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.officeName}</td>
</tr>
))}
</tbody>
</table>
)}
{!loading && analyses.length === 0 && (
<div className="text-center py-20 text-text-3 text-sm"> .</div>
)}
</div>
{/* 페이지네이션 */}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView'
@ -10,9 +10,10 @@ import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
import type { BacktrackPhase, BacktrackVessel } from '@common/types/backtrack'
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '@common/mock/backtrackMockData'
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
import type { PredictionDetail } from '../services/predictionApi'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// eslint-disable-next-line react-refresh/only-export-components
@ -63,6 +64,16 @@ export function OilSpillView() {
// 선택된 분석 (목록에서 클릭 시)
const [selectedAnalysis, setSelectedAnalysis] = useState<Analysis | null>(null)
// 분석 상세 (API에서 가져온 선박/기상 정보)
const [analysisDetail, setAnalysisDetail] = useState<PredictionDetail | null>(null)
// 역추적 API 데이터
const [backtrackConditions, setBacktrackConditions] = useState<BacktrackConditions>({
estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM',
spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0,
})
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
// 재계산 상태
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
@ -79,19 +90,93 @@ export function OilSpillView() {
})
}
// 역추적: API에서 기존 결과 로딩
const loadBacktrackData = useCallback(async (acdntSn: number) => {
try {
const bt = await fetchBacktrackByAcdnt(acdntSn)
if (bt && bt.execSttsCd === 'completed' && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
}
if (Array.isArray(rslt.replayShips)) {
setReplayShips(rslt.replayShips as ReplayShip[])
}
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
setBacktrackConditions({
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
analysisRange: bt.anlysRange || '±12시간',
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon },
totalVessels: bt.totalVessels || 0,
})
setBacktrackPhase('results')
return
}
} catch (err) {
console.error('[prediction] 역추적 데이터 로딩 실패:', err)
}
// 기존 결과 없으면 conditions 상태 유지
setBacktrackPhase('conditions')
setBacktrackVessels([])
setReplayShips([])
setCollisionEvent(null)
}, [incidentCoord])
// 역추적 핸들러
const handleOpenBacktrack = () => {
setBacktrackModalOpen(true)
setBacktrackPhase('conditions')
setBacktrackVessels([])
setBacktrackConditions(prev => ({
...prev,
spillLocation: incidentCoord,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
} else {
setBacktrackPhase('conditions')
setBacktrackVessels([])
}
}
const handleRunBacktrackAnalysis = () => {
const handleRunBacktrackAnalysis = async () => {
setBacktrackPhase('analyzing')
setTimeout(() => {
setBacktrackVessels(MOCK_VESSELS)
setBacktrackPhase('results')
}, 2000)
try {
if (selectedAnalysis) {
const { backtrackSn } = await createBacktrack({
acdntSn: selectedAnalysis.acdntSn,
lon: incidentCoord.lon,
lat: incidentCoord.lat,
})
// 생성 후 기존 결과 로딩 (시드 데이터 또는 엔진 처리 결과)
const bt = await fetchBacktrackByAcdnt(selectedAnalysis.acdntSn)
if (bt && bt.rsltData) {
const rslt = bt.rsltData as Record<string, unknown>
if (Array.isArray(rslt.vessels)) {
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
}
if (Array.isArray(rslt.replayShips)) {
setReplayShips(rslt.replayShips as ReplayShip[])
}
if (rslt.collisionEvent) {
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
}
setBacktrackConditions(prev => ({
...prev,
totalVessels: bt.totalVessels || 0,
}))
setBacktrackPhase('results')
} else {
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
setBacktrackPhase('conditions')
}
}
} catch (err) {
console.error('[prediction] 역추적 분석 실패:', err)
setBacktrackPhase('conditions')
}
}
const handleStartReplay = () => {
@ -128,15 +213,17 @@ export function OilSpillView() {
}, [isReplayPlaying, replayFrame, replaySpeed])
// 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = (analysis: Analysis) => {
const handleSelectAnalysis = async (analysis: Analysis) => {
setSelectedAnalysis(analysis)
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
if (analysis.lon != null && analysis.lat != null) {
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
}
// 유종 매핑
const oilTypeMap: Record<string, string> = {
'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유',
}
setOilType(oilTypeMap[analysis.oilType] || '벙커C유')
setSpillAmount(analysis.volume)
setSpillAmount(analysis.volume ?? 100)
setPredictionTime(parseInt(analysis.duration) || 48)
// 모델 상태에 따라 선택 모델 설정
const models = new Set<PredictionModel>()
@ -144,6 +231,13 @@ export function OilSpillView() {
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
setSelectedModels(models)
// 분석 상세 로딩 (선박/기상 정보)
try {
const detail = await fetchPredictionDetail(analysis.acdntSn)
setAnalysisDetail(detail)
} catch (err) {
console.error('[prediction] 분석 상세 로딩 실패:', err)
}
// 분석 화면으로 전환
setActiveSubTab('analysis')
}
@ -261,10 +355,10 @@ export function OilSpillView() {
drawingPoints={drawingPoints}
layerOpacity={layerOpacity}
layerBrightness={layerBrightness}
backtrackReplay={isReplayActive ? {
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
isActive: true,
ships: MOCK_REPLAY_SHIPS,
collisionEvent: MOCK_COLLISION,
ships: replayShips,
collisionEvent: collisionEvent || undefined,
replayFrame,
totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord,
@ -427,8 +521,8 @@ export function OilSpillView() {
onSeek={setReplayFrame}
onSpeedChange={setReplaySpeed}
onClose={handleCloseReplay}
replayShips={MOCK_REPLAY_SHIPS}
collisionEvent={MOCK_COLLISION}
replayShips={replayShips}
collisionEvent={collisionEvent || undefined}
/>
)}
</>
@ -436,7 +530,7 @@ export function OilSpillView() {
</div>
{/* Right Panel */}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} />}
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
{/* 재계산 모달 */}
<RecalcModal
@ -464,7 +558,7 @@ export function OilSpillView() {
isOpen={backtrackModalOpen}
onClose={() => setBacktrackModalOpen(false)}
phase={backtrackPhase}
conditions={MOCK_CONDITIONS}
conditions={backtrackConditions}
vessels={backtrackVessels}
onRunAnalysis={handleRunBacktrackAnalysis}
onStartReplay={handleStartReplay}

파일 보기

@ -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
{/* 오염 종합 상황 */}
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px', fontSize: '9px' }}>
<StatBox label="유출량" value="350.00" unit="kl" color="var(--t1)" />
<StatBox label="유출량" value={spill?.volume != null ? spill.volume.toFixed(2) : '—'} unit={spill?.unit || 'kl'} color="var(--t1)" />
<StatBox label="풍화량" value="0.43" unit="kl" color="var(--orange)" />
<StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" />
<StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" />
@ -98,35 +103,37 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
fontSize: '15px'
}}>🚢</div>
<div style={{ flex: 1 }}>
<div className="text-[11px] font-bold text-text-1 font-korean">ORIENTAL GLORY</div>
<div className="text-[8px] text-text-3 font-mono">IMO 9412856 · MMSI 440123456 · </div>
<div className="text-[11px] font-bold text-text-1 font-korean">{vessel?.vesselNm || '—'}</div>
<div className="text-[8px] text-text-3 font-mono">IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}</div>
</div>
<span className="text-[7px] px-2 py-0.5 rounded bg-[rgba(239,68,68,0.12)] text-status-red font-bold"></span>
</div>
{/* 제원 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px' }}>
<SpecCard value="174.0" label="전장 LOA(m)" color="var(--purple)" />
<SpecCard value="32.2" label="형폭 B(m)" color="var(--cyan)" />
<SpecCard value="11.2" label="흘수 d(m)" color="var(--green)" />
<SpecCard value={vessel?.loaM?.toFixed(1) || '—'} label="전장 LOA(m)" color="var(--purple)" />
<SpecCard value={vessel?.breadthM?.toFixed(1) || '—'} label="형폭 B(m)" color="var(--cyan)" />
<SpecCard value={vessel?.draftM?.toFixed(1) || '—'} label="흘수 d(m)" color="var(--green)" />
</div>
<div className="space-y-0.5 text-[9px] font-korean">
<InfoRow label="총톤수(GT)" value="38,642톤" />
<InfoRow label="재화중량(DWT)" value="72,850톤" />
<InfoRow label="건조" value="2008 현대미포" />
<InfoRow label="주기관" value="MAN B&W 9,480kW" mono />
<InfoRow label="선적/선급" value="🇰🇷 대한민국 · KR" />
<InfoRow label="호출부호" value="HLBK" mono />
<InfoRow label="총톤수(GT)" value={vessel?.gt ? `${vessel.gt.toLocaleString()}` : '—'} />
<InfoRow label="재화중량(DWT)" value={vessel?.dwt ? `${vessel.dwt.toLocaleString()}` : '—'} />
<InfoRow label="건조" value={vessel?.builtYr ? `${vessel.builtYr}` : '—'} />
<InfoRow label="주기관" value={vessel?.engineDc || '—'} mono />
<InfoRow label="선적" value={vessel?.flagCd || '—'} />
<InfoRow label="호출부호" value={vessel?.callsign || '—'} mono />
</div>
{/* 충돌 상대 */}
{vessel2 && (
<div className="p-1.5 bg-[rgba(249,115,22,0.04)] border border-[rgba(249,115,22,0.12)] rounded">
<div className="text-[8px] font-bold text-status-orange font-korean mb-1"> 상대: HAI FENG 168</div>
<div className="text-[8px] font-bold text-status-orange font-korean mb-1"> : {vessel2.vesselNm}</div>
<div className="text-[8px] text-text-3 font-korean leading-relaxed">
🇨🇳 52,340GT · 35° · No.1P 1.2m×0.8m
{vessel2.flagCd} {vessel2.vesselTp} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
</div>
</div>
)}
</div>
</CollapsibleSection>
@ -137,48 +144,30 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
onToggle={() => setInsuranceExpanded(!insuranceExpanded)}
>
<div className="space-y-2">
<div className="space-y-0.5 text-[9px] font-korean mb-2">
<InfoRow label="선주" value="대한해운(주)" />
<InfoRow label="운항사" value="대한해운(주)" />
<InfoRow label="P&I" value="한국선주상호보험" />
</div>
{/* 선체보험 */}
<InsuranceCard
title="🚢 선체보험 (H&M)"
color="cyan"
items={[
{ label: '보험사', value: '삼성화재해상보험' },
{ label: '보험가액', value: 'USD 28,500,000', mono: true },
{ label: '보험기간', value: '2025.01~2026.01', valueColor: 'var(--green)' },
{ label: '면책금', value: 'USD 150,000', mono: true }
]}
/>
{/* 화물보험 */}
<InsuranceCard
title="📦 화물보험 (Cargo)"
color="purple"
items={[
{ label: '보험사', value: 'DB손해보험' },
{ label: '보험가액', value: 'USD 42,100,000', mono: true },
{ label: '적하물', value: '벙커C유 72,850톤' },
{ label: '조건', value: 'ICC(A) All Risks' }
]}
/>
{/* 유류오염배상 */}
<InsuranceCard
title="🛢 유류오염배상 (CLC/IOPC)"
color="red"
items={[
{ label: '배상보증서', value: '유효 (2025-12-31)', valueColor: 'var(--green)' },
{ label: 'CLC 한도', value: '89.77M SDR', mono: true },
{ label: 'IOPC 기금', value: '203M SDR', mono: true },
{ label: '추가기금', value: '750M SDR', mono: true },
{ label: '발급기관', value: '한국선주상호보험' }
]}
/>
{insurance && insurance.length > 0 ? (
<>
{insurance.filter(ins => ins.type === 'P&I').map((ins, i) => (
<InsuranceCard key={`pi-${i}`} title="🚢 P&I" color="cyan" items={[
{ label: '보험사', value: ins.insurer },
{ label: '한도', value: `${ins.currency} ${ins.value}`, mono: true },
]} />
))}
{insurance.filter(ins => ins.type === 'H&M').map((ins, i) => (
<InsuranceCard key={`hm-${i}`} title="🚢 선체보험 (H&M)" color="cyan" items={[
{ label: '보험사', value: ins.insurer },
{ label: '보험가액', value: `${ins.currency} ${ins.value}`, mono: true },
]} />
))}
{insurance.filter(ins => ins.type === 'CLC').map((ins, i) => (
<InsuranceCard key={`clc-${i}`} title="🛢 유류오염배상 (CLC)" color="red" items={[
{ label: '발급기관', value: ins.insurer },
{ label: 'CLC 한도', value: `${ins.currency} ${ins.value}`, mono: true },
]} />
))}
</>
) : (
<div className="text-[9px] text-text-3 font-korean text-center py-4"> .</div>
)}
</div>
</CollapsibleSection>
</div>

파일 보기

@ -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<string, unknown> | null;
}
export const fetchPredictionAnalyses = async (params?: {
search?: string;
}): Promise<PredictionAnalysis[]> => {
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
return response.data;
};
export const fetchPredictionDetail = async (acdntSn: number): Promise<PredictionDetail> => {
const response = await api.get<PredictionDetail>(`/prediction/analyses/${acdntSn}`);
return response.data;
};
export const fetchBacktrack = async (sn: number): Promise<BacktrackResult> => {
const response = await api.get<BacktrackResult>(`/prediction/backtrack/${sn}`);
return response.data;
};
export const fetchBacktrackByAcdnt = async (
acdntSn: number,
): Promise<BacktrackResult | null> => {
const response = await api.get<BacktrackResult[]>('/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;
};

파일 보기

@ -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<Severity, { bg: string; color: string; label: string }>
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<Severity, string> = { 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<RescueOpsItem[]>([])
const [apiScenarios, setApiScenarios] = useState<RescueScenarioItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedIncident, setSelectedIncident] = useState(0)
const [scenarios] = useState<RescueScenario[]>(MOCK_SCENARIOS)
const [checked, setChecked] = useState<Set<string>>(new Set(['S-01', 'S-02', 'S-03', 'S-04', 'S-05']))
const [selectedId, setSelectedId] = useState('S-01')
const [checked, setChecked] = useState<Set<string>>(new Set())
const [selectedId, setSelectedId] = useState('')
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time')
const [detailView, setDetailView] = useState<DetailView>(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() {
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<select value={selectedIncident} onChange={e => setSelectedIncident(Number(e.target.value))} style={{ padding: '6px 12px', borderRadius: 6, border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t1)', fontSize: 10, fontFamily: 'var(--fK)', outline: 'none' }}>
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
{ops.map((op, i) => <option key={op.rescueOpsSn} value={i}>{op.opsCd} · {op.vesselNm}</option>)}
</select>
<button onClick={() => setNewScnModalOpen(true)} style={{ padding: '6px 14px', borderRadius: 6, border: 'none', background: 'linear-gradient(135deg,var(--cyan),#3b82f6)', color: '#fff', fontSize: 10, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)' }}>+ </button>
</div>
@ -248,6 +204,9 @@ export function RescueScenarioView() {
{/* Card list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
{loading && scenarios.length === 0 && (
<div style={{ textAlign: 'center', padding: '40px 0', fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)' }}> ...</div>
)}
{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 && <ScenarioComparison />}
{detailView === 1 && <ScenarioComparison chartData={chartData} />}
{/* ─── VIEW 2: 지도 오버레이 ─── */}
{detailView === 2 && (
@ -393,7 +352,7 @@ export function RescueScenarioView() {
<div style={{ fontSize: 13, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)', marginBottom: 6 }}>GIS </div>
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.6, marginBottom: 16 }}> .</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
{MOCK_SCENARIOS.map(sc => (
{scenarios.map(sc => (
<div key={sc.id} style={{ padding: '6px 12px', borderRadius: 6, border: `1px solid ${SEV_STYLE[sc.severity].color}40`, background: SEV_STYLE[sc.severity].bg, fontSize: 9, fontFamily: 'var(--fK)' }}>
<span style={{ fontWeight: 700, color: SEV_STYLE[sc.severity].color }}>{sc.id}</span>
<span style={{ color: 'var(--t2)', marginLeft: 6 }}>{sc.name}</span>
@ -411,13 +370,13 @@ export function RescueScenarioView() {
</div>
{/* ═══ 신규 시나리오 모달 ═══ */}
{newScnModalOpen && <NewScenarioModal onClose={() => setNewScnModalOpen(false)} />}
{newScnModalOpen && <NewScenarioModal ops={ops} onClose={() => setNewScnModalOpen(false)} />}
</div>
)
}
/* ═══ 신규 시나리오 생성 모달 ═══ */
function NewScenarioModal({ onClose }: { onClose: () => void }) {
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
const overlayRef = useRef<HTMLDivElement>(null)
const [submitting, setSubmitting] = useState(false)
const [done, setDone] = useState(false)
@ -471,7 +430,7 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) {
<div>
<label style={labelSt}> <span style={{ color: '#f87171' }}>*</span></label>
<select defaultValue="0" style={selSt}>
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
{ops.map((op, i) => <option key={op.rescueOpsSn} value={i}>{op.opsCd} · {op.vesselNm}</option>)}
<option value="new">+ ...</option>
</select>
</div>
@ -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 (
<div style={{ padding: 40, textAlign: 'center', fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
.
</div>
)
}
return (
<div style={{ padding: 20 }}>
@ -745,10 +712,10 @@ function ScenarioComparison() {
<line x1={PX} x2={W - PX} y1={PY + ph - (1.0 / 2.0) * ph} y2={PY + ph - (1.0 / 2.0) * ph} stroke="rgba(239,68,68,.4)" strokeDasharray="4" />
<text x={W - PX + 4} y={PY + ph - (1.0 / 2.0) * ph + 3} fill="var(--red)" fontSize={7}>GM=1.0 </text>
{/* Area */}
<polygon points={`${PX},${PY + ph} ${CHART_DATA.map((d, i) => `${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)" />
<polygon points={`${PX},${PY + ph} ${chartData.map((d, i) => `${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 */}
<polyline points={CHART_DATA.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} fill="none" stroke="var(--cyan)" strokeWidth={2} />
{CHART_DATA.map((d, i) => (
<polyline points={chartData.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) => (
<g key={d.id}>
<circle cx={PX + i * xStep} cy={PY + ph - (d.gm / 2.0) * ph} r={4} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={1.5} />
<text x={PX + i * xStep} y={PY + ph + 14} textAnchor="middle" fill="var(--t3)" fontSize={8} fontFamily="var(--fK)">{d.label}</text>
@ -769,8 +736,8 @@ function ScenarioComparison() {
return <g key={v}><line x1={PX} x2={W - PX} y1={y} y2={y} stroke="rgba(255,255,255,.06)" /><text x={PX - 6} y={y + 3} textAnchor="end" fill="var(--t3)" fontSize={7} fontFamily="var(--fM)">{v}</text></g>
})}
<line x1={PX} x2={W - PX} y1={PY + ph - (15 / 25) * ph} y2={PY + ph - (15 / 25) * ph} stroke="rgba(239,68,68,.3)" strokeDasharray="4" />
<polyline points={CHART_DATA.map((d, i) => `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} />
{CHART_DATA.map((d, i) => (
<polyline points={chartData.map((d, i) => `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} />
{chartData.map((d, i) => (
<g key={d.id}>
<circle cx={PX + i * xStep} cy={PY + ph - (d.list / 25) * ph} r={3.5} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={1.5} />
<text x={PX + i * xStep} y={PY + ph + 14} textAnchor="middle" fill="var(--t3)" fontSize={7} fontFamily="var(--fK)">{d.label}</text>
@ -787,7 +754,7 @@ function ScenarioComparison() {
const y = PY + ph - (v / 200) * ph
return <g key={v}><line x1={PX} x2={W - PX} y1={y} y2={y} stroke="rgba(255,255,255,.06)" /><text x={PX - 6} y={y + 3} textAnchor="end" fill="var(--t3)" fontSize={7} fontFamily="var(--fM)">{v}</text></g>
})}
{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() {
<thead>
<tr style={{ background: 'rgba(6,182,212,.06)' }}>
<th style={{ padding: '7px 8px', textAlign: 'left', borderBottom: '2px solid var(--bdL)', color: 'var(--cyan)' }}></th>
{CHART_DATA.map(d => (
{chartData.map(d => (
<th key={d.id} style={{ padding: '7px 8px', textAlign: 'center', borderBottom: '2px solid var(--bdL)', color: SEV_COLOR[d.severity] }}>{d.id}<br /><span style={{ fontWeight: 400, fontSize: 8, color: 'var(--t3)' }}>{d.label}</span></th>
))}
</tr>
</thead>
<tbody>
{[
{ 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 => (
<tr key={row.label} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}>
<td style={{ padding: '6px 8px', fontWeight: 600, color: 'var(--t2)' }}>{row.label}</td>
{CHART_DATA.map(d => (
{chartData.map(d => (
<td key={d.id} style={{ padding: '6px 8px', textAlign: 'center', fontFamily: 'var(--fM)', fontWeight: 700, color: row.clr(d) }}>{row.fmt(d)}</td>
))}
</tr>

파일 보기

@ -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<RescueOpsItem[]>([])
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<string, string> = {
collision: '충돌', grounding: '좌초', turning: '선회',
capsizing: '전복', sharpTurn: '급선회', flooding: '침수', sinking: '침몰',
}
return map[tpCd] ?? tpCd
}
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="px-5 py-4 flex items-center justify-between border-b border-border">
<span className="text-sm font-bold font-korean"> </span>
<div className="flex gap-2 items-center">
<input type="text" placeholder="선박명 / 사고번호 검색..." 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)]" />
<input
type="text"
placeholder="선박명 / 사고번호 검색..."
value={searchTerm}
onChange={(e) => 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)]"
/>
<button className="px-3.5 py-1.5 bg-[rgba(6,182,212,0.12)] border border-[rgba(6,182,212,0.3)] rounded-md text-[var(--cyan)] text-[11px] font-semibold cursor-pointer font-korean hover:bg-[rgba(6,182,212,0.2)]">
+
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 pb-4">
{loading ? (
<div className="text-center py-20 text-text-3 text-sm"> ...</div>
) : opsList.length === 0 ? (
<div className="text-center py-20 text-text-3 text-sm"> .</div>
) : (
<table className="w-full border-collapse text-[11px] mt-3">
<thead>
<tr className="bg-bg-3 border-b border-border">
@ -841,23 +883,27 @@ function RescueListView() {
</tr>
</thead>
<tbody>
{listData.map((r, i) => (
<tr key={i} className="border-b border-border hover:bg-bg-hover cursor-pointer">
<td className="py-2 px-2.5">
<span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{
background: `color-mix(in srgb, ${r.statusColor} 15%, transparent)`, color: r.statusColor
}}>{r.status}</span>
</td>
<td className="py-2 px-2.5 font-mono text-[var(--cyan)] font-semibold">{r.no}</td>
<td className="py-2 px-2.5 font-korean text-text-1 font-semibold">{r.vessel}</td>
<td className="py-2 px-2.5 font-korean">{r.type}</td>
<td className="py-2 px-2.5 font-mono text-text-3">{r.date}</td>
<td className="py-2 px-2.5 font-mono text-text-3 text-[10px]">{r.location}</td>
<td className="py-2 px-2.5 font-mono">{r.crew}</td>
</tr>
))}
{opsList.map((r) => {
const status = getStatusLabel(r.sttsCd)
return (
<tr key={r.rescueOpsSn} className="border-b border-border hover:bg-bg-hover cursor-pointer">
<td className="py-2 px-2.5">
<span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{
background: `color-mix(in srgb, ${status.color} 15%, transparent)`, color: status.color
}}>{status.label}</span>
</td>
<td className="py-2 px-2.5 font-mono text-[var(--cyan)] font-semibold">{r.opsCd}</td>
<td className="py-2 px-2.5 font-korean text-text-1 font-semibold">{r.vesselNm}</td>
<td className="py-2 px-2.5 font-korean">{getTypeLabel(r.acdntTpCd)}</td>
<td className="py-2 px-2.5 font-mono text-text-3">{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}</td>
<td className="py-2 px-2.5 font-mono text-text-3 text-[10px]">{r.locDc ?? '—'}</td>
<td className="py-2 px-2.5 font-mono">{r.survivors ?? 0}/{r.totalCrew ?? 0}</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)

파일 보기

@ -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<string, unknown> | null;
gmdssData: Record<string, unknown> | 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<RescueOpsItem[]> {
const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params });
return response.data;
}
export async function fetchRescueOpsDetail(sn: number): Promise<RescueOpsItem> {
const response = await api.get<RescueOpsItem>(`/rescue/ops/${sn}`);
return response.data;
}
export async function fetchRescueScenarios(rescueOpsSn: number): Promise<RescueScenarioItem[]> {
const response = await api.get<RescueScenarioItem[]>(`/rescue/ops/${rescueOpsSn}/scenarios`);
return response.data;
}