chore(map): develop 병합 (Phase 6 충돌 해결)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-01 02:59:18 +09:00
커밋 353047dede
44개의 변경된 파일5786개의 추가작업 그리고 1520개의 파일을 삭제

파일 보기

@ -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 { Router } from 'express'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js' import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
import { AuthError } from '../auth/authService.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() 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) => { router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
try { try {
const { categoryCd, search, page, size } = req.query const { categoryCd, search, page, size } = req.query
@ -32,9 +115,7 @@ router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res
} }
}) })
// ============================================================
// GET /api/board/:sn — 게시글 상세 // GET /api/board/:sn — 게시글 상세
// ============================================================
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => { router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
try { try {
const sn = parseInt(req.params.sn as string, 10) 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 권한) // POST /api/board — 게시글 작성 (카테고리별 CREATE 권한)
// ============================================================
router.post('/', requireAuth, async (req, res, next) => { router.post('/', requireAuth, async (req, res, next) => {
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board' const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
requirePermission(resource, 'CREATE')(req, res, next) requirePermission(resource, 'CREATE')(req, res, next)
@ -87,9 +166,7 @@ router.post('/', requireAuth, async (req, res, next) => {
} }
}) })
// ============================================================
// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서) // PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서)
// ============================================================
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => { router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => {
try { try {
const sn = parseInt(req.params.sn as string, 10) 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 — 게시글 삭제 (논리 삭제, 소유자 검증) // DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증)
// ============================================================
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => { router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => {
try { try {
const sn = parseInt(req.params.sn as string, 10) 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> { export async function deletePost(postSn: number, requesterId: string): Promise<void> {
// 게시글 존재 + 작성자 확인 // 게시글 존재 + 작성자 확인
const existing = await wingPool.query( const existing = await wingPool.query(

파일 보기

@ -1,9 +1,88 @@
import express from 'express' 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 { isValidNumber } from '../middleware/security.js'
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
const router = express.Router() 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 물질 검색 // HNS 물질 검색
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { 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) { export async function getSubstanceById(id: number) {
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA `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,429 @@
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<{
obsDtm: string;
locNm: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
}>;
}
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
OBS_DTM,
LOC_NM,
TEMP,
WEATHER_DC,
WIND,
WAVE,
HUMID,
VIS,
SST
FROM ACDNT_WEATHER
WHERE ACDNT_SN = $1
ORDER BY OBS_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>) => ({
obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '',
locNm: String(w['loc_nm'] ?? ''),
temp: String(w['temp'] ?? ''),
weatherDc: String(w['weather_dc'] ?? ''),
wind: String(w['wind'] ?? ''),
wave: String(w['wave'] ?? ''),
humid: String(w['humid'] ?? ''),
vis: String(w['vis'] ?? ''),
sst: String(w['sst'] ?? ''),
}));
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(),
}));
}

파일 보기

@ -0,0 +1,62 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listZones, listSections, getSection } from './scatService.js';
const router = Router();
// ============================================================
// GET /api/scat/zones — 조사구역 목록
// ============================================================
router.get('/zones', requireAuth, async (_req, res) => {
try {
const zones = await listZones();
res.json(zones);
} catch (err) {
console.error('[scat] 조사구역 목록 조회 오류:', err);
res.status(500).json({ error: '조사구역 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/sections — 해안구간 목록 (필터링)
// ============================================================
router.get('/sections', requireAuth, async (req, res) => {
try {
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
};
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
res.json(sections);
} catch (err) {
console.error('[scat] 해안구간 목록 조회 오류:', err);
res.status(500).json({ error: '해안구간 목록 조회 중 오류가 발생했습니다.' });
}
});
// ============================================================
// GET /api/scat/sections/:sn — 해안구간 상세
// ============================================================
router.get('/sections/:sn', requireAuth, async (req, res) => {
try {
const sn = parseInt(req.params.sn as string, 10);
if (isNaN(sn)) {
res.status(400).json({ error: '유효하지 않은 구간 번호입니다.' });
return;
}
const section = await getSection(sn);
if (!section) {
res.status(404).json({ error: '해안구간을 찾을 수 없습니다.' });
return;
}
res.json(section);
} catch (err) {
console.error('[scat] 해안구간 상세 조회 오류:', err);
res.status(500).json({ error: '해안구간 상세 조회 중 오류가 발생했습니다.' });
}
});
export default router;

파일 보기

@ -0,0 +1,212 @@
import { wingPool } from '../db/wingDb.js';
// ============================================================
// 인터페이스
// ============================================================
interface ZoneItem {
cstSrvyZoneSn: number;
zoneCd: string;
zoneNm: string;
jrsdNm: string;
sectCnt: number;
latCenter: number;
lngCenter: number;
latRange: number;
lngRange: number;
}
interface SectionListItem {
cstSectSn: number;
sectCd: string;
sectNm: string;
cstTpCd: string;
esiCd: string;
esiNum: number;
lenM: number;
snstvtCd: string;
srvySttsCd: string;
lat: number;
lng: number;
tags: string[];
zoneCd: string;
zoneNm: string;
jrsdNm: string;
}
interface SectionDetail {
cstSectSn: number;
sectCd: string;
sectNm: string;
cstTpCd: string;
esiCd: string;
esiNum: number;
lenM: number;
snstvtCd: string;
srvySttsCd: string;
lat: number;
lng: number;
tags: string[];
geomJson: Record<string, unknown> | null;
zoneCd: string;
zoneNm: string;
jrsdNm: string;
shoreTp: string | null;
accessDc: string | null;
accessPt: string | null;
sensitiveInfo: { t: string; v: string }[];
cleanupMethods: string[];
endCriteria: string[];
notes: string[];
}
// ============================================================
// 조사구역 목록 조회
// ============================================================
export async function listZones(): Promise<ZoneItem[]> {
const sql = `
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
FROM wing.CST_SRVY_ZONE
WHERE USE_YN = 'Y'
ORDER BY CST_SRVY_ZONE_SN
`;
const { rows } = await wingPool.query(sql);
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
return rows.map((r: Record<string, unknown>) => ({
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
zoneCd: r.zone_cd as string,
zoneNm: r.zone_nm as string,
jrsdNm: r.jrsd_nm as string,
sectCnt: r.sect_cnt as number,
latCenter: parseFloat(r.lat_center as string),
lngCenter: parseFloat(r.lng_center as string),
latRange: parseFloat(r.lat_range as string),
lngRange: parseFloat(r.lng_range as string),
}));
}
// ============================================================
// 해안구간 목록 조회 (필터링)
// ============================================================
export async function listSections(filters: {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
}): Promise<SectionListItem[]> {
const conditions: string[] = [];
const params: unknown[] = [];
let idx = 1;
if (filters.zone) {
conditions.push(`z.ZONE_CD = $${idx++}`);
params.push(filters.zone);
}
if (filters.status) {
conditions.push(`s.SRVY_STTS_CD = $${idx++}`);
params.push(filters.status);
}
if (filters.sensitivity) {
conditions.push(`s.SNSTVT_CD = $${idx++}`);
params.push(filters.sensitivity);
}
if (filters.jurisdiction) {
conditions.push(`z.JRSD_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.jurisdiction);
}
if (filters.search) {
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
params.push(filters.search);
}
conditions.push("s.USE_YN = 'Y'");
conditions.push("z.USE_YN = 'Y'");
const where = 'WHERE ' + conditions.join(' AND ');
const sql = `
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
s.LAT, s.LNG, s.TAGS,
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
FROM wing.CST_SECT s
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
${where}
ORDER BY s.CST_SECT_SN
`;
const { rows } = await wingPool.query(sql, params);
return rows.map((r: Record<string, unknown>) => ({
cstSectSn: r.cst_sect_sn as number,
sectCd: r.sect_cd as string,
sectNm: r.sect_nm as string,
cstTpCd: r.cst_tp_cd as string,
esiCd: r.esi_cd as string,
esiNum: r.esi_num as number,
lenM: r.len_m as number,
snstvtCd: r.snstvt_cd as string,
srvySttsCd: r.srvy_stts_cd as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
tags: (r.tags as string[]) ?? [],
zoneCd: r.zone_cd as string,
zoneNm: r.zone_nm as string,
jrsdNm: r.jrsd_nm as string,
}));
}
// ============================================================
// 해안구간 단건 상세 조회 (JSONB 포함)
// ============================================================
export async function getSection(sn: number): Promise<SectionDetail | null> {
const sql = `
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
s.LAT, s.LNG, s.TAGS,
ST_AsGeoJSON(s.GEOM)::jsonb AS geom_json,
s.SHORE_TP, s.ACCESS_DC, s.ACCESS_PT,
s.SENSITIVE_INFO, s.CLEANUP_METHODS, s.END_CRITERIA, s.NOTES,
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
FROM wing.CST_SECT s
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
WHERE s.CST_SECT_SN = $1 AND s.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 {
cstSectSn: r.cst_sect_sn as number,
sectCd: r.sect_cd as string,
sectNm: r.sect_nm as string,
cstTpCd: r.cst_tp_cd as string,
esiCd: r.esi_cd as string,
esiNum: r.esi_num as number,
lenM: r.len_m as number,
snstvtCd: r.snstvt_cd as string,
srvySttsCd: r.srvy_stts_cd as string,
lat: parseFloat(r.lat as string),
lng: parseFloat(r.lng as string),
tags: (r.tags as string[]) ?? [],
geomJson: (r.geom_json as Record<string, unknown>) ?? null,
zoneCd: r.zone_cd as string,
zoneNm: r.zone_nm as string,
jrsdNm: r.jrsd_nm as string,
shoreTp: (r.shore_tp as string) ?? null,
accessDc: (r.access_dc as string) ?? null,
accessPt: (r.access_pt as string) ?? null,
sensitiveInfo: (r.sensitive_info as { t: string; v: string }[]) ?? [],
cleanupMethods: (r.cleanup_methods as string[]) ?? [],
endCriteria: (r.end_criteria as string[]) ?? [],
notes: (r.notes as string[]) ?? [],
};
}

파일 보기

@ -18,6 +18,10 @@ import hnsRouter from './hns/hnsRouter.js'
import reportsRouter from './reports/reportsRouter.js' import reportsRouter from './reports/reportsRouter.js'
import assetsRouter from './assets/assetsRouter.js' import assetsRouter from './assets/assetsRouter.js'
import incidentsRouter from './incidents/incidentsRouter.js' import 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 { import {
sanitizeBody, sanitizeBody,
sanitizeQuery, sanitizeQuery,
@ -45,7 +49,13 @@ app.use(helmet({
scriptSrc: ["'self'"], scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"], imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", "http://localhost:*", "https://*.gc-si.dev", "https://*.data.go.kr", "https://*.khoa.go.kr"], connectSrc: [
"'self'",
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:*'] : []),
'https://*.gc-si.dev',
'https://*.data.go.kr',
'https://*.khoa.go.kr',
],
fontSrc: ["'self'"], fontSrc: ["'self'"],
objectSrc: ["'none'"], objectSrc: ["'none'"],
frameSrc: ["'none'"], frameSrc: ["'none'"],
@ -61,11 +71,12 @@ app.disable('x-powered-by')
// 3. CORS: 허용된 출처만 접근 가능 // 3. CORS: 허용된 출처만 접근 가능
const allowedOrigins = [ const allowedOrigins = [
'http://localhost:5173', // Vite dev server process.env.FRONTEND_URL || 'https://wing-demo.gc-si.dev',
'http://localhost:5174', ...(process.env.NODE_ENV !== 'production' ? [
'http://localhost:3000', 'http://localhost:5173',
'https://wing-demo.gc-si.dev', 'http://localhost:5174',
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL (추가 도메인) 'http://localhost:3000',
] : []),
].filter(Boolean) as string[] ].filter(Boolean) as string[]
app.use(cors({ app.use(cors({
@ -147,6 +158,10 @@ app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter) app.use('/api/reports', reportsRouter)
app.use('/api/assets', assetsRouter) app.use('/api/assets', assetsRouter)
app.use('/api/incidents', incidentsRouter) app.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) => { app.get('/health', (_req, res) => {

파일 보기

@ -0,0 +1,31 @@
-- ============================================================
-- 010: PostGIS GEOMETRY 컬럼 일괄 추가
-- 기존 NUMERIC LAT/LNG 유지 + GEOMETRY(Point, 4326) 추가
-- ============================================================
SET search_path TO wing, public;
-- PostGIS 익스텐션 확인
CREATE EXTENSION IF NOT EXISTS postgis;
-- ============================================================
-- 1. ASSET_ORG — 방제자산 기관 위치
-- ============================================================
ALTER TABLE ASSET_ORG ADD COLUMN IF NOT EXISTS GEOM GEOMETRY(Point, 4326);
UPDATE ASSET_ORG
SET GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND GEOM IS NULL;
CREATE INDEX IF NOT EXISTS IDX_ASSET_ORG_GEOM ON ASSET_ORG USING GIST(GEOM);
-- ============================================================
-- 2. ACDNT — 사고 위치
-- ============================================================
ALTER TABLE ACDNT ADD COLUMN IF NOT EXISTS LOC_GEOM GEOMETRY(Point, 4326);
UPDATE ACDNT
SET LOC_GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND LOC_GEOM IS NULL;
CREATE INDEX IF NOT EXISTS IDX_ACDNT_LOC_GEOM ON ACDNT USING GIST(LOC_GEOM);

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

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

파일 보기

@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage'
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu' import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
import { useAuthStore } from '@common/store/authStore' import { useAuthStore } from '@common/store/authStore'
import { useMenuStore } from '@common/store/menuStore' import { useMenuStore } from '@common/store/menuStore'
import { API_BASE_URL } from '@common/services/api'
import { OilSpillView } from '@tabs/prediction' import { OilSpillView } from '@tabs/prediction'
import { ReportsView } from '@tabs/reports' import { ReportsView } from '@tabs/reports'
import { HNSView } from '@tabs/hns' import { HNSView } from '@tabs/hns'
@ -46,8 +47,7 @@ function App() {
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })], [JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
{ type: 'text/plain' } { type: 'text/plain' }
) )
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api' navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
navigator.sendBeacon(`${apiBase}/audit/log`, blob)
}, [activeMainTab, isAuthenticated]) }, [activeMainTab, isAuthenticated])
// 세션 확인 중 스플래시 // 세션 확인 중 스플래시

파일 보기

@ -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' import { useState, useCallback, useEffect } from 'react'
import { fetchCctvCameras } from '../services/aerialApi'
interface CctvCamera { import type { CctvCameraItem } from '../services/aerialApi'
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' },
]
const cctvFavorites = [ const cctvFavorites = [
{ name: '서귀포항 동측', reason: '유출 사고 인접' }, { name: '서귀포항 동측', reason: '유출 사고 인접' },
@ -33,28 +9,46 @@ const cctvFavorites = [
] ]
export function CctvView() { export function CctvView() {
const [cameras, setCameras] = useState<CctvCameraItem[]>([])
const [loading, setLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [regionFilter, setRegionFilter] = 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 [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 regions = ['전체', '제주', '남해', '서해', '동해']
const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' } const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
const filtered = cctvCameras.filter(c => { const filtered = cameras.filter(c => {
if (regionFilter !== '전체' && c.region !== regionFilter) return false if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false
if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) return false
return true return true
}) })
const handleSelectCamera = (cam: CctvCamera) => { const handleSelectCamera = (cam: CctvCameraItem) => {
setSelectedCamera(cam) setSelectedCamera(cam)
if (gridMode === 1) { if (gridMode === 1) {
setActiveCells([cam]) setActiveCells([cam])
} else { } else {
setActiveCells(prev => { 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 return prev
}) })
} }
@ -114,31 +108,33 @@ export function CctvView() {
{/* 카메라 목록 */} {/* 카메라 목록 */}
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}> <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 <div
key={cam.id} key={cam.cctvSn}
onClick={() => handleSelectCamera(cam)} onClick={() => handleSelectCamera(cam)}
className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors" className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors"
style={{ style={{
borderColor: 'rgba(255,255,255,.04)', 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="relative shrink-0">
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div> <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>
<div className="flex-1 min-w-0"> <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-[11px] font-semibold text-text-1 font-korean truncate">{cam.cameraNm}</div>
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div> <div className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? ''}</div>
</div> </div>
<div className="flex flex-col items-end gap-0.5 shrink-0"> <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(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> <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>
</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 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="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"> <div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'} {selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
</div> </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)' }}> <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 <span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
</div> </div>
@ -161,7 +157,7 @@ export function CctvView() {
</div> </div>
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
{/* PTZ 컨트롤 */} {/* 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]"> <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> <span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
{['◀', '▲', '▼', '▶'].map((d, i) => ( {['◀', '▲', '▼', '▶'].map((d, i) => (
@ -213,11 +209,11 @@ export function CctvView() {
<div className="text-4xl opacity-20">📹</div> <div className="text-4xl opacity-20">📹</div>
</div> </div>
<div className="absolute top-2 left-2 flex items-center gap-1.5"> <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> <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>
<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)' }}> <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>
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60"> <div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
CCTV 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="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">: <b className="text-text-1">{selectedCamera?.cameraNm ?? ''}</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-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?.coord ?? ''}</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 className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO CCTV</div>
</div> </div>
</div> </div>
@ -275,13 +271,13 @@ export function CctvView() {
{selectedCamera ? ( {selectedCamera ? (
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
{[ {[
['카메라명', selectedCamera.name], ['카메라명', selectedCamera.cameraNm],
['지역', selectedCamera.region], ['지역', selectedCamera.regionNm],
['위치', selectedCamera.location], ['위치', selectedCamera.locDc ?? '—'],
['좌표', selectedCamera.coord], ['좌표', selectedCamera.coordDc ?? '—'],
['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'], ['상태', selectedCamera.sttsCd === 'LIVE' ? '● 송출중' : '● 오프라인'],
['PTZ', selectedCamera.ptz ? '지원' : '미지원'], ['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'],
['출처', selectedCamera.source], ['출처', selectedCamera.sourceNm ?? '—'],
].map(([k, v], i) => ( ].map(([k, v], i) => (
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]"> <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> <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 { function formatDtm(dtm: string | null): string {
id: number if (!dtm) return '—'
incident: string const d = new Date(dtm)
location: string return d.toISOString().slice(0, 16).replace('T', ' ')
filename: string
equipment: string
equipType: 'drone' | 'plane' | 'satellite'
mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학'
datetime: string
size: string
resolution: string
} }
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 equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
const equipTagCls = (t: string) => const equipTagCls = (t: string) =>
@ -63,6 +40,8 @@ const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean;
// ── Component ── // ── Component ──
export function MediaManagement() { export function MediaManagement() {
const [mediaItems, setMediaItems] = useState<AerialMediaItem[]>([])
const [loading, setLoading] = useState(true)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()) const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [equipFilter, setEquipFilter] = useState<string>('all') const [equipFilter, setEquipFilter] = useState<string>('all')
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set()) const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
@ -71,6 +50,22 @@ export function MediaManagement() {
const [showUpload, setShowUpload] = useState(false) const [showUpload, setShowUpload] = useState(false)
const modalRef = useRef<HTMLDivElement>(null) 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(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(e.target as Node)) { if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
@ -81,22 +76,22 @@ export function MediaManagement() {
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler)
}, [showUpload]) }, [showUpload])
const filtered = mediaFiles.filter(f => { const filtered = mediaItems.filter(f => {
if (equipFilter !== 'all' && f.equipType !== equipFilter) return false if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false
if (typeFilter.size > 0) { if (typeFilter.size > 0) {
const isPhoto = !['영상'].includes(f.mediaType) const isPhoto = f.mediaTpCd !== '영상'
const isVideo = f.mediaType === '영상' const isVideo = f.mediaTpCd === '영상'
if (typeFilter.has('photo') && !isPhoto) return false if (typeFilter.has('photo') && !isPhoto) return false
if (typeFilter.has('video') && !isVideo) 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 return true
}) })
const sorted = [...filtered].sort((a, b) => { const sorted = [...filtered].sort((a, b) => {
if (sortBy === 'name') return a.filename.localeCompare(b.filename) if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm)
if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size) if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0')
return b.datetime.localeCompare(a.datetime) return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '')
}) })
const toggleId = (id: number) => { const toggleId = (id: number) => {
@ -111,7 +106,7 @@ export function MediaManagement() {
if (selectedIds.size === sorted.length) { if (selectedIds.size === sorted.length) {
setSelectedIds(new Set()) setSelectedIds(new Set())
} else { } 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 droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length
const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
@ -165,11 +160,11 @@ export function MediaManagement() {
{/* Summary Stats */} {/* Summary Stats */}
<div className="flex gap-2.5 mb-4"> <div className="flex gap-2.5 mb-4">
{[ {[
{ icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' }, { icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-primary-cyan' },
{ icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' }, { icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-text-1' },
{ icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' }, { icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-text-1' },
{ icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' }, { icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-text-1' },
{ icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' }, { icon: '💾', value: '', label: '총 용량', color: 'text-text-1' },
].map((s, i) => ( ].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"> <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> <span className="text-xl">{s.icon}</span>
@ -221,39 +216,43 @@ export function MediaManagement() {
</tr> </tr>
</thead> </thead>
<tbody> <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 <tr
key={f.id} key={f.aerialMediaSn}
onClick={() => toggleId(f.id)} onClick={() => toggleId(f.aerialMediaSn)}
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${ 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()}> <td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
<input <input
type="checkbox" type="checkbox"
checked={selectedIds.has(f.id)} checked={selectedIds.has(f.aerialMediaSn)}
onChange={() => toggleId(f.id)} onChange={() => toggleId(f.aerialMediaSn)}
className="accent-primary-blue" className="accent-primary-blue"
/> />
</td> </td>
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</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.incident}</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.location}</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.filename}</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"> <td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}> <span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
{f.equipment} {f.equipNm}
</span> </span>
</td> </td>
<td className="px-2 py-2"> <td className="px-2 py-2">
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}> <span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}>
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType} {f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
</span> </span>
</td> </td>
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</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.size}</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-[11px] font-mono">{f.resolution ?? '—'}</td>
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}> <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"> <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;
}

파일 보기

@ -1,6 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import type { InsuranceRow } from './assetTypes' import type { InsuranceRow } from './assetTypes'
const DEFAULT_HAEWOON_API = import.meta.env.VITE_HAEWOON_API_URL || 'https://api.haewoon.or.kr/v1/insurance'
// 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용) // 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용)
const INSURANCE_DEMO_DATA: InsuranceRow[] = [ const INSURANCE_DEMO_DATA: InsuranceRow[] = [
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' }, { shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
@ -13,7 +15,7 @@ const INSURANCE_DEMO_DATA: InsuranceRow[] = [
function ShipInsurance() { function ShipInsurance() {
const [apiConnected, setApiConnected] = useState(false) const [apiConnected, setApiConnected] = useState(false)
const [showConfig, setShowConfig] = useState(false) const [showConfig, setShowConfig] = useState(false)
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance') const [configEndpoint, setConfigEndpoint] = useState(DEFAULT_HAEWOON_API)
const [configApiKey, setConfigApiKey] = useState('') const [configApiKey, setConfigApiKey] = useState('')
const [configKeyType, setConfigKeyType] = useState('mmsi') const [configKeyType, setConfigKeyType] = useState('mmsi')
const [configRespType, setConfigRespType] = useState('json') const [configRespType, setConfigRespType] = useState('json')

파일 보기

@ -6,7 +6,13 @@ import { BoardDetailView } from './BoardDetailView'
import { import {
fetchBoardPosts, fetchBoardPosts,
deleteBoardPost, deleteBoardPost,
fetchManuals,
createManual,
updateManual,
deleteManual,
incrementManualDownload,
type BoardPostItem, type BoardPostItem,
type ManualItem,
} from '../services/boardApi' } from '../services/boardApi'
type ViewMode = 'list' | 'detail' | 'write' type ViewMode = 'list' | 'detail' | 'write'
@ -37,34 +43,6 @@ const CATEGORY_COLORS: Record<string, string> = {
const PAGE_SIZE = 20 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() { export function BoardView() {
const { activeSubTab } = useSubMenu('board') const { activeSubTab } = useSubMenu('board')
const hasPermission = useAuthStore((s) => s.hasPermission) const hasPermission = useAuthStore((s) => s.hasPermission)
@ -181,17 +159,36 @@ export function BoardView() {
*/ */
const [manualCategory, setManualCategory] = useState<string>('전체') const [manualCategory, setManualCategory] = useState<string>('전체')
const [manualSearch, setManualSearch] = useState('') 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 [showUploadModal, setShowUploadModal] = useState(false)
const [editingManualId, setEditingManualId] = useState<number | null>(null) const [editingManualId, setEditingManualId] = useState<number | null>(null)
const [uploadForm, setUploadForm] = useState({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' }) const [uploadForm, setUploadForm] = useState({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
const manualCategories = ['전체', '방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정'] const manualCategories = ['전체', '방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정']
const filteredManuals = manualList.filter(f => {
const matchCat = manualCategory === '전체' || f.category === manualCategory const loadManuals = useCallback(async () => {
const matchSearch = f.title.toLowerCase().includes(manualSearch.toLowerCase()) || f.author.toLowerCase().includes(manualSearch.toLowerCase()) setManualLoading(true)
return matchCat && matchSearch 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) => { const catColor = (cat: string) => {
switch (cat) { switch (cat) {
@ -244,11 +241,16 @@ export function BoardView() {
{/* 그리드 */} {/* 그리드 */}
<div className="flex-1 overflow-auto px-8 py-6"> <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))' }}> <div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
{filteredManuals.map(file => { {filteredManuals.map(file => {
const cc = catColor(file.category) const cc = catColor(file.catgNm)
return ( 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)', background: 'var(--bg3)', border: '1px solid var(--bd)',
cursor: 'pointer', cursor: 'pointer',
}} }}
@ -257,7 +259,7 @@ export function BoardView() {
> >
<div className="flex items-center justify-between mb-3"> <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 }}> <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>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}> <span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
{file.version} {file.version}
@ -269,20 +271,20 @@ export function BoardView() {
<div className="flex items-center gap-2 mb-3"> <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)' }}> <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 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> </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>
<div className="flex items-center justify-end gap-1 mb-2"> <div className="flex items-center justify-end gap-1 mb-2">
<button onClick={(e) => { <button onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setEditingManualId(file.id) setEditingManualId(file.manualSn)
setUploadForm({ setUploadForm({
category: file.category, category: file.catgNm,
title: file.title, title: file.title,
version: file.version, version: file.version || '',
fileName: `${file.title}.${file.fileType.toLowerCase()}`, fileName: `${file.title}.${(file.fileTp || 'pdf').toLowerCase()}`,
fileSize: file.fileSize, fileSize: file.fileSz || '',
}) })
setShowUploadModal(true) setShowUploadModal(true)
}} }}
@ -291,10 +293,15 @@ export function BoardView() {
title="수정"> title="수정">
</button> </button>
<button onClick={(e) => { <button onClick={async (e) => {
e.stopPropagation() e.stopPropagation()
if (window.confirm(`"${file.title}" 매뉴얼을 삭제하시겠습니까?`)) { 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" className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
@ -305,26 +312,29 @@ export function BoardView() {
</div> </div>
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}> <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)' }}> <div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
<span>{file.author}</span> <span>{file.authorNm}</span>
<span>{file.uploadDate}</span> <span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}> <span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
{file.downloads} {file.dwnldCnt}
</span> </span>
<button onClick={(e) => { <button onClick={async (e) => {
e.stopPropagation() 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 = [ const content = [
`═══════════════════════════════════════════`, `═══════════════════════════════════════════`,
` ${file.title}`, ` ${file.title}`,
`═══════════════════════════════════════════`, `═══════════════════════════════════════════`,
``, ``,
` 카테고리: ${file.category}`, ` 카테고리: ${file.catgNm}`,
` 버전: ${file.version}`, ` 버전: ${file.version}`,
` 작성자: ${file.author}`, ` 작성자: ${file.authorNm}`,
` 등록일: ${file.uploadDate}`, ` 등록일: ${new Date(file.regDtm).toLocaleDateString('ko-KR')}`,
` 파일크기: ${file.fileSize}`, ` 파일크기: ${file.fileSz}`,
``, ``,
`───────────────────────────────────────────`, `───────────────────────────────────────────`,
` 본 문서는 해양경찰청 WING 시스템에서`, ` 본 문서는 해양경찰청 WING 시스템에서`,
@ -353,8 +363,9 @@ export function BoardView() {
) )
})} })}
</div> </div>
)}
{filteredManuals.length === 0 && ( {!manualLoading && filteredManuals.length === 0 && (
<div className="text-center py-20"> <div className="text-center py-20">
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div> <div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
<p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}> .</p> <p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}> .</p>
@ -422,10 +433,10 @@ export function BoardView() {
input.type = 'file' input.type = 'file'
input.accept = '.pdf,.doc,.docx,.hwp,.xlsx' input.accept = '.pdf,.doc,.docx,.hwp,.xlsx'
input.onchange = (ev) => { input.onchange = (ev) => {
const file = (ev.target as HTMLInputElement).files?.[0] const f = (ev.target as HTMLInputElement).files?.[0]
if (file) { if (f) {
const sizeMB = (file.size / (1024 * 1024)).toFixed(1) const sizeMB = (f.size / (1024 * 1024)).toFixed(1)
setUploadForm(prev => ({ ...prev, fileName: file.name, fileSize: `${sizeMB} MB` })) setUploadForm(prev => ({ ...prev, fileName: f.name, fileSize: `${sizeMB} MB` }))
} }
} }
input.click() 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' }}> 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>
<button onClick={() => { <button onClick={async () => {
if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return } if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return }
if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return } if (!editingManualId && !uploadForm.fileName) { alert('파일을 선택하세요.'); return }
const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' const ext = uploadForm.fileName ? uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' : undefined
if (editingManualId) { try {
setManualList(prev => prev.map(f => f.id === editingManualId ? { if (editingManualId) {
...f, await updateManual(editingManualId, {
category: uploadForm.category, catgNm: uploadForm.category,
title: uploadForm.title, title: uploadForm.title,
version: uploadForm.version || f.version, version: uploadForm.version || undefined,
fileType: ext, fileTp: ext,
fileSize: uploadForm.fileSize || f.fileSize, fileSz: uploadForm.fileSize || undefined,
uploadDate: new Date().toISOString().split('T')[0], })
} : f)) } else {
} else { await createManual({
const newFile: ManualFile = { catgNm: uploadForm.category,
id: Math.max(...manualList.map(f => f.id)) + 1, title: uploadForm.title,
category: uploadForm.category, version: uploadForm.version || 'v1.0',
title: uploadForm.title, fileTp: ext,
version: uploadForm.version || 'v1.0', fileSz: uploadForm.fileSize,
fileType: ext, })
fileSize: uploadForm.fileSize,
uploadDate: new Date().toISOString().split('T')[0],
author: '현재 사용자',
downloads: 0,
} }
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' }}> 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 ? '✏️ 수정' : '📤 업로드'} {editingManualId ? '✏️ 수정' : '📤 업로드'}

파일 보기

@ -73,3 +73,64 @@ export async function updateBoardPost(sn: number, input: UpdateBoardPostInput):
export async function deleteBoardPost(sn: number): Promise<void> { export async function deleteBoardPost(sn: number): Promise<void> {
await api.delete(`/board/${sn}`); 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 { interface HNSAnalysisListTableProps {
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>> 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) { export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps) {
const mockData = [ const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
{ const [loading, setLoading] = useState(true)
id: 1,
name: '울산 온산항 톨루엔 누출', const loadData = useCallback(async () => {
substance: '톨루엔 (Toluene)', setLoading(true)
substanceTag: '톨루엔', try {
datetime: '2025-02-11 14:15', const items = await fetchHnsAnalyses()
dateOnly: '2025-02-11', setAnalyses(items)
location: '부산항 신항', } catch (err) {
amount: '12.0 kL', console.error('[hns] 분석 목록 조회 실패:', err)
algorithm: 'ALOHA', } finally {
predictionTime: '24H', setLoading(false)
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: '제주팀, 일대팀'
} }
] }, [])
useEffect(() => {
loadData()
}, [loadData])
return ( return (
<div style={{ <div style={{
@ -189,7 +91,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
borderRadius: '12px', borderRadius: '12px',
fontFamily: 'var(--fM)' fontFamily: 'var(--fM)'
}}> }}>
{mockData.length} {analyses.length}
</span> </span>
</div> </div>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
@ -231,6 +133,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
{/* Table */} {/* Table */}
<div style={{ flex: 1, overflow: 'auto' }}> <div style={{ flex: 1, overflow: 'auto' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}> ...</div>
) : (
<table style={{ <table style={{
width: '100%', width: '100%',
borderCollapse: 'collapse', borderCollapse: 'collapse',
@ -274,9 +179,19 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
</tr> </tr>
</thead> </thead>
<tbody> <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 <tr
key={item.id} key={item.hnsAnlysSn}
style={{ style={{
borderBottom: '1px solid var(--bd)', borderBottom: '1px solid var(--bd)',
cursor: 'pointer', cursor: 'pointer',
@ -286,8 +201,8 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'} onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'}
onMouseLeave={(e) => e.currentTarget.style.background = index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'} 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', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{item.hnsAnlysSn}</td>
<td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.name}</td> <td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.anlysNm}</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{ <span style={{
padding: '4px 8px', padding: '4px 8px',
@ -297,13 +212,13 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
background: 'rgba(249,115,22,0.12)', background: 'rgba(249,115,22,0.12)',
color: 'var(--orange)' color: 'var(--orange)'
}}> }}>
{item.substanceTag} {substanceTag(item.sbstNm)}
</span> </span>
</td> </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(--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' }}>{item.dateOnly}</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.location}</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)' }}>{item.amount}</td> <td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{amount}</td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
<span style={{ <span style={{
padding: '4px 8px', padding: '4px 8px',
@ -313,18 +228,18 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
background: 'rgba(6,182,212,0.12)', background: 'rgba(6,182,212,0.12)',
color: 'var(--cyan)' color: 'var(--cyan)'
}}> }}>
{item.algorithm} {item.algoCd || '—'}
</span> </span>
</td> </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' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
<div style={{ <div style={{
width: '24px', width: '24px',
height: '24px', height: '24px',
borderRadius: '50%', 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', margin: '0 auto',
border: item.aegl3 ? 'none' : '1px solid var(--bd)' border: aegl3 ? 'none' : '1px solid var(--bd)'
}} /> }} />
</td> </td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -332,9 +247,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
width: '24px', width: '24px',
height: '24px', height: '24px',
borderRadius: '50%', 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', margin: '0 auto',
border: item.aegl2 ? 'none' : '1px solid var(--bd)' border: aegl2 ? 'none' : '1px solid var(--bd)'
}} /> }} />
</td> </td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -342,9 +257,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
width: '24px', width: '24px',
height: '24px', height: '24px',
borderRadius: '50%', 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', margin: '0 auto',
border: item.aegl1 ? 'none' : '1px solid var(--bd)' border: aegl1 ? 'none' : '1px solid var(--bd)'
}} /> }} />
</td> </td>
<td style={{ padding: '12px 16px', textAlign: 'center' }}> <td style={{ padding: '12px 16px', textAlign: 'center' }}>
@ -353,26 +268,24 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
borderRadius: '4px', borderRadius: '4px',
fontSize: '9px', fontSize: '9px',
fontWeight: 600, fontWeight: 600,
background: background: riskStyle.bg,
item.riskLevel === '위험' ? 'rgba(239,68,68,0.15)' : color: riskStyle.color,
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)'
}}> }}>
{item.riskLevel} {riskLabel}
</span> </span>
</td> </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(--t2)', fontFamily: 'var(--fM)' }}>{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(--t3)', fontSize: '10px' }}>{item.analystNm || '—'}</td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
)}
{!loading && analyses.length === 0 && (
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}> .</div>
)}
</div> </div>
</div> </div>
) )

파일 보기

@ -1,4 +1,5 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { createHnsAnalysis } from '../services/hnsApi'
interface HNSRecalcModalProps { interface HNSRecalcModalProps {
isOpen: boolean isOpen: boolean
@ -44,15 +45,33 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
return () => document.removeEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler)
}, [isOpen, onClose]) }, [isOpen, onClose])
const handleRun = () => { const handleRun = async () => {
setPhase('running') 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') setPhase('done')
setTimeout(() => { setTimeout(() => {
onSubmit() onSubmit()
onClose() onClose()
}, 1000) }, 800)
}, 2500) } catch (err) {
console.error('[hns] 재계산 실패:', err)
setPhase('editing')
alert('재계산 실행에 실패했습니다.')
}
} }
if (!isOpen) return null if (!isOpen) return null

파일 보기

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

파일 보기

@ -8,6 +8,7 @@ import { HNSSubstanceView } from './HNSSubstanceView'
import { HNSScenarioView } from './HNSScenarioView' import { HNSScenarioView } from './HNSScenarioView'
import { HNSRecalcModal } from './HNSRecalcModal' import { HNSRecalcModal } from './HNSRecalcModal'
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
import { createHnsAnalysis } from '../services/hnsApi'
/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */ /* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */
function HNSManualViewer() { function HNSManualViewer() {
@ -268,15 +269,17 @@ export function HNSView() {
setIsRunningPrediction(true) setIsRunningPrediction(true)
try { try {
console.log('대기확산 예측 실행 요청:', { const { hnsAnlysSn } = await createHnsAnalysis({
location: incidentCoord 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 windAngle = 225
const result = { const result = {
hnsAnlysSn,
zones: [ zones: [
{ level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: 500, angle: windAngle }, { 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 }, { level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: 1000, angle: windAngle },
@ -289,7 +292,6 @@ export function HNSView() {
} }
setDispersionResult(result) setDispersionResult(result)
console.log('대기확산 예측 완료:', result)
} catch (error) { } catch (error) {
console.error('대기확산 예측 오류:', error) console.error('대기확산 예측 오류:', error)
alert('대기확산 예측 중 오류가 발생했습니다.') 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 { export type Analysis = PredictionAnalysis
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',
},
]
interface AnalysisListTableProps { interface AnalysisListTableProps {
onTabChange: (tab: string) => void onTabChange: (tab: string) => void
@ -200,11 +10,28 @@ interface AnalysisListTableProps {
} }
export function AnalysisListTable({ onTabChange, onSelectAnalysis }: 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 [searchTerm, setSearchTerm] = useState('')
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10 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) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'completed': case 'completed':
@ -317,32 +144,33 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
{/* 테이블 */} {/* 테이블 */}
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{loading ? (
<div className="text-center py-20 text-text-3 text-sm"> ...</div>
) : (
<table className="w-full"> <table className="w-full">
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10"> <thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
<tr> <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-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider"></th> <th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-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">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">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">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-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> <th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{currentAnalyses.map((analysis) => ( {currentAnalyses.map((analysis) => (
<tr <tr
key={analysis.id} key={analysis.acdntSn}
className="hover:bg-bg-2 transition-colors cursor-pointer group" className="hover:bg-bg-2 transition-colors cursor-pointer group"
> >
<td className="px-4 py-3 text-sm text-text-2 font-mono">{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"> <td className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" /> <span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
@ -355,30 +183,31 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
} }
}} }}
> >
{analysis.name} {analysis.acdntNm}
</span> </span>
</div> </div>
</td> </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 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">{analysis.analysisDate}</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 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-2">{analysis.oilType}</td>
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold"> <td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
{analysis.volume.toFixed(2)} {analysis.volume != null ? 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" />
</td> </td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</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.poseidonStatus)}</td>
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.opendriftStatus)}</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-center">{getStatusBadge(analysis.backtrackStatus)}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.requestor}</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.analyst}</td>
<td className="px-4 py-3 text-sm text-text-2">{analysis.officeName}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
)}
{!loading && analyses.length === 0 && (
<div className="text-center py-20 text-text-3 text-sm"> .</div>
)}
</div> </div>
{/* 페이지네이션 */} {/* 페이지네이션 */}

파일 보기

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { LeftPanel } from './LeftPanel' import { LeftPanel } from './LeftPanel'
import { RightPanel } from './RightPanel' import { RightPanel } from './RightPanel'
import { MapView } from '@common/components/map/MapView' import { MapView } from '@common/components/map/MapView'
@ -10,9 +10,11 @@ import { RecalcModal } from './RecalcModal'
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar' import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu' import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine' 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 { 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'
import { api } from '@common/services/api'
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift' export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
// eslint-disable-next-line react-refresh/only-export-components // eslint-disable-next-line react-refresh/only-export-components
@ -63,6 +65,16 @@ export function OilSpillView() {
// 선택된 분석 (목록에서 클릭 시) // 선택된 분석 (목록에서 클릭 시)
const [selectedAnalysis, setSelectedAnalysis] = useState<Analysis | null>(null) 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) const [recalcModalOpen, setRecalcModalOpen] = useState(false)
@ -79,19 +91,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 = () => { const handleOpenBacktrack = () => {
setBacktrackModalOpen(true) setBacktrackModalOpen(true)
setBacktrackPhase('conditions') setBacktrackConditions(prev => ({
setBacktrackVessels([]) ...prev,
spillLocation: incidentCoord,
}))
if (selectedAnalysis) {
loadBacktrackData(selectedAnalysis.acdntSn)
} else {
setBacktrackPhase('conditions')
setBacktrackVessels([])
}
} }
const handleRunBacktrackAnalysis = () => { const handleRunBacktrackAnalysis = async () => {
setBacktrackPhase('analyzing') setBacktrackPhase('analyzing')
setTimeout(() => { try {
setBacktrackVessels(MOCK_VESSELS) if (selectedAnalysis) {
setBacktrackPhase('results') const { backtrackSn } = await createBacktrack({
}, 2000) 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 = () => { const handleStartReplay = () => {
@ -128,15 +214,17 @@ export function OilSpillView() {
}, [isReplayPlaying, replayFrame, replaySpeed]) }, [isReplayPlaying, replayFrame, replaySpeed])
// 분석 목록에서 사고명 클릭 시 // 분석 목록에서 사고명 클릭 시
const handleSelectAnalysis = (analysis: Analysis) => { const handleSelectAnalysis = async (analysis: Analysis) => {
setSelectedAnalysis(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> = { const oilTypeMap: Record<string, string> = {
'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유', 'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유',
} }
setOilType(oilTypeMap[analysis.oilType] || '벙커C유') setOilType(oilTypeMap[analysis.oilType] || '벙커C유')
setSpillAmount(analysis.volume) setSpillAmount(analysis.volume ?? 100)
setPredictionTime(parseInt(analysis.duration) || 48) setPredictionTime(parseInt(analysis.duration) || 48)
// 모델 상태에 따라 선택 모델 설정 // 모델 상태에 따라 선택 모델 설정
const models = new Set<PredictionModel>() const models = new Set<PredictionModel>()
@ -144,6 +232,13 @@ export function OilSpillView() {
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON') if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift') if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
setSelectedModels(models) setSelectedModels(models)
// 분석 상세 로딩 (선박/기상 정보)
try {
const detail = await fetchPredictionDetail(analysis.acdntSn)
setAnalysisDetail(detail)
} catch (err) {
console.error('[prediction] 분석 상세 로딩 실패:', err)
}
// 분석 화면으로 전환 // 분석 화면으로 전환
setActiveSubTab('analysis') setActiveSubTab('analysis')
} }
@ -165,27 +260,16 @@ export function OilSpillView() {
const models = Array.from(selectedModels) const models = Array.from(selectedModels)
const results = await Promise.all( const results = await Promise.all(
models.map(async (model) => { models.map(async (model) => {
const response = await fetch('http://localhost:3001/api/simulation/run', { const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', {
method: 'POST', model,
headers: { 'Content-Type': 'application/json' }, lat: incidentCoord.lat,
body: JSON.stringify({ lon: incidentCoord.lon,
model, duration_hours: predictionTime,
lat: incidentCoord.lat, oil_type: oilType,
lon: incidentCoord.lon, spill_amount: spillAmount,
duration_hours: predictionTime, spill_type: spillType,
oil_type: oilType,
spill_amount: spillAmount,
spill_type: spillType
})
}) })
return data.trajectory.map(p => ({ ...p, model }))
if (!response.ok) {
throw new Error(`API 오류 (${model}): ${response.status}`)
}
const data = await response.json()
return (data.trajectory as Array<{ lat: number; lon: number; time: number; particle?: number }>)
.map(p => ({ ...p, model }))
}) })
) )
@ -261,10 +345,10 @@ export function OilSpillView() {
drawingPoints={drawingPoints} drawingPoints={drawingPoints}
layerOpacity={layerOpacity} layerOpacity={layerOpacity}
layerBrightness={layerBrightness} layerBrightness={layerBrightness}
backtrackReplay={isReplayActive ? { backtrackReplay={isReplayActive && replayShips.length > 0 ? {
isActive: true, isActive: true,
ships: MOCK_REPLAY_SHIPS, ships: replayShips,
collisionEvent: MOCK_COLLISION, collisionEvent: collisionEvent || undefined,
replayFrame, replayFrame,
totalFrames: TOTAL_REPLAY_FRAMES, totalFrames: TOTAL_REPLAY_FRAMES,
incidentCoord, incidentCoord,
@ -427,8 +511,8 @@ export function OilSpillView() {
onSeek={setReplayFrame} onSeek={setReplayFrame}
onSpeedChange={setReplaySpeed} onSpeedChange={setReplaySpeed}
onClose={handleCloseReplay} onClose={handleCloseReplay}
replayShips={MOCK_REPLAY_SHIPS} replayShips={replayShips}
collisionEvent={MOCK_COLLISION} collisionEvent={collisionEvent || undefined}
/> />
)} )}
</> </>
@ -436,7 +520,7 @@ export function OilSpillView() {
</div> </div>
{/* Right Panel */} {/* 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 <RecalcModal
@ -464,7 +548,7 @@ export function OilSpillView() {
isOpen={backtrackModalOpen} isOpen={backtrackModalOpen}
onClose={() => setBacktrackModalOpen(false)} onClose={() => setBacktrackModalOpen(false)}
phase={backtrackPhase} phase={backtrackPhase}
conditions={MOCK_CONDITIONS} conditions={backtrackConditions}
vessels={backtrackVessels} vessels={backtrackVessels}
onRunAnalysis={handleRunBacktrackAnalysis} onRunAnalysis={handleRunBacktrackAnalysis}
onStartReplay={handleStartReplay} onStartReplay={handleStartReplay}

파일 보기

@ -1,6 +1,11 @@
import { useState } from 'react' 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 [shipExpanded, setShipExpanded] = useState(false)
const [insuranceExpanded, setInsuranceExpanded] = useState(false) const [insuranceExpanded, setInsuranceExpanded] = useState(false)
@ -38,7 +43,7 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
{/* 오염 종합 상황 */} {/* 오염 종합 상황 */}
<Section title="오염 종합 상황" badge="위험" badgeColor="red"> <Section title="오염 종합 상황" badge="위험" badgeColor="red">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px', fontSize: '9px' }}> <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="0.43" unit="kl" color="var(--orange)" />
<StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" /> <StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" />
<StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" /> <StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" />
@ -98,35 +103,37 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
fontSize: '15px' fontSize: '15px'
}}>🚢</div> }}>🚢</div>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div className="text-[11px] font-bold text-text-1 font-korean">ORIENTAL GLORY</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 9412856 · MMSI 440123456 · </div> <div className="text-[8px] text-text-3 font-mono">IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}</div>
</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> <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>
{/* 제원 */} {/* 제원 */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px' }}>
<SpecCard value="174.0" label="전장 LOA(m)" color="var(--purple)" /> <SpecCard value={vessel?.loaM?.toFixed(1) || '—'} label="전장 LOA(m)" color="var(--purple)" />
<SpecCard value="32.2" label="형폭 B(m)" color="var(--cyan)" /> <SpecCard value={vessel?.breadthM?.toFixed(1) || '—'} label="형폭 B(m)" color="var(--cyan)" />
<SpecCard value="11.2" label="흘수 d(m)" color="var(--green)" /> <SpecCard value={vessel?.draftM?.toFixed(1) || '—'} label="흘수 d(m)" color="var(--green)" />
</div> </div>
<div className="space-y-0.5 text-[9px] font-korean"> <div className="space-y-0.5 text-[9px] font-korean">
<InfoRow label="총톤수(GT)" value="38,642톤" /> <InfoRow label="총톤수(GT)" value={vessel?.gt ? `${vessel.gt.toLocaleString()}` : '—'} />
<InfoRow label="재화중량(DWT)" value="72,850톤" /> <InfoRow label="재화중량(DWT)" value={vessel?.dwt ? `${vessel.dwt.toLocaleString()}` : '—'} />
<InfoRow label="건조" value="2008 현대미포" /> <InfoRow label="건조" value={vessel?.builtYr ? `${vessel.builtYr}` : '—'} />
<InfoRow label="주기관" value="MAN B&W 9,480kW" mono /> <InfoRow label="주기관" value={vessel?.engineDc || '—'} mono />
<InfoRow label="선적/선급" value="🇰🇷 대한민국 · KR" /> <InfoRow label="선적" value={vessel?.flagCd || '—'} />
<InfoRow label="호출부호" value="HLBK" mono /> <InfoRow label="호출부호" value={vessel?.callsign || '—'} mono />
</div> </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="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"> <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> </div>
)}
</div> </div>
</CollapsibleSection> </CollapsibleSection>
@ -137,48 +144,30 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
onToggle={() => setInsuranceExpanded(!insuranceExpanded)} onToggle={() => setInsuranceExpanded(!insuranceExpanded)}
> >
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-0.5 text-[9px] font-korean mb-2"> {insurance && insurance.length > 0 ? (
<InfoRow label="선주" value="대한해운(주)" /> <>
<InfoRow label="운항사" value="대한해운(주)" /> {insurance.filter(ins => ins.type === 'P&I').map((ins, i) => (
<InfoRow label="P&I" value="한국선주상호보험" /> <InsuranceCard key={`pi-${i}`} title="🚢 P&I" color="cyan" items={[
</div> { label: '보험사', value: ins.insurer },
{ label: '한도', value: `${ins.currency} ${ins.value}`, mono: true },
{/* 선체보험 */} ]} />
<InsuranceCard ))}
title="🚢 선체보험 (H&M)" {insurance.filter(ins => ins.type === 'H&M').map((ins, i) => (
color="cyan" <InsuranceCard key={`hm-${i}`} title="🚢 선체보험 (H&M)" color="cyan" items={[
items={[ { label: '보험사', value: ins.insurer },
{ label: '보험사', value: '삼성화재해상보험' }, { label: '보험가액', value: `${ins.currency} ${ins.value}`, mono: true },
{ label: '보험가액', value: 'USD 28,500,000', mono: true }, ]} />
{ label: '보험기간', value: '2025.01~2026.01', valueColor: 'var(--green)' }, ))}
{ label: '면책금', value: 'USD 150,000', 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 },
{/* 화물보험 */} ]} />
<InsuranceCard ))}
title="📦 화물보험 (Cargo)" </>
color="purple" ) : (
items={[ <div className="text-[9px] text-text-3 font-korean text-center py-4"> .</div>
{ 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: '한국선주상호보험' }
]}
/>
</div> </div>
</CollapsibleSection> </CollapsibleSection>
</div> </div>

파일 보기

@ -0,0 +1,117 @@
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<{
obsDtm: string;
locNm: string;
temp: string;
weatherDc: string;
wind: string;
wave: string;
humid: string;
vis: string;
sst: string;
}>;
}
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 ─── */ /* ─── Types ─── */
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED' 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' }, 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' } const SEV_COLOR: Record<Severity, string> = { CRITICAL: '#f87171', HIGH: '#fb923c', MEDIUM: '#fbbf24', RESOLVED: '#22c55e' }
/* ─── Color helpers ─── */ /* ─── 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 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)' } 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 RescueScenarioView
*/ */
export function 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 [selectedIncident, setSelectedIncident] = useState(0)
const [scenarios] = useState<RescueScenario[]>(MOCK_SCENARIOS) const [checked, setChecked] = useState<Set<string>>(new Set())
const [checked, setChecked] = useState<Set<string>>(new Set(['S-01', 'S-02', 'S-03', 'S-04', 'S-05'])) const [selectedId, setSelectedId] = useState('')
const [selectedId, setSelectedId] = useState('S-01')
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time') const [sortBy, setSortBy] = useState<'time' | 'risk'>('time')
const [detailView, setDetailView] = useState<DetailView>(0) const [detailView, setDetailView] = useState<DetailView>(0)
const [newScnModalOpen, setNewScnModalOpen] = useState(false) 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) => { const sorted = [...scenarios].sort((a, b) => {
if (sortBy === 'risk') { if (sortBy === 'risk') {
@ -225,7 +181,7 @@ export function RescueScenarioView() {
</div> </div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}> <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' }}> <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> </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> <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> </div>
@ -248,6 +204,9 @@ export function RescueScenarioView() {
{/* Card list */} {/* Card list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}> <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 => { {sorted.map(sc => {
const isSel = selectedId === sc.id const isSel = selectedId === sc.id
const sev = SEV_STYLE[sc.severity] const sev = SEV_STYLE[sc.severity]
@ -383,7 +342,7 @@ export function RescueScenarioView() {
)} )}
{/* ─── VIEW 1: 비교 차트 ─── */} {/* ─── VIEW 1: 비교 차트 ─── */}
{detailView === 1 && <ScenarioComparison />} {detailView === 1 && <ScenarioComparison chartData={chartData} />}
{/* ─── VIEW 2: 지도 오버레이 ─── */} {/* ─── VIEW 2: 지도 오버레이 ─── */}
{detailView === 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: 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={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.6, marginBottom: 16 }}> .</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}> <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)' }}> <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={{ fontWeight: 700, color: SEV_STYLE[sc.severity].color }}>{sc.id}</span>
<span style={{ color: 'var(--t2)', marginLeft: 6 }}>{sc.name}</span> <span style={{ color: 'var(--t2)', marginLeft: 6 }}>{sc.name}</span>
@ -411,13 +370,13 @@ export function RescueScenarioView() {
</div> </div>
{/* ═══ 신규 시나리오 모달 ═══ */} {/* ═══ 신규 시나리오 모달 ═══ */}
{newScnModalOpen && <NewScenarioModal onClose={() => setNewScnModalOpen(false)} />} {newScnModalOpen && <NewScenarioModal ops={ops} onClose={() => setNewScnModalOpen(false)} />}
</div> </div>
) )
} }
/* ═══ 신규 시나리오 생성 모달 ═══ */ /* ═══ 신규 시나리오 생성 모달 ═══ */
function NewScenarioModal({ onClose }: { onClose: () => void }) { function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
const overlayRef = useRef<HTMLDivElement>(null) const overlayRef = useRef<HTMLDivElement>(null)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [done, setDone] = useState(false) const [done, setDone] = useState(false)
@ -471,7 +430,7 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) {
<div> <div>
<label style={labelSt}> <span style={{ color: '#f87171' }}>*</span></label> <label style={labelSt}> <span style={{ color: '#f87171' }}>*</span></label>
<select defaultValue="0" style={selSt}> <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> <option value="new">+ ...</option>
</select> </select>
</div> </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 W = 480, H = 180, PX = 50, PY = 20
const pw = W - PX * 2, ph = H - PY * 2 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 ( return (
<div style={{ padding: 20 }}> <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" /> <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> <text x={W - PX + 4} y={PY + ph - (1.0 / 2.0) * ph + 3} fill="var(--red)" fontSize={7}>GM=1.0 </text>
{/* Area */} {/* 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 */} {/* 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} /> <polyline points={chartData.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) => ( {chartData.map((d, i) => (
<g key={d.id}> <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} /> <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> <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> 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" /> <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} /> <polyline points={chartData.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) => ( {chartData.map((d, i) => (
<g key={d.id}> <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} /> <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> <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 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> 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 barW = xStep * 0.5
const barH = (d.oil / 200) * ph const barH = (d.oil / 200) * ph
return ( return (
@ -809,23 +776,23 @@ function ScenarioComparison() {
<thead> <thead>
<tr style={{ background: 'rgba(6,182,212,.06)' }}> <tr style={{ background: 'rgba(6,182,212,.06)' }}>
<th style={{ padding: '7px 8px', textAlign: 'left', borderBottom: '2px solid var(--bdL)', color: 'var(--cyan)' }}></th> <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> <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> </tr>
</thead> </thead>
<tbody> <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: 'GM (m)', key: 'gm', fmt: (d: ChartDataItem) => d.gm.toFixed(1), clr: (d: ChartDataItem) => 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: 'list', fmt: (d: ChartDataItem) => `${d.list}°`, clr: (d: ChartDataItem) => listColor(d.list) },
{ label: '잔존부력 (%)', key: 'buoy', fmt: (d: typeof CHART_DATA[0]) => `${d.buoy}%`, clr: (d: typeof CHART_DATA[0]) => buoyColor(d.buoy) }, { label: '잔존부력 (%)', key: 'buoy', fmt: (d: ChartDataItem) => `${d.buoy}%`, clr: (d: ChartDataItem) => 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: '유출률 (L/min)', key: 'oil', fmt: (d: ChartDataItem) => `${d.oil}`, clr: (d: ChartDataItem) => 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: '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: typeof CHART_DATA[0]) => d.severity, clr: (d: typeof CHART_DATA[0]) => SEV_COLOR[d.severity] }, { label: '위험 등급', key: 'sev', fmt: (d: ChartDataItem) => d.severity, clr: (d: ChartDataItem) => SEV_COLOR[d.severity] },
].map(row => ( ].map(row => (
<tr key={row.label} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}> <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> <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> <td key={d.id} style={{ padding: '6px 8px', textAlign: 'center', fontFamily: 'var(--fM)', fontWeight: 700, color: row.clr(d) }}>{row.fmt(d)}</td>
))} ))}
</tr> </tr>

파일 보기

@ -1,7 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect, useCallback } from 'react'
import { useSubMenu } from '@common/hooks/useSubMenu' import { useSubMenu } from '@common/hooks/useSubMenu'
import { RescueTheoryView } from './RescueTheoryView' import { RescueTheoryView } from './RescueTheoryView'
import { RescueScenarioView } from './RescueScenarioView' import { RescueScenarioView } from './RescueScenarioView'
import { fetchRescueOps } from '../services/rescueApi'
import type { RescueOpsItem } from '../services/rescueApi'
/* ─── Types ─── */ /* ─── Types ─── */
type AccidentType = 'collision' | 'grounding' | 'turning' | 'capsizing' | 'sharpTurn' | 'flooding' | 'sinking' type AccidentType = 'collision' | 'grounding' | 'turning' | 'capsizing' | 'sharpTurn' | 'flooding' | 'sinking'
@ -812,26 +814,66 @@ function MetricCard({ label, value, unit, color, sub, subColor }: {
/* ─── 긴급구난 목록 탭 ─── */ /* ─── 긴급구난 목록 탭 ─── */
function RescueListView() { function RescueListView() {
const listData = [ const [opsList, setOpsList] = useState<RescueOpsItem[]>([])
{ 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' }, const [loading, setLoading] = useState(true)
{ 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' }, const [searchTerm, setSearchTerm] = useState('')
{ 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' }, const loadOps = useCallback(async () => {
{ 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' }, 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 ( return (
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
<div className="px-5 py-4 flex items-center justify-between border-b border-border"> <div className="px-5 py-4 flex items-center justify-between border-b border-border">
<span className="text-sm font-bold font-korean"> </span> <span className="text-sm font-bold font-korean"> </span>
<div className="flex gap-2 items-center"> <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 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> </button>
</div> </div>
</div> </div>
<div className="flex-1 overflow-y-auto px-5 pb-4"> <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"> <table className="w-full border-collapse text-[11px] mt-3">
<thead> <thead>
<tr className="bg-bg-3 border-b border-border"> <tr className="bg-bg-3 border-b border-border">
@ -841,23 +883,27 @@ function RescueListView() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{listData.map((r, i) => ( {opsList.map((r) => {
<tr key={i} className="border-b border-border hover:bg-bg-hover cursor-pointer"> const status = getStatusLabel(r.sttsCd)
<td className="py-2 px-2.5"> return (
<span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{ <tr key={r.rescueOpsSn} className="border-b border-border hover:bg-bg-hover cursor-pointer">
background: `color-mix(in srgb, ${r.statusColor} 15%, transparent)`, color: r.statusColor <td className="py-2 px-2.5">
}}>{r.status}</span> <span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{
</td> background: `color-mix(in srgb, ${status.color} 15%, transparent)`, color: status.color
<td className="py-2 px-2.5 font-mono text-[var(--cyan)] font-semibold">{r.no}</td> }}>{status.label}</span>
<td className="py-2 px-2.5 font-korean text-text-1 font-semibold">{r.vessel}</td> </td>
<td className="py-2 px-2.5 font-korean">{r.type}</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-mono text-text-3">{r.date}</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-mono text-text-3 text-[10px]">{r.location}</td> <td className="py-2 px-2.5 font-korean">{getTypeLabel(r.acdntTpCd)}</td>
<td className="py-2 px-2.5 font-mono">{r.crew}</td> <td className="py-2 px-2.5 font-mono text-text-3">{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}</td>
</tr> <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> </tbody>
</table> </table>
)}
</div> </div>
</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;
}

파일 보기

@ -1,56 +1,120 @@
import { useState, useCallback } from 'react' import { useState, useCallback, useEffect } from 'react';
import type { ScatSegment, ScatDetail } from './scatTypes' import type { ScatSegment, ScatDetail } from './scatTypes';
import { allSegments, scatDetailData } from './scatConstants' import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
import ScatLeftPanel from './ScatLeftPanel' import type { ApiZoneItem } from '../services/scatApi';
import ScatMap from './ScatMap' import ScatLeftPanel from './ScatLeftPanel';
import ScatTimeline from './ScatTimeline' import ScatMap from './ScatMap';
import ScatPopup from './ScatPopup' import ScatTimeline from './ScatTimeline';
import ScatPopup from './ScatPopup';
// ═══ Main PreScatView ═══ // ═══ Main PreScatView ═══
export function PreScatView() { export function PreScatView() {
const [selectedSeg, setSelectedSeg] = useState<ScatSegment>(allSegments[0]) const [segments, setSegments] = useState<ScatSegment[]>([]);
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)') const [zones, setZones] = useState<ApiZoneItem[]>([]);
const [areaFilter, setAreaFilter] = useState('전체') const [loading, setLoading] = useState(true);
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)') const [error, setError] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState('전체') const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
const [searchTerm, setSearchTerm] = useState('') const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
const [popupData, setPopupData] = useState<ScatDetail | null>(null) const [areaFilter, setAreaFilter] = useState('전체');
const [timelineIdx, setTimelineIdx] = useState(6) const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
const [statusFilter, setStatusFilter] = useState('전체');
const [searchTerm, setSearchTerm] = useState('');
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
const [timelineIdx, setTimelineIdx] = useState(6);
// API에서 구역 및 구간 데이터 로딩
useEffect(() => {
let cancelled = false;
async function loadData() {
try {
setLoading(true);
const [zonesData, sectionsData] = await Promise.all([fetchZones(), fetchSections()]);
if (cancelled) return;
setZones(zonesData);
setSegments(sectionsData);
if (sectionsData.length > 0) {
setSelectedSeg(sectionsData[0]);
}
} catch (err) {
console.error('[SCAT] 데이터 로딩 오류:', err);
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
} finally {
if (!cancelled) setLoading(false);
}
}
loadData();
return () => {
cancelled = true;
};
}, []);
// 관할 기반 세그먼트 필터링 // 관할 기반 세그먼트 필터링
const segments = allSegments.filter(s => { const filteredSegments = segments.filter((s) => {
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포' if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주' if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
return true // 전체 return true; // 전체
}) });
const handleOpenPopup = useCallback((idx: number) => { const handleOpenPopup = useCallback(async (sn: number) => {
setPopupData(scatDetailData[idx] || scatDetailData[0]) try {
}, []) const detail = await fetchSectionDetail(sn);
setPopupData(detail);
} catch (err) {
console.error('[SCAT] 상세 데이터 로딩 오류:', err);
}
}, []);
const handleClosePopup = useCallback(() => { const handleClosePopup = useCallback(() => {
setPopupData(null) setPopupData(null);
}, []) }, []);
const handleTimelineSeek = useCallback((idx: number) => { const handleTimelineSeek = useCallback(
if (idx === -1) { (idx: number) => {
// advance signal from play if (idx === -1) {
setTimelineIdx(prev => { // advance signal from play
const next = (prev + 1) % Math.min(segments.length, 12) setTimelineIdx((prev) => {
if (segments[next]) setSelectedSeg(segments[next]) const next = (prev + 1) % Math.min(filteredSegments.length, 12);
return next if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
}) return next;
} else { });
setTimelineIdx(idx) } else {
if (segments[idx]) setSelectedSeg(segments[idx]) setTimelineIdx(idx);
} if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
}, [segments]) }
},
[filteredSegments],
);
if (error) {
return (
<div className="flex w-full h-full bg-bg-0 items-center justify-center flex-col gap-3">
<div className="text-status-red text-sm font-korean">{error}</div>
<button
onClick={() => { setError(null); setLoading(true); }}
className="px-4 py-1.5 bg-primary-cyan text-white text-xs rounded font-korean"
>
</button>
</div>
);
}
if (loading || !selectedSeg) {
return (
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
<div className="text-text-2 text-sm font-korean">SCAT ...</div>
</div>
);
}
return ( return (
<div className="flex w-full h-full bg-bg-0 overflow-hidden"> <div className="flex w-full h-full bg-bg-0 overflow-hidden">
<ScatLeftPanel <ScatLeftPanel
segments={segments} segments={filteredSegments}
zones={zones}
selectedSeg={selectedSeg} selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg} onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup} onOpenPopup={handleOpenPopup}
@ -68,13 +132,13 @@ export function PreScatView() {
<div className="flex-1 relative"> <div className="flex-1 relative">
<ScatMap <ScatMap
segments={segments} segments={filteredSegments}
selectedSeg={selectedSeg} selectedSeg={selectedSeg}
onSelectSeg={setSelectedSeg} onSelectSeg={setSelectedSeg}
onOpenPopup={handleOpenPopup} onOpenPopup={handleOpenPopup}
/> />
<ScatTimeline <ScatTimeline
segments={segments} segments={filteredSegments}
currentIdx={timelineIdx} currentIdx={timelineIdx}
onSeek={handleTimelineSeek} onSeek={handleTimelineSeek}
/> />
@ -84,5 +148,5 @@ export function PreScatView() {
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} /> <ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
)} )}
</div> </div>
) );
} }

파일 보기

@ -1,25 +1,28 @@
import type { ScatSegment } from './scatTypes' import type { ScatSegment } from './scatTypes';
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants' import type { ApiZoneItem } from '../services/scatApi';
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
interface ScatLeftPanelProps { interface ScatLeftPanelProps {
segments: ScatSegment[] segments: ScatSegment[];
selectedSeg: ScatSegment zones: ApiZoneItem[];
onSelectSeg: (s: ScatSegment) => void selectedSeg: ScatSegment;
onOpenPopup: (idx: number) => void onSelectSeg: (s: ScatSegment) => void;
jurisdictionFilter: string onOpenPopup: (sn: number) => void;
onJurisdictionChange: (v: string) => void jurisdictionFilter: string;
areaFilter: string onJurisdictionChange: (v: string) => void;
onAreaChange: (v: string) => void areaFilter: string;
phaseFilter: string onAreaChange: (v: string) => void;
onPhaseChange: (v: string) => void phaseFilter: string;
statusFilter: string onPhaseChange: (v: string) => void;
onStatusChange: (v: string) => void statusFilter: string;
searchTerm: string onStatusChange: (v: string) => void;
onSearchChange: (v: string) => void searchTerm: string;
onSearchChange: (v: string) => void;
} }
function ScatLeftPanel({ function ScatLeftPanel({
segments, segments,
zones,
selectedSeg, selectedSeg,
onSelectSeg, onSelectSeg,
onOpenPopup, onOpenPopup,
@ -34,12 +37,18 @@ function ScatLeftPanel({
searchTerm, searchTerm,
onSearchChange, onSearchChange,
}: ScatLeftPanelProps) { }: ScatLeftPanelProps) {
const filtered = segments.filter(s => { const filtered = segments.filter((s) => {
if (areaFilter !== '전체' && !s.area.includes(areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''))) return false if (
if (statusFilter !== '전체' && s.status !== statusFilter) return false areaFilter !== '전체' &&
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false !s.area.includes(
return true areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''),
}) )
)
return false;
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
return true;
});
return ( return (
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden"> <div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
@ -51,8 +60,14 @@ function ScatLeftPanel({
</div> </div>
<div className="mb-2.5"> <div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label> <label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
</label>
<select
value={jurisdictionFilter}
onChange={(e) => onJurisdictionChange(e.target.value)}
className="prd-i w-full"
>
<option> ()</option> <option> ()</option>
<option></option> <option></option>
<option></option> <option></option>
@ -60,18 +75,32 @@ function ScatLeftPanel({
</div> </div>
<div className="mb-2.5"> <div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label> <label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
</label>
<select
value={areaFilter}
onChange={(e) => onAreaChange(e.target.value)}
className="prd-i w-full"
>
<option></option> <option></option>
{scatAreas.map(a => ( {zones.map((z) => (
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} </option> <option key={z.zoneCd}>
{z.jrsdNm === '서귀포' ? '서귀포시' : '제주시'} {z.zoneNm}
</option>
))} ))}
</select> </select>
</div> </div>
<div className="mb-2.5"> <div className="mb-2.5">
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean"> </label> <label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
</label>
<select
value={phaseFilter}
onChange={(e) => onPhaseChange(e.target.value)}
className="prd-i w-full"
>
<option>Pre-SCAT ()</option> <option>Pre-SCAT ()</option>
<option>SCAT ( )</option> <option>SCAT ( )</option>
<option>Post-SCAT ( )</option> <option>Post-SCAT ( )</option>
@ -83,10 +112,14 @@ function ScatLeftPanel({
type="text" type="text"
placeholder="🔍 구간 검색..." placeholder="🔍 구간 검색..."
value={searchTerm} value={searchTerm}
onChange={e => onSearchChange(e.target.value)} onChange={(e) => onSearchChange(e.target.value)}
className="prd-i flex-1" className="prd-i flex-1"
/> />
<select value={statusFilter} onChange={e => onStatusChange(e.target.value)} className="prd-i w-[70px]"> <select
value={statusFilter}
onChange={(e) => onStatusChange(e.target.value)}
className="prd-i w-[70px]"
>
<option></option> <option></option>
<option></option> <option></option>
<option></option> <option></option>
@ -102,54 +135,83 @@ function ScatLeftPanel({
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" /> <span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
</span> </span>
<span className="text-primary-cyan font-mono text-[10px]"> {filtered.length} </span> <span className="text-primary-cyan font-mono text-[10px]">
{filtered.length}
</span>
</div> </div>
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5"> <div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
{filtered.map(seg => { {filtered.map((seg) => {
const lvl = esiLevel(seg.esiNum) const lvl = esiLevel(seg.esiNum);
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green' const borderColor =
const isSelected = selectedSeg.id === seg.id lvl === 'h'
? 'border-l-status-red'
: lvl === 'm'
? 'border-l-status-orange'
: 'border-l-status-green';
const isSelected = selectedSeg.id === seg.id;
return ( return (
<div <div
key={seg.id} key={seg.id}
onClick={() => { onSelectSeg(seg); onOpenPopup(seg.id % scatDetailData.length) }} onClick={() => {
onSelectSeg(seg);
onOpenPopup(seg.id);
}}
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${ className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
isSelected ? 'border-status-green bg-[rgba(34,197,94,0.05)]' : 'hover:border-border-light hover:bg-bg-hover' isSelected
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
: 'hover:border-border-light hover:bg-bg-hover'
}`} }`}
> >
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5"> <span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
📍 {seg.code} {seg.area} 📍 {seg.code} {seg.area}
</span> </span>
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white" style={{ background: esiColor(seg.esiNum) }}> <span
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
style={{ background: esiColor(seg.esiNum) }}
>
ESI {seg.esi} ESI {seg.esi}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 gap-x-3 gap-y-1"> <div className="grid grid-cols-2 gap-x-3 gap-y-1">
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span> <span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span> <span className="text-text-1 font-medium font-mono text-[11px]">
{seg.type}
</span>
</div> </div>
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span> <span className="text-text-2 font-korean"></span>
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span> <span className="text-text-1 font-medium font-mono text-[11px]">
{seg.length}
</span>
</div> </div>
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span> <span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>{seg.sensitivity}</span> <span
className="font-medium font-mono text-[11px]"
style={{ color: sensColor[seg.sensitivity] }}
>
{seg.sensitivity}
</span>
</div> </div>
<div className="flex justify-between text-[11px]"> <div className="flex justify-between text-[11px]">
<span className="text-text-2 font-korean"></span> <span className="text-text-2 font-korean"></span>
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>{seg.status}</span> <span
className="font-medium font-mono text-[11px]"
style={{ color: statusColor[seg.status] }}
>
{seg.status}
</span>
</div> </div>
</div> </div>
</div> </div>
) );
})} })}
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default ScatLeftPanel export default ScatLeftPanel;

파일 보기

@ -1,387 +1,104 @@
import type { ScatSegment, ScatDetail } from './scatTypes'
// ═══ ESI 색상 ═══ // ═══ ESI 색상 ═══
export const esiColor = (n: number): string => { export const esiColor = (n: number): string => {
if (n >= 10) return '#991b1b' if (n >= 10) return '#991b1b';
if (n >= 9) return '#b91c1c' if (n >= 9) return '#b91c1c';
if (n >= 8) return '#dc2626' if (n >= 8) return '#dc2626';
if (n >= 7) return '#ef4444' if (n >= 7) return '#ef4444';
if (n >= 6) return '#f97316' if (n >= 6) return '#f97316';
if (n >= 5) return '#fb923c' if (n >= 5) return '#fb923c';
if (n >= 4) return '#facc15' if (n >= 4) return '#facc15';
if (n >= 3) return '#a3e635' if (n >= 3) return '#a3e635';
if (n >= 2) return '#22c55e' if (n >= 2) return '#22c55e';
return '#4ade80' return '#4ade80';
} };
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': 'var(--red)', '중': 'var(--orange)', '하': 'var(--green)' } export const sensColor: Record<string, string> = {
export const statusColor: Record<string, string> = { '완료': 'var(--green)', '진행중': 'var(--orange)', '미조사': 'var(--t3)' } : 'var(--red)',
export const esiLevel = (n: number) => n >= 8 ? 'h' : n >= 5 ? 'm' : 'l' : 'var(--red)',
: 'var(--orange)',
: 'var(--green)',
};
export const statusColor: Record<string, string> = {
: 'var(--green)',
: 'var(--orange)',
: 'var(--t3)',
};
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
// ═══ Mock Data ═══ // ═══ 제주도 해안선 좌표 (시계방향) ═══
// --- 서귀포시 (서귀포해양경비안전서 관할) ---
export const sgAreas = [
{ area: '성산읍', code: 'SGSS', cnt: 99, villages: ['시흥리', '오조리', '성산리', '고성리', '온평리', '신산리', '삼달리', '신풍리', '신천리'], jurisdiction: '서귀포' },
{ area: '표선면', code: 'SGPS', cnt: 41, villages: ['하천리', '표선리', '세화리'], jurisdiction: '서귀포' },
{ area: '남원읍', code: 'SGNW', cnt: 73, villages: ['신흥리', '태흥리', '남원리', '위미리', '신례리'], jurisdiction: '서귀포' },
{ area: '하효동·보목동', code: 'SGHY', cnt: 8, villages: ['하효동', '보목동'], jurisdiction: '서귀포' },
{ area: '토평동·동흥동', code: 'SGTP', cnt: 12, villages: ['토평동', '동흥동'], jurisdiction: '서귀포' },
{ area: '서귀동·서홍동', code: 'SGSG', cnt: 20, villages: ['서귀동', '서홍동'], jurisdiction: '서귀포' },
{ area: '호근동·법환동', code: 'SGHG', cnt: 6, villages: ['호근동', '서호동', '법환동'], jurisdiction: '서귀포' },
{ area: '강정동', code: 'SGGJ', cnt: 21, villages: ['강정동'], jurisdiction: '서귀포' },
{ area: '월평동·대포동', code: 'SGWP', cnt: 4, villages: ['월평동', '하원동', '대포동'], jurisdiction: '서귀포' },
{ area: '중문동', code: 'SGJM', cnt: 8, villages: ['중문동'], jurisdiction: '서귀포' },
{ area: '색달동·하예동', code: 'SGSE', cnt: 8, villages: ['색달동', '하예동'], jurisdiction: '서귀포' },
{ area: '안덕면', code: 'SGAD', cnt: 38, villages: ['감산리', '사계리', '덕수리', '창천리', '대평리', '화순리'], jurisdiction: '서귀포' },
{ area: '대정읍', code: 'SGDJ', cnt: 79, villages: ['상모리', '하모리', '영락리', '인성리', '보성리', '무릉리', '신도리'], jurisdiction: '서귀포' },
]
// --- 제주시 (제주해양경비안전서 관할) ---
export const jjAreas = [
{ area: '한경면', code: 'JJHG', cnt: 81, villages: ['고산리', '금등리', '두모리', '신창리', '용수리', '판포리'], jurisdiction: '제주' },
{ area: '한림읍', code: 'JJHL', cnt: 87, villages: ['귀덕리', '금능리', '수원리', '옹포리', '월령리', '한림리', '한수리', '협재리'], jurisdiction: '제주' },
{ area: '애월읍', code: 'JJAW', cnt: 89, villages: ['고내리', '곽지리', '구엄리', '금성리', '신엄리', '애월리', '하귀1리', '하귀2리'], jurisdiction: '제주' },
{ area: '외도이동', code: 'JJOD', cnt: 19, villages: ['외도이동'], jurisdiction: '제주' },
{ area: '내도동', code: 'JJND', cnt: 7, villages: ['내도동'], jurisdiction: '제주' },
{ area: '이호일동', code: 'JJIH', cnt: 20, villages: ['이호일동'], jurisdiction: '제주' },
{ area: '도두동', code: 'JJDD', cnt: 17, villages: ['도두일동', '도두이동'], jurisdiction: '제주' },
{ area: '용담동', code: 'JJYD', cnt: 19, villages: ['용담삼동', '용담이동', '용담일동'], jurisdiction: '제주' },
{ area: '삼도이동', code: 'JJSD', cnt: 2, villages: ['삼도2동'], jurisdiction: '제주' },
{ area: '건입동', code: 'JJGI', cnt: 26, villages: ['건입동'], jurisdiction: '제주' },
{ area: '화북일동', code: 'JJHB', cnt: 23, villages: ['화북일동'], jurisdiction: '제주' },
{ area: '삼양삼동', code: 'JJYN', cnt: 19, villages: ['삼양삼동', '삼양이동', '삼양일동'], jurisdiction: '제주' },
{ area: '삼양일동', code: 'JJSY', cnt: 24, villages: ['삼양이동', '삼양일동'], jurisdiction: '제주' },
{ area: '조천읍', code: 'JJJC', cnt: 95, villages: ['북촌리', '신촌리', '신흥리', '조천리', '함덕리'], jurisdiction: '제주' },
{ area: '구좌읍', code: 'JJGJ', cnt: 147, villages: ['김녕리', '동복리', '상도리', '월정리', '종달리', '평대리', '하도리', '한동리', '행원리'], jurisdiction: '제주' },
]
export const scatAreas = [...sgAreas, ...jjAreas]
export const scatSubstrates = ['투과성 인공호안', '수직호안', '모래', '모래자갈혼합', '자갈·왕자갈', '수평암반', '수직암반']
export const substrateESI: Record<string, { esi: string; n: number }> = {
'투과성 인공호안': { esi: '6B', n: 6 }, '수직호안': { esi: '1B', n: 1 },
'모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 },
'자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 },
}
export const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']]
const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하'
const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사']
// 지역별 좌표 범위 (제주도 전체 해안)
export const areaCoords: Record<string, { latC: number; lngC: number; latR: number; lngR: number }> = {
// 서귀포시 (남부 해안)
SGSS: { latC: 33.39, lngC: 126.89, latR: 0.07, lngR: 0.05 },
SGPS: { latC: 33.33, lngC: 126.81, latR: 0.03, lngR: 0.04 },
SGNW: { latC: 33.26, lngC: 126.63, latR: 0.02, lngR: 0.05 },
SGHY: { latC: 33.245, lngC: 126.59, latR: 0.005, lngR: 0.02 },
SGTP: { latC: 33.245, lngC: 126.555, latR: 0.005, lngR: 0.015 },
SGSG: { latC: 33.245, lngC: 126.53, latR: 0.005, lngR: 0.015 },
SGHG: { latC: 33.245, lngC: 126.50, latR: 0.005, lngR: 0.02 },
SGGJ: { latC: 33.245, lngC: 126.45, latR: 0.005, lngR: 0.03 },
SGWP: { latC: 33.245, lngC: 126.40, latR: 0.005, lngR: 0.02 },
SGJM: { latC: 33.245, lngC: 126.37, latR: 0.005, lngR: 0.015 },
SGSE: { latC: 33.245, lngC: 126.34, latR: 0.005, lngR: 0.015 },
SGAD: { latC: 33.24, lngC: 126.29, latR: 0.01, lngR: 0.035 },
SGDJ: { latC: 33.25, lngC: 126.21, latR: 0.035, lngR: 0.05 },
// 제주시 (북부 해안)
JJHG: { latC: 33.31, lngC: 126.19, latR: 0.04, lngR: 0.04 },
JJHL: { latC: 33.39, lngC: 126.26, latR: 0.04, lngR: 0.05 },
JJAW: { latC: 33.46, lngC: 126.35, latR: 0.04, lngR: 0.06 },
JJOD: { latC: 33.505, lngC: 126.43, latR: 0.005, lngR: 0.015 },
JJND: { latC: 33.505, lngC: 126.44, latR: 0.003, lngR: 0.008 },
JJIH: { latC: 33.50, lngC: 126.46, latR: 0.005, lngR: 0.012 },
JJDD: { latC: 33.51, lngC: 126.49, latR: 0.005, lngR: 0.012 },
JJYD: { latC: 33.515, lngC: 126.52, latR: 0.005, lngR: 0.015 },
JJSD: { latC: 33.515, lngC: 126.525, latR: 0.003, lngR: 0.005 },
JJGI: { latC: 33.52, lngC: 126.545, latR: 0.005, lngR: 0.015 },
JJHB: { latC: 33.52, lngC: 126.565, latR: 0.005, lngR: 0.012 },
JJYN: { latC: 33.52, lngC: 126.585, latR: 0.005, lngR: 0.012 },
JJSY: { latC: 33.52, lngC: 126.59, latR: 0.005, lngR: 0.012 },
JJJC: { latC: 33.535, lngC: 126.64, latR: 0.015, lngR: 0.04 },
JJGJ: { latC: 33.53, lngC: 126.78, latR: 0.03, lngR: 0.10 },
}
// 제주도 전체 해안선 좌표 (시계방향: 대정읍→서귀포→성산→조천→구좌→한경)
export const jejuCoastCoords: [number, number][] = [ export const jejuCoastCoords: [number, number][] = [
// 서부 (대정읍~한경면) // 서부 (대정읍~한경면)
[33.2800, 126.1600], [33.2600, 126.1800], [33.2400, 126.2000], [33.28, 126.16],
[33.26, 126.18],
[33.24, 126.2],
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산) // 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
[33.2300, 126.2300], [33.2350, 126.2600], [33.2400, 126.2900], [33.2450, 126.3200], [33.23, 126.23],
[33.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300], [33.235, 126.26],
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300], [33.24, 126.29],
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200], [33.245, 126.32],
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800], [33.247, 126.35],
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800], [33.246, 126.37],
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310], [33.245, 126.4],
[33.244, 126.43],
[33.243, 126.46],
[33.242, 126.49],
[33.241, 126.51],
[33.24, 126.53],
[33.24, 126.55],
[33.241, 126.57],
[33.243, 126.59],
[33.245, 126.62],
[33.25, 126.66],
[33.26, 126.7],
[33.28, 126.74],
[33.31, 126.78],
[33.33, 126.82],
[33.36, 126.84],
[33.39, 126.86],
[33.42, 126.88],
[33.44, 126.9],
[33.453, 126.91],
[33.458, 126.92],
[33.461, 126.931],
// 동부 (성산~구좌) // 동부 (성산~구좌)
[33.4700, 126.9200], [33.4900, 126.9100], [33.5100, 126.8700], [33.47, 126.92],
[33.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900], [33.49, 126.91],
[33.51, 126.87],
[33.52, 126.85],
[33.535, 126.82],
[33.545, 126.79],
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경) // 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
[33.5500, 126.7600], [33.5500, 126.7300], [33.5450, 126.7000], [33.55, 126.76],
[33.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200], [33.55, 126.73],
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400], [33.545, 126.7],
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600], [33.54, 126.68],
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800], [33.535, 126.66],
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900], [33.53, 126.64],
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200], [33.525, 126.62],
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620], [33.52, 126.6],
] [33.52, 126.58],
[33.52, 126.56],
function seededRandom(seed: number) { [33.518, 126.54],
const x = Math.sin(seed) * 10000 [33.516, 126.52],
return x - Math.floor(x) [33.514, 126.5],
} [33.512, 126.48],
[33.51, 126.46],
const generateSegments = (): ScatSegment[] => { [33.505, 126.44],
const segs: ScatSegment[] = [] [33.5, 126.42],
let idx = 0 [33.495, 126.4],
scatAreas.forEach(a => { [33.485, 126.38],
const ac = areaCoords[a.code] [33.47, 126.35],
for (let i = 0; i < a.cnt; i++) { [33.455, 126.33],
const seed = idx * 137 + 42 [33.44, 126.31],
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)] [33.42, 126.29],
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)] [33.4, 126.27],
const { esi: esiStr, n: esiNum } = substrateESI[substrate] [33.38, 126.25],
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100 [33.36, 126.235],
// 지역 좌표 범위 내 분포 [33.34, 126.22],
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5 [33.32, 126.205],
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003 [33.31, 126.19],
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003 [33.3, 126.175],
segs.push({ [33.293, 126.162],
id: idx, ];
code: `${a.code}-${i + 1}`,
area: a.area,
name: `${village} 해안`,
type: substrate,
esi: esiStr,
esiNum,
length: `${lengthM.toLocaleString()}.0 m`,
lengthM,
sensitivity: sensFromESI(esiNum),
status: statusArr[Math.floor(seededRandom(seed + 5) * statusArr.length)],
lat, lng,
tags: scatTagSets[Math.floor(seededRandom(seed + 8) * scatTagSets.length)],
jurisdiction: a.jurisdiction,
})
idx++
}
})
return segs
}
export const allSegments = generateSegments()
export const scatDetailData: ScatDetail[] = [
// ═══ 서귀포시 (남부 해안) ═══
// SGSS-1: 성산읍 시흥리 — 투과성 인공호안
{
code: 'SGSS-1', name: '서귀포시 성산읍 시흥리', esi: '6B', esiColor: '#f97316', lat: 33.4610, lng: 126.9310,
type: '폐쇄형', substrate: '투과성 인공호안', length: '846.4m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1270-1, 12-64',
sensitive: [{ t: '사회·경제적', v: '올레길1코스, 파래양식장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGSS-6: 성산읍 시흥리 — 모래
{
code: 'SGSS-6', name: '서귀포시 성산읍 시흥리', esi: '3A', esiColor: '#a3e635', lat: 33.4580, lng: 126.9200,
type: '폐쇄형', substrate: '모래', length: '131.3m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1',
sensitive: [{ t: '사회·경제적', v: '숙박시설, 조가비박물관' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
},
// SGSS-10: 성산읍 오조리 — 수평암반
{
code: 'SGSS-10', name: '서귀포시 성산읍 오조리', esi: '8A', esiColor: '#dc2626', lat: 33.4500, lng: 126.9050,
type: '개방형', substrate: '수평암반', length: '433.6m', sensitivity: '상', status: '완료',
access: '도보로 접근 가능, 인근구획에서 접근', accessPt: '서귀포시 성산읍 오조리 391',
sensitive: [{ t: '사회·경제적', v: '교육시설(성산고등학교)' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// SGPS-6: 표선면 표선리 — 모래 (표선해수욕장)
{
code: 'SGPS-6', name: '서귀포시 표선면 표선리', esi: '3A', esiColor: '#a3e635', lat: 33.3270, lng: 126.8320,
type: '폐쇄형', substrate: '모래', length: '827.9m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 표선면 표선리 464-1',
sensitive: [{ t: '사회·경제적', v: '표선해수욕장, 올레길3코스, 숙박시설, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// SGNW-5: 남원읍 태흥리 — 수평암반
{
code: 'SGNW-5', name: '서귀포시 남원읍 태흥리', esi: '8A', esiColor: '#dc2626', lat: 33.2510, lng: 126.6650,
type: '개방형', substrate: '수평암반', length: '432.8m', sensitivity: '상', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 7',
sensitive: [{ t: '사회·경제적', v: '육상양식장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// SGNW-12: 남원읍 태흥리 — 모래자갈혼합
{
code: 'SGNW-12', name: '서귀포시 남원읍 태흥리', esi: '5', esiColor: '#fb923c', lat: 33.2480, lng: 126.6400,
type: '폐쇄형', substrate: '모래자갈혼합', length: '237.3m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 364-2',
sensitive: [{ t: '사회·경제적', v: '올레길4코스, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능'],
},
// SGTP-5: 서귀동 — 투과성 인공호안 (서귀포항)
{
code: 'SGTP-5', name: '서귀포시 서귀동', esi: '6B', esiColor: '#f97316', lat: 33.2400, lng: 126.5550,
type: '개방형', substrate: '투과성 인공호안', length: '701.6m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 서귀동 758-5',
sensitive: [{ t: '사회·경제적', v: '서귀포항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGGJ-5: 강정동 — 수직호안
{
code: 'SGGJ-5', name: '서귀포시 강정동', esi: '1B', esiColor: '#4ade80', lat: 33.2430, lng: 126.4500,
type: '폐쇄형', substrate: '수직호안', length: '380.0m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 강정동 산1',
sensitive: [{ t: '사회·경제적', v: '강정항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적'],
},
// SGAD-5: 안덕면 감산리 — 수직호안 (대평항)
{
code: 'SGAD-5', name: '서귀포시 안덕면 감산리', esi: '1B', esiColor: '#4ade80', lat: 33.2400, lng: 126.2950,
type: '폐쇄형', substrate: '수직호안', length: '246.9m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 982-1',
sensitive: [{ t: '사회·경제적', v: '대평항' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGAD-7: 안덕면 감산리 — 자갈·왕자갈
{
code: 'SGAD-7', name: '서귀포시 안덕면 감산리', esi: '6A', esiColor: '#f97316', lat: 33.2380, lng: 126.2850,
type: '폐쇄형', substrate: '자갈·왕자갈', length: '154.2m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 985',
sensitive: [{ t: '사회·경제적', v: '올레길8코스(해안로), 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
},
// SGDJ-5: 대정읍 상모리 — 수직호안 (산이수동항)
{
code: 'SGDJ-5', name: '서귀포시 대정읍 상모리', esi: '1B', esiColor: '#4ade80', lat: 33.2300, lng: 126.2350,
type: '개방형', substrate: '수직호안', length: '202.0m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 대정읍 상모리 133',
sensitive: [{ t: '사회·경제적', v: '산이수동항' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// SGDJ-7: 대정읍 상모리 — 모래 (송악산)
{
code: 'SGDJ-7', name: '서귀포시 대정읍 상모리', esi: '3A', esiColor: '#a3e635', lat: 33.2280, lng: 126.2280,
type: '개방형', substrate: '모래', length: '179.6m', sensitivity: '하', status: '미조사',
access: '도보로 접근 가능', accessPt: '서귀포시 대정읍 상모리 179-3',
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// SGDJ-8: 대정읍 상모리 — 수직암반 (송악산)
{
code: 'SGDJ-8', name: '서귀포시 대정읍 상모리', esi: '1A', esiColor: '#4ade80', lat: 33.2260, lng: 126.2200,
type: '개방형', substrate: '수직암반', length: '585.1m', sensitivity: '하', status: '완료',
access: '선박을 이용하여 접근', accessPt: '서귀포시 대정읍 상모리 179-3',
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
// ═══ 제주시 (북부 해안) ═══
// JJHG-1: 한경면 고산리 — 수평암반
{
code: 'JJHG-1', name: '제주시 한경면 고산리', esi: '8A', esiColor: '#dc2626', lat: 33.2930, lng: 126.1620,
type: '개방형', substrate: '수평암반', length: '306.0m', sensitivity: '상', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한경면 고산리 3987',
sensitive: [{ t: '사회·경제적', v: '육상양식장(도로 주변 농사구역)' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이', '부분적으로 모래와 암반이 형성되어 있음'],
},
// JJHG-8: 한경면 고산리 — 투과성 인공호안 (차귀도항)
{
code: 'JJHG-8', name: '제주시 한경면 고산리', esi: '6B', esiColor: '#f97316', lat: 33.3100, lng: 126.1750,
type: '개방형', substrate: '투과성 인공호안', length: '201.8m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 소형선박 이용 방제작업 가능', accessPt: '제주시 한경면 고산리 3616-10',
sensitive: [{ t: '사회·경제적', v: '차귀도항, 잠수함 매표소' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업(흡착제,걸레)에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJHL-4: 한림읍 월령리 — 모래
{
code: 'JJHL-4', name: '제주시 한림읍 월령리', esi: '3A', esiColor: '#a3e635', lat: 33.3900, lng: 126.2400,
type: '폐쇄형', substrate: '모래', length: '100.2m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한림읍 월령리 3855',
sensitive: [{ t: '사회·경제적', v: '월령항, 숙박시설' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
},
// JJAW-8: 애월읍 곽지리 — 모래 (곽지해수욕장)
{
code: 'JJAW-8', name: '제주시 애월읍 곽지리', esi: '3A', esiColor: '#a3e635', lat: 33.4700, lng: 126.3400,
type: '개방형', substrate: '모래', length: '573.6m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 애월읍 곽지리 3855',
sensitive: [{ t: '사회·경제적', v: '곽지해수욕장, 캠핑장' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
},
// JJGI-3: 건입동 — 수직호안 (제주항)
{
code: 'JJGI-3', name: '제주시 건입동', esi: '1B', esiColor: '#4ade80', lat: 33.5200, lng: 126.5450,
type: '폐쇄형', substrate: '수직호안', length: '365.8m', sensitivity: '하', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 건입동 3855',
sensitive: [{ t: '사회·경제적', v: '제주항, 제주조선' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 호안의 거친 표면에 쉽게 표착될 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJJC-4: 조천읍 신촌리 — 자갈·왕자갈
{
code: 'JJJC-4', name: '제주시 조천읍 신촌리', esi: '6A', esiColor: '#f97316', lat: 33.5380, lng: 126.6400,
type: '폐쇄형', substrate: '자갈·왕자갈', length: '360.4m', sensitivity: '중', status: '진행중',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 조천읍 신촌리 3855',
sensitive: [{ t: '사회·경제적', v: '정치망어장(전면 270m)' }, { t: '생물자원', v: '폐류 서식지' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
},
// JJGJ-2: 구좌읍 동복리 — 투과성 인공호안
{
code: 'JJGJ-2', name: '제주시 구좌읍 동복리', esi: '6B', esiColor: '#f97316', lat: 33.5500, lng: 126.7300,
type: '폐쇄형', substrate: '투과성 인공호안', length: '219.2m', sensitivity: '중', status: '완료',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
sensitive: [{ t: '사회·경제적', v: '접안시설' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
},
// JJGJ-3: 구좌읍 동복리 — 수평암반
{
code: 'JJGJ-3', name: '제주시 구좌읍 동복리', esi: '8A', esiColor: '#dc2626', lat: 33.5480, lng: 126.7350,
type: '개방형', substrate: '수평암반', length: '197.4m', sensitivity: '상', status: '미조사',
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
sensitive: [{ t: '사회·경제적', v: '산책로, 민가' }, { t: '생물자원', v: '없음' }],
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
},
]

파일 보기

@ -0,0 +1,135 @@
import { api } from '@common/services/api';
import type { ScatSegment, ScatDetail } from '../components/scatTypes';
import { esiColor } from '../components/scatConstants';
// ============================================================
// 백엔드 API 응답 타입
// ============================================================
export interface ApiZoneItem {
cstSrvyZoneSn: number;
zoneCd: string;
zoneNm: string;
jrsdNm: string;
sectCnt: number;
latCenter: number;
lngCenter: number;
latRange: number;
lngRange: number;
}
export interface ApiSectionListItem {
cstSectSn: number;
sectCd: string;
sectNm: string;
cstTpCd: string;
esiCd: string;
esiNum: number;
lenM: number;
snstvtCd: string;
srvySttsCd: string;
lat: number;
lng: number;
tags: string[];
zoneCd: string;
zoneNm: string;
jrsdNm: string;
}
export interface ApiSectionDetail extends ApiSectionListItem {
geomJson: Record<string, unknown> | null;
shoreTp: string | null;
accessDc: string | null;
accessPt: string | null;
sensitiveInfo: { t: string; v: string }[];
cleanupMethods: string[];
endCriteria: string[];
notes: string[];
}
// ============================================================
// 프론트 호환 변환 함수
// ============================================================
const sensFromESI = (n: number): ScatSegment['sensitivity'] =>
n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하';
function toScatSegment(item: ApiSectionListItem): ScatSegment {
const lengthM = Number(item.lenM) || 0;
return {
id: item.cstSectSn,
code: item.sectCd,
area: item.zoneNm,
name: item.sectNm,
type: item.cstTpCd,
esi: item.esiCd,
esiNum: item.esiNum,
length: `${lengthM.toLocaleString()}.0 m`,
lengthM,
sensitivity: (item.snstvtCd as ScatSegment['sensitivity']) || sensFromESI(item.esiNum),
status: (item.srvySttsCd as ScatSegment['status']) || '미조사',
lat: item.lat,
lng: item.lng,
tags: item.tags ?? [],
jurisdiction: item.jrsdNm,
};
}
function toScatDetail(item: ApiSectionDetail): ScatDetail {
const lengthM = Number(item.lenM) || 0;
return {
code: item.sectCd,
name: item.sectNm,
esi: item.esiCd,
esiColor: esiColor(item.esiNum),
lat: item.lat,
lng: item.lng,
type: item.shoreTp ?? item.cstTpCd,
substrate: item.cstTpCd,
length: `${lengthM.toLocaleString()}.0 m`,
sensitivity: item.snstvtCd,
status: item.srvySttsCd,
access: item.accessDc ?? '',
accessPt: item.accessPt ?? '',
sensitive: item.sensitiveInfo ?? [],
cleanup: item.cleanupMethods ?? [],
endCriteria: item.endCriteria ?? [],
notes: item.notes ?? [],
};
}
// ============================================================
// API 호출 함수
// ============================================================
export async function fetchZones(): Promise<ApiZoneItem[]> {
const { data } = await api.get<ApiZoneItem[]>('/scat/zones');
return data;
}
export interface SectionFilters {
zone?: string;
status?: string;
sensitivity?: string;
jurisdiction?: string;
search?: string;
}
export async function fetchSections(filters?: SectionFilters): Promise<ScatSegment[]> {
const params = new URLSearchParams();
if (filters?.zone) params.set('zone', filters.zone);
if (filters?.status) params.set('status', filters.status);
if (filters?.sensitivity) params.set('sensitivity', filters.sensitivity);
if (filters?.jurisdiction) params.set('jurisdiction', filters.jurisdiction);
if (filters?.search) params.set('search', filters.search);
const query = params.toString();
const url = query ? `/scat/sections?${query}` : '/scat/sections';
const { data } = await api.get<ApiSectionListItem[]>(url);
return data.map(toScatSegment);
}
export async function fetchSectionDetail(sn: number): Promise<ScatDetail> {
const { data } = await api.get<ApiSectionDetail>(`/scat/sections/${sn}`);
return toScatDetail(data);
}