Merge pull request 'feat(phase4): Board/HNS/Prediction/Aerial/Rescue Mock → API 전환' (#43) from feature/scat-api-conversion into develop
Reviewed-on: #43
This commit is contained in:
커밋
481c93e249
144
backend/src/aerial/aerialRouter.ts
Normal file
144
backend/src/aerial/aerialRouter.ts
Normal file
@ -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;
|
||||
341
backend/src/aerial/aerialService.ts
Normal file
341
backend/src/aerial/aerialService.ts
Normal file
@ -0,0 +1,341 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
// ============================================================
|
||||
// AERIAL_MEDIA
|
||||
// ============================================================
|
||||
|
||||
interface AerialMediaItem {
|
||||
aerialMediaSn: number;
|
||||
acdntSn: number | null;
|
||||
fileNm: string;
|
||||
orgnlNm: string | null;
|
||||
filePath: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
locDc: string | null;
|
||||
equipTpCd: string | null;
|
||||
equipNm: string | null;
|
||||
mediaTpCd: string | null;
|
||||
takngDtm: string | null;
|
||||
fileSz: string | null;
|
||||
resolution: string | null;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListMediaInput {
|
||||
equipType?: string;
|
||||
mediaType?: string;
|
||||
acdntSn?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
function rowToMedia(r: Record<string, unknown>): AerialMediaItem {
|
||||
return {
|
||||
aerialMediaSn: r.aerial_media_sn as number,
|
||||
acdntSn: r.acdnt_sn as number | null,
|
||||
fileNm: r.file_nm as string,
|
||||
orgnlNm: r.orgnl_nm as string | null,
|
||||
filePath: r.file_path as string | null,
|
||||
lon: r.lon ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat ? parseFloat(r.lat as string) : null,
|
||||
locDc: r.loc_dc as string | null,
|
||||
equipTpCd: r.equip_tp_cd as string | null,
|
||||
equipNm: r.equip_nm as string | null,
|
||||
mediaTpCd: r.media_tp_cd as string | null,
|
||||
takngDtm: r.takng_dtm as string | null,
|
||||
fileSz: r.file_sz as string | null,
|
||||
resolution: r.resolution as string | null,
|
||||
regDtm: r.reg_dtm as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listMedia(input: ListMediaInput): Promise<AerialMediaItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||
const params: (string | number)[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (input.equipType) {
|
||||
conditions.push(`EQUIP_TP_CD = $${idx++}`);
|
||||
params.push(input.equipType);
|
||||
}
|
||||
if (input.mediaType) {
|
||||
conditions.push(`MEDIA_TP_CD = $${idx++}`);
|
||||
params.push(input.mediaType);
|
||||
}
|
||||
if (input.acdntSn) {
|
||||
conditions.push(`ACDNT_SN = $${idx++}`);
|
||||
params.push(input.acdntSn);
|
||||
}
|
||||
if (input.search) {
|
||||
conditions.push(`(FILE_NM ILIKE '%' || $${idx} || '%' OR EQUIP_NM ILIKE '%' || $${idx} || '%')`);
|
||||
params.push(input.search);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT AERIAL_MEDIA_SN, ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
|
||||
LON, LAT, LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
|
||||
TAKNG_DTM, FILE_SZ, RESOLUTION, REG_DTM
|
||||
FROM AERIAL_MEDIA
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY TAKNG_DTM DESC NULLS LAST`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => rowToMedia(r));
|
||||
}
|
||||
|
||||
export async function createMedia(input: {
|
||||
acdntSn?: number;
|
||||
fileNm: string;
|
||||
orgnlNm?: string;
|
||||
filePath?: string;
|
||||
lon?: number;
|
||||
lat?: number;
|
||||
locDc?: string;
|
||||
equipTpCd?: string;
|
||||
equipNm?: string;
|
||||
mediaTpCd?: string;
|
||||
takngDtm?: string;
|
||||
fileSz?: string;
|
||||
resolution?: string;
|
||||
}): Promise<{ aerialMediaSn: number }> {
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO AERIAL_MEDIA (
|
||||
ACDNT_SN, FILE_NM, ORGNL_NM, FILE_PATH,
|
||||
LON, LAT,
|
||||
GEOM,
|
||||
LOC_DC, EQUIP_TP_CD, EQUIP_NM, MEDIA_TP_CD,
|
||||
TAKNG_DTM, FILE_SZ, RESOLUTION
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6,
|
||||
CASE WHEN $5 IS NOT NULL AND $6 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($5::float, $6::float), 4326) END,
|
||||
$7, $8, $9, $10,
|
||||
$11, $12, $13
|
||||
) RETURNING AERIAL_MEDIA_SN`,
|
||||
[
|
||||
input.acdntSn || null,
|
||||
input.fileNm,
|
||||
input.orgnlNm || null,
|
||||
input.filePath || null,
|
||||
input.lon || null,
|
||||
input.lat || null,
|
||||
input.locDc || null,
|
||||
input.equipTpCd || null,
|
||||
input.equipNm || null,
|
||||
input.mediaTpCd || null,
|
||||
input.takngDtm || null,
|
||||
input.fileSz || null,
|
||||
input.resolution || null,
|
||||
]
|
||||
);
|
||||
|
||||
return { aerialMediaSn: rows[0].aerial_media_sn };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CCTV_CAMERA
|
||||
// ============================================================
|
||||
|
||||
interface CctvCameraItem {
|
||||
cctvSn: number;
|
||||
cameraNm: string;
|
||||
regionNm: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
locDc: string | null;
|
||||
coordDc: string | null;
|
||||
sttsCd: string;
|
||||
ptzYn: string;
|
||||
sourceNm: string | null;
|
||||
streamUrl: string | null;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListCctvInput {
|
||||
region?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function rowToCctv(r: Record<string, unknown>): CctvCameraItem {
|
||||
return {
|
||||
cctvSn: r.cctv_sn as number,
|
||||
cameraNm: r.camera_nm as string,
|
||||
regionNm: r.region_nm as string | null,
|
||||
lon: r.lon ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat ? parseFloat(r.lat as string) : null,
|
||||
locDc: r.loc_dc as string | null,
|
||||
coordDc: r.coord_dc as string | null,
|
||||
sttsCd: r.stts_cd as string,
|
||||
ptzYn: r.ptz_yn as string,
|
||||
sourceNm: r.source_nm as string | null,
|
||||
streamUrl: r.stream_url as string | null,
|
||||
regDtm: r.reg_dtm as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listCctv(input: ListCctvInput): Promise<CctvCameraItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||
const params: string[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (input.region) {
|
||||
conditions.push(`REGION_NM = $${idx++}`);
|
||||
params.push(input.region);
|
||||
}
|
||||
if (input.status) {
|
||||
conditions.push(`STTS_CD = $${idx++}`);
|
||||
params.push(input.status);
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT CCTV_SN, CAMERA_NM, REGION_NM, LON, LAT,
|
||||
LOC_DC, COORD_DC, STTS_CD, PTZ_YN, SOURCE_NM, STREAM_URL, REG_DTM
|
||||
FROM CCTV_CAMERA
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY REGION_NM, CAMERA_NM`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => rowToCctv(r));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SAT_REQUEST
|
||||
// ============================================================
|
||||
|
||||
interface SatRequestItem {
|
||||
satReqSn: number;
|
||||
reqCd: string;
|
||||
acdntSn: number | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
zoneDc: string | null;
|
||||
coordDc: string | null;
|
||||
zoneAreaKm2: number | null;
|
||||
satNm: string | null;
|
||||
providerNm: string | null;
|
||||
resolution: string | null;
|
||||
purposeDc: string | null;
|
||||
reqstrNm: string | null;
|
||||
reqDtm: string | null;
|
||||
expectedRcvDtm: string | null;
|
||||
sttsCd: string;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListSatRequestsInput {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
function rowToSatRequest(r: Record<string, unknown>): SatRequestItem {
|
||||
return {
|
||||
satReqSn: r.sat_req_sn as number,
|
||||
reqCd: r.req_cd as string,
|
||||
acdntSn: r.acdnt_sn as number | null,
|
||||
lon: r.lon ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat ? parseFloat(r.lat as string) : null,
|
||||
zoneDc: r.zone_dc as string | null,
|
||||
coordDc: r.coord_dc as string | null,
|
||||
zoneAreaKm2: r.zone_area_km2 ? parseFloat(r.zone_area_km2 as string) : null,
|
||||
satNm: r.sat_nm as string | null,
|
||||
providerNm: r.provider_nm as string | null,
|
||||
resolution: r.resolution as string | null,
|
||||
purposeDc: r.purpose_dc as string | null,
|
||||
reqstrNm: r.reqstr_nm as string | null,
|
||||
reqDtm: r.req_dtm as string | null,
|
||||
expectedRcvDtm: r.expected_rcv_dtm as string | null,
|
||||
sttsCd: r.stts_cd as string,
|
||||
regDtm: r.reg_dtm as string,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listSatRequests(input: ListSatRequestsInput): Promise<SatRequestItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||
const params: string[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (input.status) {
|
||||
conditions.push(`STTS_CD = $${idx++}`);
|
||||
params.push(input.status);
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT SAT_REQ_SN, REQ_CD, ACDNT_SN, LON, LAT,
|
||||
ZONE_DC, COORD_DC, ZONE_AREA_KM2, SAT_NM, PROVIDER_NM,
|
||||
RESOLUTION, PURPOSE_DC, REQSTR_NM,
|
||||
REQ_DTM, EXPECTED_RCV_DTM, STTS_CD, REG_DTM
|
||||
FROM SAT_REQUEST
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY REQ_DTM DESC NULLS LAST`,
|
||||
params
|
||||
);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => rowToSatRequest(r));
|
||||
}
|
||||
|
||||
export async function createSatRequest(input: {
|
||||
reqCd: string;
|
||||
acdntSn?: number;
|
||||
lon?: number;
|
||||
lat?: number;
|
||||
zoneDc?: string;
|
||||
coordDc?: string;
|
||||
zoneAreaKm2?: number;
|
||||
satNm?: string;
|
||||
providerNm?: string;
|
||||
resolution?: string;
|
||||
purposeDc?: string;
|
||||
reqstrNm?: string;
|
||||
reqDtm?: string;
|
||||
expectedRcvDtm?: string;
|
||||
}): Promise<{ satReqSn: number }> {
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO SAT_REQUEST (
|
||||
REQ_CD, ACDNT_SN, LON, LAT,
|
||||
GEOM,
|
||||
ZONE_DC, COORD_DC, ZONE_AREA_KM2,
|
||||
SAT_NM, PROVIDER_NM, RESOLUTION,
|
||||
PURPOSE_DC, REQSTR_NM, REQ_DTM, EXPECTED_RCV_DTM
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
CASE WHEN $3 IS NOT NULL AND $4 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($3::float, $4::float), 4326) END,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10,
|
||||
$11, $12, $13, $14
|
||||
) RETURNING SAT_REQ_SN`,
|
||||
[
|
||||
input.reqCd,
|
||||
input.acdntSn || null,
|
||||
input.lon || null,
|
||||
input.lat || null,
|
||||
input.zoneDc || null,
|
||||
input.coordDc || null,
|
||||
input.zoneAreaKm2 || null,
|
||||
input.satNm || null,
|
||||
input.providerNm || null,
|
||||
input.resolution || null,
|
||||
input.purposeDc || null,
|
||||
input.reqstrNm || null,
|
||||
input.reqDtm || null,
|
||||
input.expectedRcvDtm || null,
|
||||
]
|
||||
);
|
||||
|
||||
return { satReqSn: rows[0].sat_req_sn };
|
||||
}
|
||||
|
||||
const VALID_SAT_STATUSES = ['PENDING', 'SHOOTING', 'COMPLETED', 'CANCELLED'] as const;
|
||||
type SatStatus = typeof VALID_SAT_STATUSES[number];
|
||||
|
||||
export function isValidSatStatus(value: string): value is SatStatus {
|
||||
return (VALID_SAT_STATUSES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export async function updateSatRequestStatus(sn: number, sttsCd: string): Promise<void> {
|
||||
await wingPool.query(
|
||||
`UPDATE SAT_REQUEST SET STTS_CD = $1 WHERE SAT_REQ_SN = $2 AND USE_YN = 'Y'`,
|
||||
[sttsCd, sn]
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,10 @@
|
||||
import { Router } from 'express'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
import { AuthError } from '../auth/authService.js'
|
||||
import { listPosts, getPost, createPost, updatePost, deletePost } from './boardService.js'
|
||||
import {
|
||||
listPosts, getPost, createPost, updatePost, deletePost,
|
||||
listManuals, createManual, updateManual, deleteManual, incrementManualDownload,
|
||||
} from './boardService.js'
|
||||
|
||||
const router = Router()
|
||||
|
||||
@ -14,8 +17,88 @@ const CATEGORY_RESOURCE: Record<string, string> = {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// GET /api/board — 게시글 목록
|
||||
// 매뉴얼 라우트 (/:sn 보다 먼저 등록해야 함)
|
||||
// ============================================================
|
||||
|
||||
// GET /api/board/manual — 매뉴얼 목록
|
||||
router.get('/manual', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { category, search } = req.query
|
||||
const items = await listManuals({
|
||||
category: category as string | undefined,
|
||||
search: search as string | undefined,
|
||||
})
|
||||
res.json(items)
|
||||
} catch (err) {
|
||||
console.error('[board] 매뉴얼 목록 오류:', err)
|
||||
res.status(500).json({ error: '매뉴얼 목록 조회 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/board/manual — 매뉴얼 등록
|
||||
router.post('/manual', requireAuth, requirePermission('board:manual', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { catgNm, title, version, fileTp, fileSz, filePath, authorNm } = req.body
|
||||
if (!catgNm || !title) {
|
||||
res.status(400).json({ error: '카테고리와 제목은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
const result = await createManual({ catgNm, title, version, fileTp, fileSz, filePath, authorNm })
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
||||
console.error('[board] 매뉴얼 등록 오류:', err)
|
||||
res.status(500).json({ error: '매뉴얼 등록 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/board/manual/:sn — 매뉴얼 수정
|
||||
router.put('/manual/:sn', requireAuth, requirePermission('board:manual', 'UPDATE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
||||
const { catgNm, title, version, fileTp, fileSz, filePath } = req.body
|
||||
await updateManual(sn, { catgNm, title, version, fileTp, fileSz, filePath })
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
||||
console.error('[board] 매뉴얼 수정 오류:', err)
|
||||
res.status(500).json({ error: '매뉴얼 수정 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/board/manual/:sn — 매뉴얼 삭제
|
||||
router.delete('/manual/:sn', requireAuth, requirePermission('board:manual', 'DELETE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
||||
await deleteManual(sn)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }); return }
|
||||
console.error('[board] 매뉴얼 삭제 오류:', err)
|
||||
res.status(500).json({ error: '매뉴얼 삭제 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/board/manual/:sn/download — 매뉴얼 다운로드 카운트 증가
|
||||
router.post('/manual/:sn/download', requireAuth, requirePermission('board:manual', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (isNaN(sn)) { res.status(400).json({ error: '유효하지 않은 매뉴얼 번호입니다.' }); return }
|
||||
await incrementManualDownload(sn)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('[board] 다운로드 카운트 오류:', err)
|
||||
res.status(500).json({ error: '다운로드 처리 중 오류가 발생했습니다.' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// 게시글 라우트
|
||||
// ============================================================
|
||||
|
||||
// GET /api/board — 게시글 목록
|
||||
router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { categoryCd, search, page, size } = req.query
|
||||
@ -32,9 +115,7 @@ router.get('/', requireAuth, requirePermission('board', 'READ'), async (req, res
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// GET /api/board/:sn — 게시글 상세
|
||||
// ============================================================
|
||||
router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
@ -54,9 +135,7 @@ router.get('/:sn', requireAuth, requirePermission('board', 'READ'), async (req,
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// POST /api/board — 게시글 작성 (카테고리별 CREATE 권한)
|
||||
// ============================================================
|
||||
router.post('/', requireAuth, async (req, res, next) => {
|
||||
const resource = CATEGORY_RESOURCE[req.body.categoryCd] || 'board'
|
||||
requirePermission(resource, 'CREATE')(req, res, next)
|
||||
@ -87,9 +166,7 @@ router.post('/', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// PUT /api/board/:sn — 게시글 수정 (소유자 검증은 서비스에서)
|
||||
// ============================================================
|
||||
router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
@ -111,9 +188,7 @@ router.put('/:sn', requireAuth, requirePermission('board', 'UPDATE'), async (req
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// DELETE /api/board/:sn — 게시글 삭제 (논리 삭제, 소유자 검증)
|
||||
// ============================================================
|
||||
router.delete('/:sn', requireAuth, requirePermission('board', 'DELETE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
|
||||
@ -220,6 +220,163 @@ export async function updatePost(
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 매뉴얼 CRUD
|
||||
// ============================================================
|
||||
|
||||
interface ManualItem {
|
||||
manualSn: number
|
||||
catgNm: string
|
||||
title: string
|
||||
version: string | null
|
||||
fileTp: string | null
|
||||
fileSz: string | null
|
||||
filePath: string | null
|
||||
authorNm: string | null
|
||||
dwnldCnt: number
|
||||
regDtm: string
|
||||
}
|
||||
|
||||
interface ListManualsInput {
|
||||
category?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
interface CreateManualInput {
|
||||
catgNm: string
|
||||
title: string
|
||||
version?: string
|
||||
fileTp?: string
|
||||
fileSz?: string
|
||||
filePath?: string
|
||||
authorNm?: string
|
||||
}
|
||||
|
||||
interface UpdateManualInput {
|
||||
catgNm?: string
|
||||
title?: string
|
||||
version?: string
|
||||
fileTp?: string
|
||||
fileSz?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
function rowToManual(r: Record<string, unknown>): ManualItem {
|
||||
return {
|
||||
manualSn: r.manual_sn as number,
|
||||
catgNm: r.catg_nm as string,
|
||||
title: r.title as string,
|
||||
version: r.version as string | null,
|
||||
fileTp: r.file_tp as string | null,
|
||||
fileSz: r.file_sz as string | null,
|
||||
filePath: r.file_path as string | null,
|
||||
authorNm: r.author_nm as string | null,
|
||||
dwnldCnt: r.dwnld_cnt as number,
|
||||
regDtm: r.reg_dtm as string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listManuals(input: ListManualsInput): Promise<ManualItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||
const params: string[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.category) {
|
||||
conditions.push(`CATG_NM = $${idx++}`)
|
||||
params.push(input.category)
|
||||
}
|
||||
if (input.search) {
|
||||
conditions.push(`(TITLE ILIKE $${idx} OR AUTHOR_NM ILIKE $${idx})`)
|
||||
params.push(`%${input.search}%`)
|
||||
idx++
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT MANUAL_SN, CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ,
|
||||
FILE_PATH, AUTHOR_NM, DWNLD_CNT, REG_DTM
|
||||
FROM MANUAL_FILE
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY REG_DTM DESC`,
|
||||
params
|
||||
)
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => rowToManual(r))
|
||||
}
|
||||
|
||||
export async function createManual(input: CreateManualInput): Promise<{ manualSn: number }> {
|
||||
if (!input.title || input.title.trim().length === 0) {
|
||||
throw new AuthError('제목은 필수입니다.', 400)
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO MANUAL_FILE (CATG_NM, TITLE, VERSION, FILE_TP, FILE_SZ, FILE_PATH, AUTHOR_NM)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING MANUAL_SN`,
|
||||
[input.catgNm, input.title.trim(), input.version || null, input.fileTp || null, input.fileSz || null, input.filePath || null, input.authorNm || null]
|
||||
)
|
||||
|
||||
return { manualSn: rows[0].manual_sn }
|
||||
}
|
||||
|
||||
export async function updateManual(manualSn: number, input: UpdateManualInput): Promise<void> {
|
||||
const existing = await wingPool.query(
|
||||
`SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`,
|
||||
[manualSn]
|
||||
)
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
const sets: string[] = []
|
||||
const params: (string | number | null)[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.catgNm !== undefined) { sets.push(`CATG_NM = $${idx++}`); params.push(input.catgNm) }
|
||||
if (input.title !== undefined) { sets.push(`TITLE = $${idx++}`); params.push(input.title.trim()) }
|
||||
if (input.version !== undefined) { sets.push(`VERSION = $${idx++}`); params.push(input.version) }
|
||||
if (input.fileTp !== undefined) { sets.push(`FILE_TP = $${idx++}`); params.push(input.fileTp) }
|
||||
if (input.fileSz !== undefined) { sets.push(`FILE_SZ = $${idx++}`); params.push(input.fileSz) }
|
||||
if (input.filePath !== undefined) { sets.push(`FILE_PATH = $${idx++}`); params.push(input.filePath) }
|
||||
|
||||
if (sets.length === 0) {
|
||||
throw new AuthError('수정할 항목이 없습니다.', 400)
|
||||
}
|
||||
|
||||
sets.push('MDFCN_DTM = NOW()')
|
||||
params.push(manualSn)
|
||||
|
||||
await wingPool.query(
|
||||
`UPDATE MANUAL_FILE SET ${sets.join(', ')} WHERE MANUAL_SN = $${idx}`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
export async function deleteManual(manualSn: number): Promise<void> {
|
||||
const existing = await wingPool.query(
|
||||
`SELECT MANUAL_SN FROM MANUAL_FILE WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`,
|
||||
[manualSn]
|
||||
)
|
||||
if (existing.rows.length === 0) {
|
||||
throw new AuthError('매뉴얼을 찾을 수 없습니다.', 404)
|
||||
}
|
||||
|
||||
await wingPool.query(
|
||||
`UPDATE MANUAL_FILE SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE MANUAL_SN = $1`,
|
||||
[manualSn]
|
||||
)
|
||||
}
|
||||
|
||||
export async function incrementManualDownload(manualSn: number): Promise<void> {
|
||||
await wingPool.query(
|
||||
`UPDATE MANUAL_FILE SET DWNLD_CNT = DWNLD_CNT + 1 WHERE MANUAL_SN = $1 AND USE_YN = 'Y'`,
|
||||
[manualSn]
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 게시글 삭제
|
||||
// ============================================================
|
||||
|
||||
export async function deletePost(postSn: number, requesterId: string): Promise<void> {
|
||||
// 게시글 존재 + 작성자 확인
|
||||
const existing = await wingPool.query(
|
||||
|
||||
@ -1,9 +1,88 @@
|
||||
import express from 'express'
|
||||
import { searchSubstances, getSubstanceById } from './hnsService.js'
|
||||
import { searchSubstances, getSubstanceById, listAnalyses, getAnalysis, createAnalysis, deleteAnalysis } from './hnsService.js'
|
||||
import { isValidNumber } from '../middleware/security.js'
|
||||
import { requireAuth, requirePermission } from '../auth/authMiddleware.js'
|
||||
|
||||
const router = express.Router()
|
||||
|
||||
// ============================================================
|
||||
// HNS 분석 라우트 (/:id 보다 먼저 등록)
|
||||
// ============================================================
|
||||
|
||||
// GET /api/hns/analyses — 분석 목록
|
||||
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const { status, substance, search } = req.query
|
||||
const items = await listAnalyses({
|
||||
status: status as string | undefined,
|
||||
substance: substance as string | undefined,
|
||||
search: search as string | undefined,
|
||||
})
|
||||
res.json(items)
|
||||
} catch (err) {
|
||||
console.error('[hns] 분석 목록 오류:', err)
|
||||
res.status(500).json({ error: 'HNS 분석 목록 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/hns/analyses/:sn — 분석 상세
|
||||
router.get('/analyses/:sn', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (!isValidNumber(sn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 분석 번호' })
|
||||
return
|
||||
}
|
||||
const item = await getAnalysis(sn)
|
||||
if (!item) {
|
||||
res.status(404).json({ error: '분석을 찾을 수 없습니다' })
|
||||
return
|
||||
}
|
||||
res.json(item)
|
||||
} catch (err) {
|
||||
console.error('[hns] 분석 상세 오류:', err)
|
||||
res.status(500).json({ error: 'HNS 분석 조회 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/hns/analyses — 분석 생성
|
||||
router.post('/analyses', requireAuth, requirePermission('hns', 'CREATE'), async (req, res) => {
|
||||
try {
|
||||
const { anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm } = req.body
|
||||
if (!anlysNm) {
|
||||
res.status(400).json({ error: '분석명은 필수입니다.' })
|
||||
return
|
||||
}
|
||||
const result = await createAnalysis({
|
||||
anlysNm, acdntDtm, locNm, lon, lat, sbstNm, spilQty, spilUnitCd, fcstHr, algoCd, critMdlCd, windSpd, windDir, temp, humid, atmStblCd, analystNm,
|
||||
})
|
||||
res.status(201).json(result)
|
||||
} catch (err) {
|
||||
console.error('[hns] 분석 생성 오류:', err)
|
||||
res.status(500).json({ error: 'HNS 분석 생성 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/hns/analyses/:sn — 분석 삭제
|
||||
router.delete('/analyses/:sn', requireAuth, requirePermission('hns', 'DELETE'), async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10)
|
||||
if (!isValidNumber(sn, 1, 999999)) {
|
||||
res.status(400).json({ error: '유효하지 않은 분석 번호' })
|
||||
return
|
||||
}
|
||||
await deleteAnalysis(sn)
|
||||
res.json({ success: true })
|
||||
} catch (err) {
|
||||
console.error('[hns] 분석 삭제 오류:', err)
|
||||
res.status(500).json({ error: 'HNS 분석 삭제 실패' })
|
||||
}
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
// HNS 물질 라우트
|
||||
// ============================================================
|
||||
|
||||
// HNS 물질 검색
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -88,6 +88,163 @@ export async function searchSubstances(params: HnsSearchParams) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// HNS 분석 CRUD
|
||||
// ============================================================
|
||||
|
||||
interface HnsAnalysisItem {
|
||||
hnsAnlysSn: number
|
||||
anlysNm: string
|
||||
acdntDtm: string | null
|
||||
locNm: string | null
|
||||
lon: number | null
|
||||
lat: number | null
|
||||
sbstNm: string | null
|
||||
spilQty: number | null
|
||||
spilUnitCd: string | null
|
||||
fcstHr: number | null
|
||||
algoCd: string | null
|
||||
critMdlCd: string | null
|
||||
windSpd: number | null
|
||||
windDir: string | null
|
||||
execSttsCd: string
|
||||
riskCd: string | null
|
||||
analystNm: string | null
|
||||
rsltData: Record<string, unknown> | null
|
||||
regDtm: string
|
||||
}
|
||||
|
||||
interface ListAnalysesInput {
|
||||
status?: string
|
||||
substance?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
|
||||
return {
|
||||
hnsAnlysSn: r.hns_anlys_sn as number,
|
||||
anlysNm: r.anlys_nm as string,
|
||||
acdntDtm: r.acdnt_dtm as string | null,
|
||||
locNm: r.loc_nm as string | null,
|
||||
lon: r.lon ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat ? parseFloat(r.lat as string) : null,
|
||||
sbstNm: r.sbst_nm as string | null,
|
||||
spilQty: r.spil_qty ? parseFloat(r.spil_qty as string) : null,
|
||||
spilUnitCd: r.spil_unit_cd as string | null,
|
||||
fcstHr: r.fcst_hr as number | null,
|
||||
algoCd: r.algo_cd as string | null,
|
||||
critMdlCd: r.crit_mdl_cd as string | null,
|
||||
windSpd: r.wind_spd ? parseFloat(r.wind_spd as string) : null,
|
||||
windDir: r.wind_dir as string | null,
|
||||
execSttsCd: r.exec_stts_cd as string,
|
||||
riskCd: r.risk_cd as string | null,
|
||||
analystNm: r.analyst_nm as string | null,
|
||||
rsltData: (r.rslt_data as Record<string, unknown>) ?? null,
|
||||
regDtm: r.reg_dtm as string,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"]
|
||||
const params: string[] = []
|
||||
let idx = 1
|
||||
|
||||
if (input.status) {
|
||||
conditions.push(`EXEC_STTS_CD = $${idx++}`)
|
||||
params.push(input.status)
|
||||
}
|
||||
if (input.substance) {
|
||||
conditions.push(`SBST_NM ILIKE '%' || $${idx++} || '%'`)
|
||||
params.push(input.substance)
|
||||
}
|
||||
if (input.search) {
|
||||
conditions.push(`(ANLYS_NM ILIKE '%' || $${idx} || '%' OR LOC_NM ILIKE '%' || $${idx} || '%')`)
|
||||
params.push(input.search)
|
||||
idx++
|
||||
}
|
||||
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||
RSLT_DATA, REG_DTM
|
||||
FROM HNS_ANALYSIS
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
ORDER BY ACDNT_DTM DESC NULLS LAST`,
|
||||
params
|
||||
)
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => rowToAnalysis(r))
|
||||
}
|
||||
|
||||
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
EXEC_STTS_CD, RISK_CD, ANALYST_NM,
|
||||
RSLT_DATA, REG_DTM
|
||||
FROM HNS_ANALYSIS
|
||||
WHERE HNS_ANLYS_SN = $1 AND USE_YN = 'Y'`,
|
||||
[sn]
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
return rowToAnalysis(rows[0] as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export async function createAnalysis(input: {
|
||||
anlysNm: string
|
||||
acdntDtm?: string
|
||||
locNm?: string
|
||||
lon?: number
|
||||
lat?: number
|
||||
sbstNm?: string
|
||||
spilQty?: number
|
||||
spilUnitCd?: string
|
||||
fcstHr?: number
|
||||
algoCd?: string
|
||||
critMdlCd?: string
|
||||
windSpd?: number
|
||||
windDir?: string
|
||||
temp?: number
|
||||
humid?: number
|
||||
atmStblCd?: string
|
||||
analystNm?: string
|
||||
}): Promise<{ hnsAnlysSn: number }> {
|
||||
const { rows } = await wingPool.query(
|
||||
`INSERT INTO HNS_ANALYSIS (
|
||||
ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
|
||||
GEOM, LOC_DC,
|
||||
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
|
||||
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
|
||||
ANALYST_NM, EXEC_STTS_CD
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN ST_SetSRID(ST_MakePoint($4::float, $5::float), 4326) END,
|
||||
CASE WHEN $4 IS NOT NULL AND $5 IS NOT NULL THEN $4 || ' + ' || $5 END,
|
||||
$6, $7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15, $16,
|
||||
$17, 'PENDING'
|
||||
) RETURNING HNS_ANLYS_SN`,
|
||||
[
|
||||
input.anlysNm, input.acdntDtm || null, input.locNm || null, input.lon || null, input.lat || null,
|
||||
input.sbstNm || null, input.spilQty || null, input.spilUnitCd || 'KL',
|
||||
input.fcstHr || null, input.algoCd || null, input.critMdlCd || null,
|
||||
input.windSpd || null, input.windDir || null, input.temp || null, input.humid || null, input.atmStblCd || null,
|
||||
input.analystNm || null,
|
||||
]
|
||||
)
|
||||
|
||||
return { hnsAnlysSn: rows[0].hns_anlys_sn }
|
||||
}
|
||||
|
||||
export async function deleteAnalysis(sn: number): Promise<void> {
|
||||
await wingPool.query(
|
||||
`UPDATE HNS_ANALYSIS SET USE_YN = 'N', MDFCN_DTM = NOW() WHERE HNS_ANLYS_SN = $1`,
|
||||
[sn]
|
||||
)
|
||||
}
|
||||
|
||||
export async function getSubstanceById(id: number) {
|
||||
const { rows } = await wingPool.query(
|
||||
`SELECT SBST_SN, ABBREVIATION, NM_KR, NM_EN, UN_NO, CAS_NO, SEBC, DATA
|
||||
|
||||
127
backend/src/prediction/predictionRouter.ts
Normal file
127
backend/src/prediction/predictionRouter.ts
Normal file
@ -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;
|
||||
423
backend/src/prediction/predictionService.ts
Normal file
423
backend/src/prediction/predictionService.ts
Normal file
@ -0,0 +1,423 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
interface PredictionAnalysis {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
analysisDate: string;
|
||||
requestor: string;
|
||||
duration: string;
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
location: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
kospsStatus: string;
|
||||
poseidonStatus: string;
|
||||
opendriftStatus: string;
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
}
|
||||
|
||||
interface PredictionDetail {
|
||||
acdnt: {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
location: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
};
|
||||
spill: {
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
unit: string;
|
||||
fcstHr: number | null;
|
||||
} | null;
|
||||
vessels: Array<{
|
||||
vesselInfoSn: number;
|
||||
imoNo: string;
|
||||
vesselNm: string;
|
||||
vesselTp: string;
|
||||
loaM: number | null;
|
||||
breadthM: number | null;
|
||||
draftM: number | null;
|
||||
gt: number | null;
|
||||
dwt: number | null;
|
||||
builtYr: number | null;
|
||||
flagCd: string;
|
||||
callsign: string;
|
||||
engineDc: string;
|
||||
insuranceData: unknown;
|
||||
}>;
|
||||
weather: Array<{
|
||||
weatherDtm: string;
|
||||
windSpd: number | null;
|
||||
windDir: string | null;
|
||||
waveHgt: number | null;
|
||||
currentSpd: number | null;
|
||||
currentDir: string | null;
|
||||
temp: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface BacktrackResult {
|
||||
backtrackSn: number;
|
||||
acdntSn: number;
|
||||
estSpilDtm: string | null;
|
||||
anlysRange: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
srchRadiusNm: number | null;
|
||||
totalVessels: number | null;
|
||||
execSttsCd: string;
|
||||
rsltData: unknown;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface CreateBacktrackInput {
|
||||
acdntSn: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
estSpilDtm?: string;
|
||||
anlysRange?: string;
|
||||
srchRadiusNm?: number;
|
||||
}
|
||||
|
||||
interface SaveBoomLineInput {
|
||||
acdntSn: number;
|
||||
boomNm: string;
|
||||
priorityOrd?: number;
|
||||
geojson: unknown;
|
||||
lengthM?: number;
|
||||
efficiencyPct?: number;
|
||||
}
|
||||
|
||||
interface BoomLineItem {
|
||||
boomLineSn: number;
|
||||
acdntSn: number;
|
||||
boomNm: string;
|
||||
priorityOrd: number;
|
||||
geom: unknown;
|
||||
lengthM: number | null;
|
||||
efficiencyPct: number | null;
|
||||
sttsCd: string;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListAnalysesInput {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
||||
|
||||
if (input.search) {
|
||||
params.push(`%${input.search}%`);
|
||||
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
A.LOC_DC,
|
||||
A.ANALYST_NM,
|
||||
A.OFFICE_NM,
|
||||
A.REGION_NM,
|
||||
S.OIL_TP_CD,
|
||||
S.SPIL_QTY,
|
||||
S.SPIL_UNIT_CD,
|
||||
S.FCST_HR,
|
||||
P.KOSPS_STATUS,
|
||||
P.POSEIDON_STATUS,
|
||||
P.OPENDRIFT_STATUS,
|
||||
B.BACKTRACK_STATUS
|
||||
FROM ACDNT A
|
||||
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ACDNT_SN,
|
||||
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
|
||||
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
||||
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
|
||||
FROM PRED_EXEC
|
||||
GROUP BY ACDNT_SN
|
||||
) P ON P.ACDNT_SN = A.ACDNT_SN
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ACDNT_SN,
|
||||
MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS
|
||||
FROM BACKTRACK B
|
||||
GROUP BY ACDNT_SN
|
||||
) B ON B.ACDNT_SN = A.ACDNT_SN
|
||||
${whereClause}
|
||||
ORDER BY A.OCCRN_DTM DESC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
acdntSn: Number(row['acdnt_sn']),
|
||||
acdntNm: String(row['acdnt_nm'] ?? ''),
|
||||
occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
||||
analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
||||
requestor: String(row['analyst_nm'] ?? ''),
|
||||
duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '',
|
||||
oilType: String(row['oil_tp_cd'] ?? ''),
|
||||
volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null,
|
||||
location: String(row['loc_dc'] ?? ''),
|
||||
lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null,
|
||||
lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null,
|
||||
kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(),
|
||||
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
||||
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||
analyst: String(row['analyst_nm'] ?? ''),
|
||||
officeName: String(row['office_nm'] ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDetail | null> {
|
||||
const acdntSql = `
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
A.LOC_DC,
|
||||
A.ANALYST_NM,
|
||||
A.OFFICE_NM
|
||||
FROM ACDNT A
|
||||
WHERE A.ACDNT_SN = $1
|
||||
AND A.USE_YN = 'Y'
|
||||
`;
|
||||
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
||||
if (acdntRows.length === 0) return null;
|
||||
|
||||
const a = acdntRows[0] as Record<string, unknown>;
|
||||
|
||||
const spillSql = `
|
||||
SELECT
|
||||
OIL_TP_CD,
|
||||
SPIL_QTY,
|
||||
SPIL_UNIT_CD,
|
||||
FCST_HR
|
||||
FROM SPIL_DATA
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY SPIL_DATA_SN ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]);
|
||||
|
||||
const vesselSql = `
|
||||
SELECT
|
||||
VESSEL_INFO_SN,
|
||||
IMO_NO,
|
||||
VESSEL_NM,
|
||||
VESSEL_TP,
|
||||
LOA_M,
|
||||
BREADTH_M,
|
||||
DRAFT_M,
|
||||
GT,
|
||||
DWT,
|
||||
BUILT_YR,
|
||||
FLAG_CD,
|
||||
CALLSIGN,
|
||||
ENGINE_DC,
|
||||
INSURANCE_DATA
|
||||
FROM VESSEL_INFO
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY VESSEL_INFO_SN ASC
|
||||
`;
|
||||
const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]);
|
||||
|
||||
const weatherSql = `
|
||||
SELECT
|
||||
WEATHER_DTM,
|
||||
WIND_SPD,
|
||||
WIND_DIR,
|
||||
WAVE_HGT,
|
||||
CURRENT_SPD,
|
||||
CURRENT_DIR,
|
||||
TEMP
|
||||
FROM ACDNT_WEATHER
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY WEATHER_DTM ASC
|
||||
`;
|
||||
const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]);
|
||||
|
||||
const spill =
|
||||
spillRows.length > 0
|
||||
? (() => {
|
||||
const s = spillRows[0] as Record<string, unknown>;
|
||||
return {
|
||||
oilType: String(s['oil_tp_cd'] ?? ''),
|
||||
volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null,
|
||||
unit: String(s['spil_unit_cd'] ?? ''),
|
||||
fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const vessels = vesselRows.map((v: Record<string, unknown>) => ({
|
||||
vesselInfoSn: Number(v['vessel_info_sn']),
|
||||
imoNo: String(v['imo_no'] ?? ''),
|
||||
vesselNm: String(v['vessel_nm'] ?? ''),
|
||||
vesselTp: String(v['vessel_tp'] ?? ''),
|
||||
loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null,
|
||||
breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null,
|
||||
draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null,
|
||||
gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null,
|
||||
dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null,
|
||||
builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null,
|
||||
flagCd: String(v['flag_cd'] ?? ''),
|
||||
callsign: String(v['callsign'] ?? ''),
|
||||
engineDc: String(v['engine_dc'] ?? ''),
|
||||
insuranceData: v['insurance_data'] ?? null,
|
||||
}));
|
||||
|
||||
const weather = weatherRows.map((w: Record<string, unknown>) => ({
|
||||
weatherDtm: String(w['weather_dtm'] ?? ''),
|
||||
windSpd: w['wind_spd'] != null ? parseFloat(String(w['wind_spd'])) : null,
|
||||
windDir: w['wind_dir'] != null ? String(w['wind_dir']) : null,
|
||||
waveHgt: w['wave_hgt'] != null ? parseFloat(String(w['wave_hgt'])) : null,
|
||||
currentSpd: w['current_spd'] != null ? parseFloat(String(w['current_spd'])) : null,
|
||||
currentDir: w['current_dir'] != null ? String(w['current_dir']) : null,
|
||||
temp: w['temp'] != null ? parseFloat(String(w['temp'])) : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
acdnt: {
|
||||
acdntSn: Number(a['acdnt_sn']),
|
||||
acdntNm: String(a['acdnt_nm'] ?? ''),
|
||||
occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '',
|
||||
lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null,
|
||||
lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null,
|
||||
location: String(a['loc_dc'] ?? ''),
|
||||
analyst: String(a['analyst_nm'] ?? ''),
|
||||
officeName: String(a['office_nm'] ?? ''),
|
||||
},
|
||||
spill,
|
||||
vessels,
|
||||
weather,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBacktrack(sn: number): Promise<BacktrackResult | null> {
|
||||
const sql = `
|
||||
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
||||
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
||||
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
||||
FROM BACKTRACK
|
||||
WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y'
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [sn]);
|
||||
if (rows.length === 0) return null;
|
||||
return rowToBacktrack(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
|
||||
const sql = `
|
||||
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
||||
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
||||
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
||||
FROM BACKTRACK
|
||||
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
||||
ORDER BY REG_DTM DESC
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
return rows.map((r: Record<string, unknown>) => rowToBacktrack(r));
|
||||
}
|
||||
|
||||
function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
||||
return {
|
||||
backtrackSn: Number(r['backtrack_sn']),
|
||||
acdntSn: Number(r['acdnt_sn']),
|
||||
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
|
||||
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
||||
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
||||
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
|
||||
srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null,
|
||||
totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null,
|
||||
execSttsCd: String(r['exec_stts_cd'] ?? ''),
|
||||
rsltData: r['rslt_data'] ?? null,
|
||||
regDtm: String(r['reg_dtm'] ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBacktrack(
|
||||
input: CreateBacktrackInput,
|
||||
): Promise<{ backtrackSn: number }> {
|
||||
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
|
||||
$3 || ' + ' || $2,
|
||||
$4, $5, $6, 'PENDING'
|
||||
)
|
||||
RETURNING BACKTRACK_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [
|
||||
acdntSn, lat, lon,
|
||||
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
||||
]);
|
||||
|
||||
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
|
||||
}
|
||||
|
||||
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
||||
const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT)
|
||||
VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6)
|
||||
RETURNING BOOM_LINE_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [
|
||||
acdntSn, boomNm, priorityOrd,
|
||||
JSON.stringify(geojson),
|
||||
lengthM || null, efficiencyPct || null,
|
||||
]);
|
||||
|
||||
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
|
||||
}
|
||||
|
||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||
const sql = `
|
||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||
ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM
|
||||
FROM BOOM_LINE
|
||||
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
||||
ORDER BY PRIORITY_ORD ASC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
boomLineSn: Number(r['boom_line_sn']),
|
||||
acdntSn: Number(r['acdnt_sn']),
|
||||
boomNm: String(r['boom_nm'] ?? ''),
|
||||
priorityOrd: Number(r['priority_ord'] ?? 0),
|
||||
geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null,
|
||||
lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null,
|
||||
efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null,
|
||||
sttsCd: String(r['stts_cd'] ?? 'PLANNED'),
|
||||
regDtm: String(r['reg_dtm'] ?? ''),
|
||||
}));
|
||||
}
|
||||
66
backend/src/rescue/rescueRouter.ts
Normal file
66
backend/src/rescue/rescueRouter.ts
Normal file
@ -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;
|
||||
217
backend/src/rescue/rescueService.ts
Normal file
217
backend/src/rescue/rescueService.ts
Normal file
@ -0,0 +1,217 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
// ============================================================
|
||||
// 인터페이스
|
||||
// ============================================================
|
||||
|
||||
interface RescueOpsListItem {
|
||||
rescueOpsSn: number;
|
||||
acdntSn: number | null;
|
||||
opsCd: string;
|
||||
acdntTpCd: string | null;
|
||||
vesselNm: string | null;
|
||||
commanderNm: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
locDc: string | null;
|
||||
depthM: number | null;
|
||||
currentDc: string | null;
|
||||
gmM: number | null;
|
||||
listDeg: number | null;
|
||||
trimM: number | null;
|
||||
buoyancyPct: number | null;
|
||||
oilRateLpm: number | null;
|
||||
bmRatioPct: number | null;
|
||||
totalCrew: number | null;
|
||||
survivors: number | null;
|
||||
missing: number | null;
|
||||
sttsCd: string;
|
||||
regDtm: string;
|
||||
mdfcnDtm: string;
|
||||
}
|
||||
|
||||
interface RescueOpsDetail extends RescueOpsListItem {
|
||||
hydroData: Record<string, unknown> | null;
|
||||
gmdssData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
interface RescueScenarioItem {
|
||||
scenarioSn: number;
|
||||
rescueOpsSn: number;
|
||||
timeStep: string;
|
||||
scenarioDtm: string | null;
|
||||
svrtCd: string | null;
|
||||
gmM: number | null;
|
||||
listDeg: number | null;
|
||||
trimM: number | null;
|
||||
buoyancyPct: number | null;
|
||||
oilRateLpm: number | null;
|
||||
bmRatioPct: number | null;
|
||||
description: string | null;
|
||||
compartments: unknown[] | null;
|
||||
assessment: unknown[] | null;
|
||||
actions: unknown[] | null;
|
||||
sortOrd: number;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListOpsInput {
|
||||
sttsCd?: string;
|
||||
acdntTpCd?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 구조 작전 목록 조회
|
||||
// ============================================================
|
||||
|
||||
export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]> {
|
||||
const conditions: string[] = ["USE_YN = 'Y'"];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (input?.sttsCd) {
|
||||
conditions.push(`STTS_CD = $${idx++}`);
|
||||
params.push(input.sttsCd);
|
||||
}
|
||||
if (input?.acdntTpCd) {
|
||||
conditions.push(`ACDNT_TP_CD = $${idx++}`);
|
||||
params.push(input.acdntTpCd);
|
||||
}
|
||||
if (input?.search) {
|
||||
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
|
||||
params.push(input.search);
|
||||
}
|
||||
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
RESCUE_OPS_SN, ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM,
|
||||
LON, LAT, LOC_DC, DEPTH_M, CURRENT_DC,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
TOTAL_CREW, SURVIVORS, MISSING,
|
||||
STTS_CD, REG_DTM, MDFCN_DTM
|
||||
FROM wing.RESCUE_OPS
|
||||
${where}
|
||||
ORDER BY REG_DTM DESC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
rescueOpsSn: r.rescue_ops_sn as number,
|
||||
acdntSn: (r.acdnt_sn as number) ?? null,
|
||||
opsCd: r.ops_cd as string,
|
||||
acdntTpCd: (r.acdnt_tp_cd as string) ?? null,
|
||||
vesselNm: (r.vessel_nm as string) ?? null,
|
||||
commanderNm: (r.commander_nm as string) ?? null,
|
||||
lon: r.lon != null ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat != null ? parseFloat(r.lat as string) : null,
|
||||
locDc: (r.loc_dc as string) ?? null,
|
||||
depthM: r.depth_m != null ? parseFloat(r.depth_m as string) : null,
|
||||
currentDc: (r.current_dc as string) ?? null,
|
||||
gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null,
|
||||
listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null,
|
||||
trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null,
|
||||
buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null,
|
||||
oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null,
|
||||
bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null,
|
||||
totalCrew: (r.total_crew as number) ?? null,
|
||||
survivors: (r.survivors as number) ?? null,
|
||||
missing: (r.missing as number) ?? null,
|
||||
sttsCd: r.stts_cd as string,
|
||||
regDtm: (r.reg_dtm as Date).toISOString(),
|
||||
mdfcnDtm: (r.mdfcn_dtm as Date).toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 구조 작전 단건 상세 조회
|
||||
// ============================================================
|
||||
|
||||
export async function getOps(sn: number): Promise<RescueOpsDetail | null> {
|
||||
const sql = `
|
||||
SELECT
|
||||
RESCUE_OPS_SN, ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM,
|
||||
LON, LAT, LOC_DC, DEPTH_M, CURRENT_DC,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
TOTAL_CREW, SURVIVORS, MISSING,
|
||||
HYDRO_DATA, GMDSS_DATA,
|
||||
STTS_CD, REG_DTM, MDFCN_DTM
|
||||
FROM wing.RESCUE_OPS
|
||||
WHERE RESCUE_OPS_SN = $1 AND USE_YN = 'Y'
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [sn]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const r = rows[0] as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
rescueOpsSn: r.rescue_ops_sn as number,
|
||||
acdntSn: (r.acdnt_sn as number) ?? null,
|
||||
opsCd: r.ops_cd as string,
|
||||
acdntTpCd: (r.acdnt_tp_cd as string) ?? null,
|
||||
vesselNm: (r.vessel_nm as string) ?? null,
|
||||
commanderNm: (r.commander_nm as string) ?? null,
|
||||
lon: r.lon != null ? parseFloat(r.lon as string) : null,
|
||||
lat: r.lat != null ? parseFloat(r.lat as string) : null,
|
||||
locDc: (r.loc_dc as string) ?? null,
|
||||
depthM: r.depth_m != null ? parseFloat(r.depth_m as string) : null,
|
||||
currentDc: (r.current_dc as string) ?? null,
|
||||
gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null,
|
||||
listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null,
|
||||
trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null,
|
||||
buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null,
|
||||
oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null,
|
||||
bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null,
|
||||
totalCrew: (r.total_crew as number) ?? null,
|
||||
survivors: (r.survivors as number) ?? null,
|
||||
missing: (r.missing as number) ?? null,
|
||||
hydroData: (r.hydro_data as Record<string, unknown>) ?? null,
|
||||
gmdssData: (r.gmdss_data as Record<string, unknown>) ?? null,
|
||||
sttsCd: r.stts_cd as string,
|
||||
regDtm: (r.reg_dtm as Date).toISOString(),
|
||||
mdfcnDtm: (r.mdfcn_dtm as Date).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 시나리오 목록 조회
|
||||
// ============================================================
|
||||
|
||||
export async function listScenarios(rescueOpsSn: number): Promise<RescueScenarioItem[]> {
|
||||
const sql = `
|
||||
SELECT
|
||||
SCENARIO_SN, RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS,
|
||||
SORT_ORD, REG_DTM
|
||||
FROM wing.RESCUE_SCENARIO
|
||||
WHERE RESCUE_OPS_SN = $1
|
||||
ORDER BY SORT_ORD ASC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [rescueOpsSn]);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
scenarioSn: r.scenario_sn as number,
|
||||
rescueOpsSn: r.rescue_ops_sn as number,
|
||||
timeStep: r.time_step as string,
|
||||
scenarioDtm: r.scenario_dtm != null ? (r.scenario_dtm as Date).toISOString() : null,
|
||||
svrtCd: (r.svrt_cd as string) ?? null,
|
||||
gmM: r.gm_m != null ? parseFloat(r.gm_m as string) : null,
|
||||
listDeg: r.list_deg != null ? parseFloat(r.list_deg as string) : null,
|
||||
trimM: r.trim_m != null ? parseFloat(r.trim_m as string) : null,
|
||||
buoyancyPct: r.buoyancy_pct != null ? parseFloat(r.buoyancy_pct as string) : null,
|
||||
oilRateLpm: r.oil_rate_lpm != null ? parseFloat(r.oil_rate_lpm as string) : null,
|
||||
bmRatioPct: r.bm_ratio_pct != null ? parseFloat(r.bm_ratio_pct as string) : null,
|
||||
description: (r.description as string) ?? null,
|
||||
compartments: (r.compartments as unknown[]) ?? null,
|
||||
assessment: (r.assessment as unknown[]) ?? null,
|
||||
actions: (r.actions as unknown[]) ?? null,
|
||||
sortOrd: r.sort_ord as number,
|
||||
regDtm: (r.reg_dtm as Date).toISOString(),
|
||||
}));
|
||||
}
|
||||
@ -19,6 +19,9 @@ import reportsRouter from './reports/reportsRouter.js'
|
||||
import assetsRouter from './assets/assetsRouter.js'
|
||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||
import scatRouter from './scat/scatRouter.js'
|
||||
import predictionRouter from './prediction/predictionRouter.js'
|
||||
import aerialRouter from './aerial/aerialRouter.js'
|
||||
import rescueRouter from './rescue/rescueRouter.js'
|
||||
import {
|
||||
sanitizeBody,
|
||||
sanitizeQuery,
|
||||
@ -149,6 +152,9 @@ app.use('/api/reports', reportsRouter)
|
||||
app.use('/api/assets', assetsRouter)
|
||||
app.use('/api/incidents', incidentsRouter)
|
||||
app.use('/api/scat', scatRouter)
|
||||
app.use('/api/prediction', predictionRouter)
|
||||
app.use('/api/aerial', aerialRouter)
|
||||
app.use('/api/rescue', rescueRouter)
|
||||
|
||||
// 헬스 체크
|
||||
app.get('/health', (_req, res) => {
|
||||
|
||||
51
database/migration/012_board_ext.sql
Normal file
51
database/migration/012_board_ext.sql
Normal file
@ -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);
|
||||
130
database/migration/013_hns_analysis.sql
Normal file
130
database/migration/013_hns_analysis.sql
Normal file
@ -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
|
||||
);
|
||||
111
database/migration/014_prediction.sql
Normal file
111
database/migration/014_prediction.sql
Normal file
@ -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
|
||||
);
|
||||
128
database/migration/015_aerial.sql
Normal file
128
database/migration/015_aerial.sql
Normal file
@ -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');
|
||||
182
database/migration/016_rescue.sql
Normal file
182
database/migration/016_rescue.sql
Normal file
@ -0,0 +1,182 @@
|
||||
-- ============================================================
|
||||
-- 016_rescue.sql — 구조 시나리오(Rescue) 탭 테이블 + 초기 데이터
|
||||
-- RESCUE_OPS (5건), RESCUE_SCENARIO (5건)
|
||||
-- ============================================================
|
||||
|
||||
SET search_path TO wing, public;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. RESCUE_OPS — 구조 작전 정보
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS RESCUE_OPS (
|
||||
RESCUE_OPS_SN SERIAL PRIMARY KEY,
|
||||
ACDNT_SN INTEGER REFERENCES ACDNT(ACDNT_SN),
|
||||
OPS_CD VARCHAR(20) NOT NULL UNIQUE,
|
||||
ACDNT_TP_CD VARCHAR(20),
|
||||
VESSEL_NM VARCHAR(100),
|
||||
COMMANDER_NM VARCHAR(50),
|
||||
LON NUMERIC(10,6),
|
||||
LAT NUMERIC(9,6),
|
||||
GEOM GEOMETRY(Point, 4326),
|
||||
LOC_DC VARCHAR(100),
|
||||
DEPTH_M NUMERIC(6,1),
|
||||
CURRENT_DC VARCHAR(50),
|
||||
GM_M NUMERIC(5,2),
|
||||
LIST_DEG NUMERIC(5,1),
|
||||
TRIM_M NUMERIC(5,2),
|
||||
BUOYANCY_PCT NUMERIC(5,1),
|
||||
OIL_RATE_LPM NUMERIC(8,1),
|
||||
BM_RATIO_PCT NUMERIC(5,1),
|
||||
TOTAL_CREW INTEGER,
|
||||
SURVIVORS INTEGER,
|
||||
MISSING INTEGER,
|
||||
HYDRO_DATA JSONB,
|
||||
GMDSS_DATA JSONB,
|
||||
STTS_CD VARCHAR(20) DEFAULT 'ACTIVE',
|
||||
USE_YN CHAR(1) DEFAULT 'Y',
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW(),
|
||||
MDFCN_DTM TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rescue_ops_acdnt ON RESCUE_OPS(ACDNT_SN);
|
||||
CREATE INDEX IF NOT EXISTS idx_rescue_ops_geom ON RESCUE_OPS USING GIST(GEOM);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. RESCUE_SCENARIO — 시나리오 타임스텝
|
||||
-- ============================================================
|
||||
CREATE TABLE IF NOT EXISTS RESCUE_SCENARIO (
|
||||
SCENARIO_SN SERIAL PRIMARY KEY,
|
||||
RESCUE_OPS_SN INTEGER NOT NULL REFERENCES RESCUE_OPS(RESCUE_OPS_SN) ON DELETE CASCADE,
|
||||
TIME_STEP VARCHAR(10) NOT NULL,
|
||||
SCENARIO_DTM TIMESTAMPTZ,
|
||||
SVRT_CD VARCHAR(20),
|
||||
GM_M NUMERIC(5,2),
|
||||
LIST_DEG NUMERIC(5,1),
|
||||
TRIM_M NUMERIC(5,2),
|
||||
BUOYANCY_PCT NUMERIC(5,1),
|
||||
OIL_RATE_LPM NUMERIC(8,1),
|
||||
BM_RATIO_PCT NUMERIC(5,1),
|
||||
DESCRIPTION TEXT,
|
||||
COMPARTMENTS JSONB,
|
||||
ASSESSMENT JSONB,
|
||||
ACTIONS JSONB,
|
||||
SORT_ORD INTEGER DEFAULT 0,
|
||||
REG_DTM TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rescue_scenario_ops ON RESCUE_SCENARIO(RESCUE_OPS_SN);
|
||||
|
||||
-- ============================================================
|
||||
-- 3. RESCUE_OPS 시드 데이터 (5건)
|
||||
-- ============================================================
|
||||
INSERT INTO RESCUE_OPS (
|
||||
ACDNT_SN, OPS_CD, ACDNT_TP_CD, VESSEL_NM, COMMANDER_NM,
|
||||
LON, LAT, GEOM, LOC_DC, DEPTH_M, CURRENT_DC,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
TOTAL_CREW, SURVIVORS, MISSING,
|
||||
HYDRO_DATA, GMDSS_DATA,
|
||||
STTS_CD, USE_YN
|
||||
) VALUES
|
||||
(
|
||||
1, 'RSC-2026-001', 'collision', 'M/V SEA GUARDIAN', NULL,
|
||||
126.25, 37.467,
|
||||
ST_SetSRID(ST_MakePoint(126.25::float, 37.467::float), 4326),
|
||||
'37°28''N, 126°15''E', 25.0, '2.5kn NE',
|
||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||
20, 15, 5,
|
||||
'{"displacement":"8,420t","draft":"5.8m","kg":"6.2m","km":"6.9m","tpc":"18.5","mtc":"195"}',
|
||||
'{"mmsi":"440234567","dscAlert":"VHF Ch.16 자동발신"}',
|
||||
'ACTIVE', 'Y'
|
||||
),
|
||||
(
|
||||
2, 'RSC-2026-002', 'grounding', 'M/V OCEAN BREEZE', NULL,
|
||||
128.62, 35.09,
|
||||
ST_SetSRID(ST_MakePoint(128.62::float, 35.09::float), 4326),
|
||||
'35°05''N, 128°37''E', 12.0, '1.8kn SW',
|
||||
0.3, 22.0, 4.8, 15.0, 250.0, 78.0,
|
||||
18, 18, 0,
|
||||
NULL, NULL,
|
||||
'ACTIVE', 'Y'
|
||||
),
|
||||
(
|
||||
3, 'RSC-2026-003', 'flooding', 'F/V DONG JIN', NULL,
|
||||
129.08, 35.15,
|
||||
ST_SetSRID(ST_MakePoint(129.08::float, 35.15::float), 4326),
|
||||
'35°09''N, 129°05''E', 30.0, '3.0kn N',
|
||||
0.5, 8.0, 1.5, 45.0, 50.0, 85.0,
|
||||
8, 7, 1,
|
||||
NULL, NULL,
|
||||
'ACTIVE', 'Y'
|
||||
),
|
||||
(
|
||||
4, 'RSC-2025-045', 'capsizing', 'M/V PACIFIC STAR', NULL,
|
||||
126.57, 33.24,
|
||||
ST_SetSRID(ST_MakePoint(126.57::float, 33.24::float), 4326),
|
||||
'33°14''N, 126°34''E', 45.0, '2.0kn SE',
|
||||
0.1, 35.0, 6.0, 10.0, 300.0, 65.0,
|
||||
25, 20, 5,
|
||||
NULL, NULL,
|
||||
'RESOLVED', 'Y'
|
||||
),
|
||||
(
|
||||
5, 'RSC-2025-040', 'turning', 'M/V GOLDEN WAVE', NULL,
|
||||
127.68, 34.73,
|
||||
ST_SetSRID(ST_MakePoint(127.68::float, 34.73::float), 4326),
|
||||
'34°44''N, 127°41''E', 18.0, '1.5kn W',
|
||||
1.2, 5.0, 0.8, 65.0, 20.0, 95.0,
|
||||
12, 12, 0,
|
||||
NULL, NULL,
|
||||
'RESOLVED', 'Y'
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 4. RESCUE_SCENARIO 시드 데이터 (5건, RESCUE_OPS_SN=1 기준)
|
||||
-- ============================================================
|
||||
INSERT INTO RESCUE_SCENARIO (
|
||||
RESCUE_OPS_SN, TIME_STEP, SCENARIO_DTM, SVRT_CD,
|
||||
GM_M, LIST_DEG, TRIM_M, BUOYANCY_PCT, OIL_RATE_LPM, BM_RATIO_PCT,
|
||||
DESCRIPTION, COMPARTMENTS, ASSESSMENT, ACTIONS, SORT_ORD
|
||||
) VALUES
|
||||
(
|
||||
1, 'T+0h', '2024-10-27 10:30:00+09', 'CRITICAL',
|
||||
0.8, 15.0, 2.5, 30.0, 100.0, 92.0,
|
||||
'좌현 35° 충돌로 No.1P 화물탱크 파공, 벙커C유 유출 개시. 좌현 경사 15°, GM 위험수준.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"BREACHED","color":"var(--red)"},{"name":"#2 Port Tank","status":"RISK","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM < 1.0m)","color":"var(--red)"},{"label":"유출 위험","value":"활발 유출중","color":"var(--red)"},{"label":"선체 강도","value":"BM 92% (경계)","color":"var(--orange)"},{"label":"승선인원","value":"15/20 확인, 5명 수색중","color":"var(--red)"}]',
|
||||
'[{"time":"10:30","text":"충돌 발생, VHF Ch.16 조난 통보","color":"var(--red)"},{"time":"10:35","text":"해경 3009함 출동 지시","color":"var(--orange)"},{"time":"10:42","text":"인근 선박 구조 활동 개시","color":"var(--cyan)"},{"time":"10:50","text":"유출유 방제선 배치 요청","color":"var(--orange)"}]',
|
||||
1
|
||||
),
|
||||
(
|
||||
1, 'T+2h', '2024-10-27 12:30:00+09', 'HIGH',
|
||||
0.6, 18.0, 3.2, 25.0, 150.0, 88.0,
|
||||
'침수 확대로 경사 증가, 유출량 증가 추세. 긴급 이초 작업 검토 필요.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"위험 (GM 0.6m)","color":"var(--red)"},{"label":"유출 위험","value":"증가 추세","color":"var(--red)"},{"label":"선체 강도","value":"BM 88%","color":"var(--orange)"},{"label":"승선인원","value":"전원 퇴선 완료","color":"var(--green)"}]',
|
||||
'[{"time":"12:00","text":"2차 침수 확인 (#2 PT)","color":"var(--red)"},{"time":"12:15","text":"긴급 이초 작업 개시","color":"var(--orange)"},{"time":"12:20","text":"오일펜스 1차 전개 완료","color":"var(--cyan)"},{"time":"12:30","text":"항공기 유출유 촬영 요청","color":"var(--cyan)"}]',
|
||||
2
|
||||
),
|
||||
(
|
||||
1, 'T+6h', '2024-10-27 16:30:00+09', 'HIGH',
|
||||
0.4, 12.0, 2.8, 35.0, 80.0, 90.0,
|
||||
'평형수 이동으로 경사 일부 복원. 유출률 감소 추세.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"Engine Room","status":"RISK","color":"var(--orange)"},{"name":"#3 Stbd Tank","status":"RISK","color":"var(--orange)"}]',
|
||||
'[{"label":"복원력","value":"개선 추세 (GM 0.4m)","color":"var(--orange)"},{"label":"유출 위험","value":"감소 추세","color":"var(--orange)"},{"label":"선체 강도","value":"BM 90%","color":"var(--orange)"},{"label":"구조 상황","value":"구조 작전 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"14:00","text":"평형수 이동 작업 개시","color":"var(--cyan)"},{"time":"15:00","text":"해상크레인 도착","color":"var(--cyan)"},{"time":"15:30","text":"잔류유 이적 작업 개시","color":"var(--orange)"},{"time":"16:30","text":"예인준비 완료","color":"var(--green)"}]',
|
||||
3
|
||||
),
|
||||
(
|
||||
1, 'T+12h', '2024-10-27 22:30:00+09', 'MEDIUM',
|
||||
0.6, 8.0, 1.5, 50.0, 30.0, 94.0,
|
||||
'예인 작업 진행중, 선체 안정화 확인. 유출 대부분 차단.',
|
||||
'[{"name":"#1 FP Tank","status":"FLOODED","color":"var(--red)"},{"name":"#1 Port Tank","status":"FLOODED","color":"var(--red)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안정 (GM 0.6m)","color":"var(--orange)"},{"label":"유출 위험","value":"대부분 차단","color":"var(--green)"},{"label":"선체 강도","value":"BM 94%","color":"var(--green)"},{"label":"예인 상태","value":"목포항 예인 진행중","color":"var(--cyan)"}]',
|
||||
'[{"time":"18:00","text":"예인 개시 (목포항 방향)","color":"var(--cyan)"},{"time":"19:00","text":"유출유 차단 확인","color":"var(--green)"},{"time":"20:00","text":"야간 감시 체제 전환","color":"var(--orange)"},{"time":"22:30","text":"예인 50% 진행","color":"var(--cyan)"}]',
|
||||
4
|
||||
),
|
||||
(
|
||||
1, 'T+24h', '2024-10-28 10:30:00+09', 'RESOLVED',
|
||||
1.2, 3.0, 0.5, 75.0, 5.0, 98.0,
|
||||
'목포항 도착, 선체 안정. 잔류유 이적 완료.',
|
||||
'[{"name":"#1 FP Tank","status":"SEALED","color":"var(--orange)"},{"name":"#1 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"#2 Port Tank","status":"SEALED","color":"var(--orange)"},{"name":"Engine Room","status":"INTACT","color":"var(--green)"},{"name":"#3 Stbd Tank","status":"INTACT","color":"var(--green)"}]',
|
||||
'[{"label":"복원력","value":"안전 (GM 1.2m)","color":"var(--green)"},{"label":"유출 위험","value":"차단 완료","color":"var(--green)"},{"label":"선체 강도","value":"BM 98% 정상","color":"var(--green)"},{"label":"예인 상태","value":"목포항 접안 완료","color":"var(--green)"}]',
|
||||
'[{"time":"06:00","text":"목포항 접근","color":"var(--cyan)"},{"time":"08:00","text":"도선사 승선, 접안 개시","color":"var(--cyan)"},{"time":"09:30","text":"접안 완료","color":"var(--green)"},{"time":"10:30","text":"잔류유 이적 완료, 상황 종료","color":"var(--green)"}]',
|
||||
5
|
||||
);
|
||||
@ -1,126 +0,0 @@
|
||||
import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
|
||||
export const MOCK_CONDITIONS: BacktrackConditions = {
|
||||
estimatedSpillTime: '02-10 06:30',
|
||||
analysisRange: '±12시간',
|
||||
searchRadius: '10 NM',
|
||||
spillLocation: { lat: 34.7312, lon: 127.6845 },
|
||||
totalVessels: 47,
|
||||
}
|
||||
|
||||
export const MOCK_VESSELS: BacktrackVessel[] = [
|
||||
{
|
||||
rank: 1,
|
||||
name: 'ORIENTAL GLORY',
|
||||
imo: '9412856',
|
||||
type: '유조선',
|
||||
flag: '🇰🇷',
|
||||
flagCountry: '대한민국',
|
||||
probability: 96.7,
|
||||
closestTime: '06:28',
|
||||
closestDistance: 0.02,
|
||||
speedChange: '급감속',
|
||||
aisStatus: '충돌신호',
|
||||
description: '06:28 HAI FENG 168과 충돌 → 06:30 No.1P 탱크 파공 → 벙커C유 유출 개시. AIS 충돌경보 발신, 속력 8.3kts→0.2kts 급감속.',
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
name: 'HAI FENG 168',
|
||||
imo: '9538721',
|
||||
type: '벌크선',
|
||||
flag: '🇨🇳',
|
||||
flagCountry: '중국',
|
||||
probability: 23.4,
|
||||
closestTime: '06:28',
|
||||
closestDistance: 0.02,
|
||||
speedChange: '급감속',
|
||||
aisStatus: '미확인',
|
||||
description: '충돌 당사선. 구상선수 손상으로 연료유탱크 미세 누유 가능성. 자체 연료(벙커C) 1,200톤 적재.',
|
||||
color: '#f97316',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
name: 'DONG JIN STAR',
|
||||
imo: '9287403',
|
||||
type: '케미컬탱커',
|
||||
flag: '🇰🇷',
|
||||
flagCountry: '대한민국',
|
||||
probability: 4.1,
|
||||
closestTime: '05:45',
|
||||
closestDistance: 1.8,
|
||||
speedChange: '정상',
|
||||
aisStatus: '정상',
|
||||
description: '',
|
||||
color: '#64788c',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_REPLAY_SHIPS: ReplayShip[] = [
|
||||
{
|
||||
vesselName: 'ORIENTAL GLORY',
|
||||
color: '#ef4444',
|
||||
path: [
|
||||
{ lat: 34.82, lon: 127.58 },
|
||||
{ lat: 34.80, lon: 127.60 },
|
||||
{ lat: 34.78, lon: 127.62 },
|
||||
{ lat: 34.76, lon: 127.64 },
|
||||
{ lat: 34.75, lon: 127.66 },
|
||||
{ lat: 34.74, lon: 127.67 },
|
||||
{ lat: 34.73, lon: 127.68 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
],
|
||||
speedLabels: [
|
||||
'8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°',
|
||||
'8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°',
|
||||
'2.1 kts · 215°', '0.2 kts · 정지', '0.2 kts · 정지',
|
||||
],
|
||||
},
|
||||
{
|
||||
vesselName: 'HAI FENG 168',
|
||||
color: '#f97316',
|
||||
path: [
|
||||
{ lat: 34.64, lon: 127.78 },
|
||||
{ lat: 34.66, lon: 127.76 },
|
||||
{ lat: 34.68, lon: 127.74 },
|
||||
{ lat: 34.70, lon: 127.72 },
|
||||
{ lat: 34.71, lon: 127.71 },
|
||||
{ lat: 34.72, lon: 127.70 },
|
||||
{ lat: 34.73, lon: 127.69 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
{ lat: 34.7315, lon: 127.6840 },
|
||||
],
|
||||
speedLabels: [
|
||||
'11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°',
|
||||
'11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°',
|
||||
'3.4 kts · 038°', '0.5 kts · 정지', '0.5 kts · 정지',
|
||||
],
|
||||
},
|
||||
{
|
||||
vesselName: 'DONG JIN STAR',
|
||||
color: '#64788c',
|
||||
path: [
|
||||
{ lat: 34.82, lon: 127.52 },
|
||||
{ lat: 34.80, lon: 127.53 },
|
||||
{ lat: 34.78, lon: 127.54 },
|
||||
{ lat: 34.76, lon: 127.55 },
|
||||
{ lat: 34.74, lon: 127.56 },
|
||||
{ lat: 34.72, lon: 127.57 },
|
||||
{ lat: 34.70, lon: 127.58 },
|
||||
{ lat: 34.68, lon: 127.59 },
|
||||
{ lat: 34.66, lon: 127.60 },
|
||||
],
|
||||
speedLabels: [
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_COLLISION: CollisionEvent = {
|
||||
position: { lat: 34.7312, lon: 127.6845 },
|
||||
timeLabel: '06:28 충돌',
|
||||
progressPercent: 75,
|
||||
}
|
||||
@ -1,30 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CctvCamera {
|
||||
id: number
|
||||
name: string
|
||||
region: '제주' | '남해' | '서해' | '동해'
|
||||
location: string
|
||||
coord: string
|
||||
status: 'live' | 'offline'
|
||||
ptz: boolean
|
||||
source: string
|
||||
}
|
||||
|
||||
const cctvCameras: CctvCamera[] = [
|
||||
{ id: 1, name: '서귀포항 동측', region: '제주', location: '제주 서귀포시 서귀동', coord: '33.24°N 126.57°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 2, name: '제주항 입구', region: '제주', location: '제주 제주시 건입동', coord: '33.52°N 126.53°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 3, name: '성산포항', region: '제주', location: '제주 서귀포시 성산읍', coord: '33.46°N 126.93°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
{ id: 4, name: '모슬포항', region: '제주', location: '제주 서귀포시 대정읍', coord: '33.21°N 126.25°E', status: 'live', ptz: false, source: 'KBS' },
|
||||
{ id: 5, name: '여수 신항', region: '남해', location: '전남 여수시 웅천동', coord: '34.73°N 127.68°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 6, name: '통영항', region: '남해', location: '경남 통영시 항남동', coord: '34.84°N 128.43°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 7, name: '부산 감천항', region: '남해', location: '부산 서구 암남동', coord: '35.08°N 129.01°E', status: 'live', ptz: false, source: 'KBS' },
|
||||
{ id: 8, name: '목포 내항', region: '서해', location: '전남 목포시 항동', coord: '34.79°N 126.38°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 9, name: '군산 외항', region: '서해', location: '전북 군산시 소룡동', coord: '35.97°N 126.72°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
{ id: 10, name: '인천항 연안', region: '서해', location: '인천 중구 항동', coord: '37.45°N 126.60°E', status: 'offline', ptz: false, source: 'KBS' },
|
||||
{ id: 11, name: '동해항', region: '동해', location: '강원 동해시 송정동', coord: '37.52°N 129.12°E', status: 'live', ptz: true, source: 'TAGO' },
|
||||
{ id: 12, name: '포항 영일만', region: '동해', location: '경북 포항시 남구', coord: '36.02°N 129.38°E', status: 'live', ptz: false, source: 'TAGO' },
|
||||
]
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { fetchCctvCameras } from '../services/aerialApi'
|
||||
import type { CctvCameraItem } from '../services/aerialApi'
|
||||
|
||||
const cctvFavorites = [
|
||||
{ name: '서귀포항 동측', reason: '유출 사고 인접' },
|
||||
@ -33,28 +9,46 @@ const cctvFavorites = [
|
||||
]
|
||||
|
||||
export function CctvView() {
|
||||
const [cameras, setCameras] = useState<CctvCameraItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [regionFilter, setRegionFilter] = useState('전체')
|
||||
const [selectedCamera, setSelectedCamera] = useState<CctvCamera | null>(null)
|
||||
const [selectedCamera, setSelectedCamera] = useState<CctvCameraItem | null>(null)
|
||||
const [gridMode, setGridMode] = useState(1)
|
||||
const [activeCells, setActiveCells] = useState<CctvCamera[]>([])
|
||||
const [activeCells, setActiveCells] = useState<CctvCameraItem[]>([])
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchCctvCameras()
|
||||
setCameras(items)
|
||||
} catch (err) {
|
||||
console.error('[aerial] CCTV 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const regions = ['전체', '제주', '남해', '서해', '동해']
|
||||
const regionIcons: Record<string, string> = { '전체': '', '제주': '🌊', '남해': '⚓', '서해': '🐟', '동해': '🌅' }
|
||||
|
||||
const filtered = cctvCameras.filter(c => {
|
||||
if (regionFilter !== '전체' && c.region !== regionFilter) return false
|
||||
if (searchTerm && !c.name.includes(searchTerm) && !c.location.includes(searchTerm)) return false
|
||||
const filtered = cameras.filter(c => {
|
||||
if (regionFilter !== '전체' && c.regionNm !== regionFilter) return false
|
||||
if (searchTerm && !c.cameraNm.includes(searchTerm) && !(c.locDc ?? '').includes(searchTerm)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const handleSelectCamera = (cam: CctvCamera) => {
|
||||
const handleSelectCamera = (cam: CctvCameraItem) => {
|
||||
setSelectedCamera(cam)
|
||||
if (gridMode === 1) {
|
||||
setActiveCells([cam])
|
||||
} else {
|
||||
setActiveCells(prev => {
|
||||
if (prev.length < gridMode && !prev.find(c => c.id === cam.id)) return [...prev, cam]
|
||||
if (prev.length < gridMode && !prev.find(c => c.cctvSn === cam.cctvSn)) return [...prev, cam]
|
||||
return prev
|
||||
})
|
||||
}
|
||||
@ -114,31 +108,33 @@ export function CctvView() {
|
||||
|
||||
{/* 카메라 목록 */}
|
||||
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
{filtered.map(cam => (
|
||||
{loading ? (
|
||||
<div className="px-3.5 py-4 text-[11px] text-text-3 font-korean">불러오는 중...</div>
|
||||
) : filtered.map(cam => (
|
||||
<div
|
||||
key={cam.id}
|
||||
key={cam.cctvSn}
|
||||
onClick={() => handleSelectCamera(cam)}
|
||||
className="flex items-center gap-2.5 px-3.5 py-2.5 border-b cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderColor: 'rgba(255,255,255,.04)',
|
||||
background: selectedCamera?.id === cam.id ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
background: selectedCamera?.cctvSn === cam.cctvSn ? 'rgba(6,182,212,.08)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<div className="w-8 h-8 rounded-md bg-bg-3 flex items-center justify-center text-sm">📹</div>
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.status === 'live' ? 'var(--green)' : 'var(--t3)' }} />
|
||||
<div className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-bg-1" style={{ background: cam.sttsCd === 'LIVE' ? 'var(--green)' : 'var(--t3)' }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.name}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean truncate">{cam.location}</div>
|
||||
<div className="text-[11px] font-semibold text-text-1 font-korean truncate">{cam.cameraNm}</div>
|
||||
<div className="text-[9px] text-text-3 font-korean truncate">{cam.locDc ?? ''}</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-0.5 shrink-0">
|
||||
{cam.status === 'live' ? (
|
||||
{cam.sttsCd === 'LIVE' ? (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(34,197,94,.12)', color: 'var(--green)' }}>LIVE</span>
|
||||
) : (
|
||||
<span className="text-[8px] font-bold px-1.5 py-px rounded-full" style={{ background: 'rgba(255,255,255,.06)', color: 'var(--t3)' }}>OFF</span>
|
||||
)}
|
||||
{cam.ptz && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
|
||||
{cam.ptzYn === 'Y' && <span className="text-[8px] text-text-3 font-mono">PTZ</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@ -151,9 +147,9 @@ export function CctvView() {
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-2 shrink-0 gap-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-xs font-bold text-text-1 font-korean whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{selectedCamera ? `📹 ${selectedCamera.name}` : '📹 카메라를 선택하세요'}
|
||||
{selectedCamera ? `📹 ${selectedCamera.cameraNm}` : '📹 카메라를 선택하세요'}
|
||||
</div>
|
||||
{selectedCamera?.status === 'live' && (
|
||||
{selectedCamera?.sttsCd === 'LIVE' && (
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 rounded-full text-[9px] font-bold shrink-0" style={{ background: 'rgba(239,68,68,.14)', border: '1px solid rgba(239,68,68,.35)', color: 'var(--red)' }}>
|
||||
<span className="w-[5px] h-[5px] rounded-full inline-block animate-pulse" style={{ background: 'var(--red)' }} />LIVE
|
||||
</div>
|
||||
@ -161,7 +157,7 @@ export function CctvView() {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* PTZ 컨트롤 */}
|
||||
{selectedCamera?.ptz && (
|
||||
{selectedCamera?.ptzYn === 'Y' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-bg-3 border border-border rounded-[5px]">
|
||||
<span className="text-[9px] text-text-3 font-korean mr-1">PTZ</span>
|
||||
{['◀', '▲', '▼', '▶'].map((d, i) => (
|
||||
@ -213,11 +209,11 @@ export function CctvView() {
|
||||
<div className="text-4xl opacity-20">📹</div>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 flex items-center gap-1.5">
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.name}</span>
|
||||
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t1)' }}>{cam.cameraNm}</span>
|
||||
<span className="text-[8px] font-bold px-1 py-0.5 rounded" style={{ background: 'rgba(239,68,68,.3)', color: '#f87171' }}>● REC</span>
|
||||
</div>
|
||||
<div className="absolute bottom-2 left-2 text-[9px] font-mono px-1.5 py-0.5 rounded" style={{ background: 'rgba(0,0,0,.7)', color: 'var(--t3)' }}>
|
||||
{cam.coord} · {cam.source}
|
||||
{cam.coordDc ?? ''} · {cam.sourceNm ?? ''}
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center text-[11px] font-korean text-text-3 opacity-60">
|
||||
CCTV 스트리밍 영역
|
||||
@ -233,9 +229,9 @@ export function CctvView() {
|
||||
|
||||
{/* 하단 정보 바 */}
|
||||
<div className="flex items-center gap-3.5 px-4 py-1.5 border-t border-border bg-bg-2 shrink-0">
|
||||
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedCamera?.name ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">위치: <span className="text-text-2">{selectedCamera?.location ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">좌표: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coord ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">선택: <b className="text-text-1">{selectedCamera?.cameraNm ?? '–'}</b></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">위치: <span className="text-text-2">{selectedCamera?.locDc ?? '–'}</span></div>
|
||||
<div className="text-[10px] text-text-3 font-korean">좌표: <span className="text-primary-cyan font-mono text-[9px]">{selectedCamera?.coordDc ?? '–'}</span></div>
|
||||
<div className="ml-auto text-[9px] text-text-3 font-korean">API: 국립해양조사원 TAGO 해양 CCTV</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -275,13 +271,13 @@ export function CctvView() {
|
||||
{selectedCamera ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{[
|
||||
['카메라명', selectedCamera.name],
|
||||
['지역', selectedCamera.region],
|
||||
['위치', selectedCamera.location],
|
||||
['좌표', selectedCamera.coord],
|
||||
['상태', selectedCamera.status === 'live' ? '● 송출중' : '● 오프라인'],
|
||||
['PTZ', selectedCamera.ptz ? '지원' : '미지원'],
|
||||
['출처', selectedCamera.source],
|
||||
['카메라명', selectedCamera.cameraNm],
|
||||
['지역', selectedCamera.regionNm],
|
||||
['위치', selectedCamera.locDc ?? '—'],
|
||||
['좌표', selectedCamera.coordDc ?? '—'],
|
||||
['상태', selectedCamera.sttsCd === 'LIVE' ? '● 송출중' : '● 오프라인'],
|
||||
['PTZ', selectedCamera.ptzYn === 'Y' ? '지원' : '미지원'],
|
||||
['출처', selectedCamera.sourceNm ?? '—'],
|
||||
].map(([k, v], i) => (
|
||||
<div key={i} className="flex justify-between px-2 py-1 bg-bg-0 rounded text-[9px]">
|
||||
<span className="text-text-3 font-korean">{k}</span>
|
||||
|
||||
@ -1,38 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { fetchAerialMedia } from '../services/aerialApi'
|
||||
import type { AerialMediaItem } from '../services/aerialApi'
|
||||
|
||||
// ── Types & Mock Data ──
|
||||
// ── Helpers ──
|
||||
|
||||
interface MediaFile {
|
||||
id: number
|
||||
incident: string
|
||||
location: string
|
||||
filename: string
|
||||
equipment: string
|
||||
equipType: 'drone' | 'plane' | 'satellite'
|
||||
mediaType: '사진' | '영상' | '적외선' | 'SAR' | '가시광' | '광학'
|
||||
datetime: string
|
||||
size: string
|
||||
resolution: string
|
||||
function formatDtm(dtm: string | null): string {
|
||||
if (!dtm) return '—'
|
||||
const d = new Date(dtm)
|
||||
return d.toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const mediaFiles: MediaFile[] = [
|
||||
{ id: 1, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_001.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:20', size: '12.4 MB', resolution: '5472×3648' },
|
||||
{ id: 2, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_002.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:21', size: '11.8 MB', resolution: '5472×3648' },
|
||||
{ id: 3, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_003.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:22', size: '13.1 MB', resolution: '5472×3648' },
|
||||
{ id: 4, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_004.jpg', equipment: 'DJI M300', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:23', size: '12.9 MB', resolution: '5472×3648' },
|
||||
{ id: 5, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_005.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:24', size: '11.5 MB', resolution: '5472×3648' },
|
||||
{ id: 6, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론_006.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 15:25', size: '13.3 MB', resolution: '5472×3648' },
|
||||
{ id: 7, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_01.mp4', equipment: 'DJI M300', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 15:30', size: '842 MB', resolution: '4K 30fps' },
|
||||
{ id: 8, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_드론영상_02.mp4', equipment: 'Mavic3', equipType: 'drone', mediaType: '영상', datetime: '2026-01-18 16:00', size: '624 MB', resolution: '4K 30fps' },
|
||||
{ id: 9, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_01.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '적외선', datetime: '2026-01-18 14:00', size: '156 MB', resolution: '8192×6144' },
|
||||
{ id: 10, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공_광역_02.tif', equipment: 'CN-235', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 14:10', size: '148 MB', resolution: '8192×6144' },
|
||||
{ id: 11, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: '여수항_항공영상_01.mp4', equipment: 'B-512', equipType: 'plane', mediaType: '영상', datetime: '2026-01-18 14:30', size: '1.2 GB', resolution: 'FHD 60fps' },
|
||||
{ id: 12, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'Sentinel1_SAR_20260118.tif', equipment: 'Sentinel-1', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 10:00', size: '420 MB', resolution: '10m/px' },
|
||||
{ id: 13, incident: '여수 유조선 충돌', location: '34.73°N, 127.68°E', filename: 'KompSat5_여수_20260118.tif', equipment: '다목적5호', equipType: 'satellite', mediaType: 'SAR', datetime: '2026-01-18 11:00', size: '380 MB', resolution: '1m/px' },
|
||||
{ id: 14, incident: '통영 해역 기름오염', location: '34.85°N, 128.43°E', filename: '통영_드론_001.jpg', equipment: 'Mavic3', equipType: 'drone', mediaType: '사진', datetime: '2026-01-18 09:30', size: '10.2 MB', resolution: '5472×3648' },
|
||||
{ id: 15, incident: '군산항 인근 오염', location: '35.97°N, 126.72°E', filename: '군산_항공촬영_01.tif', equipment: 'B-512', equipType: 'plane', mediaType: '가시광', datetime: '2026-01-18 13:00', size: '132 MB', resolution: '8192×6144' },
|
||||
]
|
||||
|
||||
const equipIcon = (t: string) => t === 'drone' ? '🛸' : t === 'plane' ? '✈' : '🛰'
|
||||
|
||||
const equipTagCls = (t: string) =>
|
||||
@ -63,6 +40,8 @@ const FilterBtn = ({ label, active, onClick }: { label: string; active: boolean;
|
||||
// ── Component ──
|
||||
|
||||
export function MediaManagement() {
|
||||
const [mediaItems, setMediaItems] = useState<AerialMediaItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [equipFilter, setEquipFilter] = useState<string>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<Set<string>>(new Set())
|
||||
@ -71,6 +50,22 @@ export function MediaManagement() {
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchAerialMedia()
|
||||
setMediaItems(items)
|
||||
} catch (err) {
|
||||
console.error('[aerial] 미디어 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (modalRef.current && !modalRef.current.contains(e.target as Node)) {
|
||||
@ -81,22 +76,22 @@ export function MediaManagement() {
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [showUpload])
|
||||
|
||||
const filtered = mediaFiles.filter(f => {
|
||||
if (equipFilter !== 'all' && f.equipType !== equipFilter) return false
|
||||
const filtered = mediaItems.filter(f => {
|
||||
if (equipFilter !== 'all' && f.equipTpCd !== equipFilter) return false
|
||||
if (typeFilter.size > 0) {
|
||||
const isPhoto = !['영상'].includes(f.mediaType)
|
||||
const isVideo = f.mediaType === '영상'
|
||||
const isPhoto = f.mediaTpCd !== '영상'
|
||||
const isVideo = f.mediaTpCd === '영상'
|
||||
if (typeFilter.has('photo') && !isPhoto) return false
|
||||
if (typeFilter.has('video') && !isVideo) return false
|
||||
}
|
||||
if (searchTerm && !f.filename.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
if (searchTerm && !f.fileNm.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sortBy === 'name') return a.filename.localeCompare(b.filename)
|
||||
if (sortBy === 'size') return parseFloat(b.size) - parseFloat(a.size)
|
||||
return b.datetime.localeCompare(a.datetime)
|
||||
if (sortBy === 'name') return a.fileNm.localeCompare(b.fileNm)
|
||||
if (sortBy === 'size') return parseFloat(b.fileSz ?? '0') - parseFloat(a.fileSz ?? '0')
|
||||
return (b.takngDtm ?? '').localeCompare(a.takngDtm ?? '')
|
||||
})
|
||||
|
||||
const toggleId = (id: number) => {
|
||||
@ -111,7 +106,7 @@ export function MediaManagement() {
|
||||
if (selectedIds.size === sorted.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(sorted.map(f => f.id)))
|
||||
setSelectedIds(new Set(sorted.map(f => f.aerialMediaSn)))
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,9 +118,9 @@ export function MediaManagement() {
|
||||
})
|
||||
}
|
||||
|
||||
const droneCount = mediaFiles.filter(f => f.equipType === 'drone').length
|
||||
const planeCount = mediaFiles.filter(f => f.equipType === 'plane').length
|
||||
const satCount = mediaFiles.filter(f => f.equipType === 'satellite').length
|
||||
const droneCount = mediaItems.filter(f => f.equipTpCd === 'drone').length
|
||||
const planeCount = mediaItems.filter(f => f.equipTpCd === 'plane').length
|
||||
const satCount = mediaItems.filter(f => f.equipTpCd === 'satellite').length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@ -165,11 +160,11 @@ export function MediaManagement() {
|
||||
{/* Summary Stats */}
|
||||
<div className="flex gap-2.5 mb-4">
|
||||
{[
|
||||
{ icon: '📸', value: String(mediaFiles.length), label: '총 파일', color: 'text-primary-cyan' },
|
||||
{ icon: '🛸', value: String(droneCount), label: '드론', color: 'text-text-1' },
|
||||
{ icon: '✈', value: String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||||
{ icon: '🛰', value: String(satCount), label: '위성', color: 'text-text-1' },
|
||||
{ icon: '💾', value: '3.8 GB', label: '총 용량', color: 'text-text-1' },
|
||||
{ icon: '📸', value: loading ? '…' : String(mediaItems.length), label: '총 파일', color: 'text-primary-cyan' },
|
||||
{ icon: '🛸', value: loading ? '…' : String(droneCount), label: '드론', color: 'text-text-1' },
|
||||
{ icon: '✈', value: loading ? '…' : String(planeCount), label: '유인항공기', color: 'text-text-1' },
|
||||
{ icon: '🛰', value: loading ? '…' : String(satCount), label: '위성', color: 'text-text-1' },
|
||||
{ icon: '💾', value: '—', label: '총 용량', color: 'text-text-1' },
|
||||
].map((s, i) => (
|
||||
<div key={i} className="flex-1 flex items-center gap-2.5 px-4 py-3 bg-bg-3 border border-border rounded-sm">
|
||||
<span className="text-xl">{s.icon}</span>
|
||||
@ -221,39 +216,43 @@ export function MediaManagement() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map(f => (
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="px-4 py-8 text-center text-[11px] text-text-3 font-korean">불러오는 중...</td>
|
||||
</tr>
|
||||
) : sorted.map(f => (
|
||||
<tr
|
||||
key={f.id}
|
||||
onClick={() => toggleId(f.id)}
|
||||
key={f.aerialMediaSn}
|
||||
onClick={() => toggleId(f.aerialMediaSn)}
|
||||
className={`border-b border-border/50 cursor-pointer transition-colors hover:bg-[rgba(255,255,255,0.02)] ${
|
||||
selectedIds.has(f.id) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
selectedIds.has(f.aerialMediaSn) ? 'bg-[rgba(6,182,212,0.06)]' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(f.id)}
|
||||
onChange={() => toggleId(f.id)}
|
||||
checked={selectedIds.has(f.aerialMediaSn)}
|
||||
onChange={() => toggleId(f.aerialMediaSn)}
|
||||
className="accent-primary-blue"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipType)}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.incident}</td>
|
||||
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.location}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.filename}</td>
|
||||
<td className="px-1 py-2 text-base">{equipIcon(f.equipTpCd)}</td>
|
||||
<td className="px-2 py-2 text-[10px] font-semibold text-text-1 font-korean truncate">{f.acdntSn != null ? String(f.acdntSn) : '—'}</td>
|
||||
<td className="px-2 py-2 text-[10px] text-primary-cyan font-mono truncate">{f.locDc ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-semibold text-text-1 font-korean truncate">{f.fileNm}</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipType)}`}>
|
||||
{f.equipment}
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${equipTagCls(f.equipTpCd)}`}>
|
||||
{f.equipNm}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaType)}`}>
|
||||
{f.mediaType === '영상' ? '🎬' : '📷'} {f.mediaType}
|
||||
<span className={`px-1.5 py-0.5 rounded text-[9px] font-semibold font-korean ${mediaTagCls(f.mediaTpCd)}`}>
|
||||
{f.mediaTpCd === '영상' ? '🎬' : '📷'} {f.mediaTpCd}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.datetime}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.size}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{formatDtm(f.takngDtm)}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.fileSz ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-[11px] font-mono">{f.resolution ?? '—'}</td>
|
||||
<td className="px-2 py-2 text-center" onClick={e => e.stopPropagation()}>
|
||||
<button className="px-2 py-1 text-[10px] rounded bg-[rgba(6,182,212,0.1)] text-primary-cyan border border-primary-cyan/20 hover:bg-[rgba(6,182,212,0.2)] transition-colors">
|
||||
📥
|
||||
|
||||
105
frontend/src/tabs/aerial/services/aerialApi.ts
Normal file
105
frontend/src/tabs/aerial/services/aerialApi.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// ============================================================
|
||||
// 항공 방제 API
|
||||
// ============================================================
|
||||
|
||||
// === AERIAL_MEDIA ===
|
||||
export interface AerialMediaItem {
|
||||
aerialMediaSn: number;
|
||||
acdntSn: number | null;
|
||||
fileNm: string;
|
||||
orgnlNm: string | null;
|
||||
filePath: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
locDc: string | null;
|
||||
equipTpCd: string; // drone/plane/satellite
|
||||
equipNm: string;
|
||||
mediaTpCd: string; // 사진/영상/적외선/SAR/가시광/광학
|
||||
takngDtm: string | null;
|
||||
fileSz: string | null;
|
||||
resolution: string | null;
|
||||
}
|
||||
|
||||
// === CCTV_CAMERA ===
|
||||
export interface CctvCameraItem {
|
||||
cctvSn: number;
|
||||
cameraNm: string;
|
||||
regionNm: string;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
locDc: string | null;
|
||||
coordDc: string | null;
|
||||
sttsCd: string; // LIVE/OFFLINE/MAINT
|
||||
ptzYn: string;
|
||||
sourceNm: string | null;
|
||||
streamUrl: string | null;
|
||||
}
|
||||
|
||||
// === SAT_REQUEST ===
|
||||
export interface SatRequestItem {
|
||||
satReqSn: number;
|
||||
reqCd: string;
|
||||
acdntSn: number | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
zoneDc: string | null;
|
||||
coordDc: string | null;
|
||||
zoneAreaKm2: number | null;
|
||||
satNm: string | null;
|
||||
providerNm: string | null;
|
||||
resolution: string | null;
|
||||
purposeDc: string | null;
|
||||
reqstrNm: string | null;
|
||||
reqDtm: string | null;
|
||||
expectedRcvDtm: string | null;
|
||||
sttsCd: string; // PENDING/SHOOTING/COMPLETED/CANCELLED
|
||||
}
|
||||
|
||||
export interface CreateSatRequestInput {
|
||||
reqCd: string;
|
||||
acdntSn?: number;
|
||||
lon?: number;
|
||||
lat?: number;
|
||||
zoneDc?: string;
|
||||
zoneAreaKm2?: number;
|
||||
satNm?: string;
|
||||
providerNm?: string;
|
||||
resolution?: string;
|
||||
purposeDc?: string;
|
||||
reqstrNm?: string;
|
||||
expectedRcvDtm?: string;
|
||||
}
|
||||
|
||||
export async function fetchAerialMedia(params?: {
|
||||
equipType?: string;
|
||||
mediaType?: string;
|
||||
acdntSn?: number;
|
||||
search?: string;
|
||||
}): Promise<AerialMediaItem[]> {
|
||||
const response = await api.get<AerialMediaItem[]>('/aerial/media', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchCctvCameras(params?: {
|
||||
region?: string;
|
||||
status?: string;
|
||||
}): Promise<CctvCameraItem[]> {
|
||||
const response = await api.get<CctvCameraItem[]>('/aerial/cctv', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchSatRequests(params?: {
|
||||
status?: string;
|
||||
}): Promise<SatRequestItem[]> {
|
||||
const response = await api.get<SatRequestItem[]>('/aerial/sat-requests', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createSatRequest(
|
||||
input: CreateSatRequestInput,
|
||||
): Promise<{ satReqSn: number }> {
|
||||
const response = await api.post<{ satReqSn: number }>('/aerial/sat-requests', input);
|
||||
return response.data;
|
||||
}
|
||||
@ -6,7 +6,13 @@ import { BoardDetailView } from './BoardDetailView'
|
||||
import {
|
||||
fetchBoardPosts,
|
||||
deleteBoardPost,
|
||||
fetchManuals,
|
||||
createManual,
|
||||
updateManual,
|
||||
deleteManual,
|
||||
incrementManualDownload,
|
||||
type BoardPostItem,
|
||||
type ManualItem,
|
||||
} from '../services/boardApi'
|
||||
|
||||
type ViewMode = 'list' | 'detail' | 'write'
|
||||
@ -37,34 +43,6 @@ const CATEGORY_COLORS: Record<string, string> = {
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
/* ── 해경매뉴얼 Mock 데이터 (향후 API 전환 예정) ── */
|
||||
interface ManualFile {
|
||||
id: number
|
||||
category: string
|
||||
title: string
|
||||
version: string
|
||||
fileType: string
|
||||
fileSize: string
|
||||
uploadDate: string
|
||||
author: string
|
||||
downloads: number
|
||||
}
|
||||
|
||||
const manualFiles: ManualFile[] = [
|
||||
{ id: 1, category: '방제매뉴얼', title: '해양오염방제 업무매뉴얼 (2026 개정판)', version: 'v4.2', fileType: 'PDF', fileSize: '28.5 MB', uploadDate: '2026-02-15', author: '해양오염대응국', downloads: 1245 },
|
||||
{ id: 2, category: '방제매뉴얼', title: '해양오염 방제자원 운용 지침서', version: 'v3.1', fileType: 'PDF', fileSize: '15.2 MB', uploadDate: '2026-01-20', author: '방제과', downloads: 892 },
|
||||
{ id: 3, category: '방제매뉴얼', title: '오일펜스 전개 · 회수 표준절차서', version: 'v2.8', fileType: 'PDF', fileSize: '12.7 MB', uploadDate: '2025-12-10', author: '방제과', downloads: 1567 },
|
||||
{ id: 4, category: '대응매뉴얼', title: '해양오염사고 초동대응 매뉴얼', version: 'v5.0', fileType: 'PDF', fileSize: '22.1 MB', uploadDate: '2026-02-01', author: '해양오염대응국', downloads: 2103 },
|
||||
{ id: 5, category: '대응매뉴얼', title: 'HNS 해양사고 대응 가이드라인', version: 'v2.3', fileType: 'PDF', fileSize: '18.9 MB', uploadDate: '2025-11-15', author: '해양오염대응국', downloads: 734 },
|
||||
{ id: 6, category: '대응매뉴얼', title: '대량 유출유 방제 대응 체계 매뉴얼', version: 'v3.5', fileType: 'PDF', fileSize: '31.4 MB', uploadDate: '2025-10-20', author: '방제과', downloads: 1089 },
|
||||
{ id: 7, category: '교육자료', title: '방제요원 교육훈련 교재 (기본과정)', version: 'v6.1', fileType: 'PDF', fileSize: '45.3 MB', uploadDate: '2026-01-10', author: '교육훈련과', downloads: 567 },
|
||||
{ id: 8, category: '교육자료', title: '방제요원 교육훈련 교재 (심화과정)', version: 'v4.0', fileType: 'PDF', fileSize: '52.8 MB', uploadDate: '2025-12-05', author: '교육훈련과', downloads: 423 },
|
||||
{ id: 9, category: '교육자료', title: '유류오염 식별 및 샘플링 실무 교재', version: 'v2.0', fileType: 'PDF', fileSize: '9.6 MB', uploadDate: '2025-09-18', author: '교육훈련과', downloads: 312 },
|
||||
{ id: 10, category: '법령·규정', title: '해양환경관리법 시행규칙 (방제 관련)', version: '2026', fileType: 'PDF', fileSize: '5.4 MB', uploadDate: '2026-02-10', author: '법무담당관', downloads: 645 },
|
||||
{ id: 11, category: '법령·규정', title: '해양오염방제 자재·약제 검정 기준', version: '2025', fileType: 'PDF', fileSize: '3.8 MB', uploadDate: '2025-08-22', author: '법무담당관', downloads: 389 },
|
||||
{ id: 12, category: '법령·규정', title: '방제선·방제정 운용 및 관리 규정', version: '2026', fileType: 'PDF', fileSize: '7.2 MB', uploadDate: '2026-01-05', author: '장비관리과', downloads: 478 },
|
||||
]
|
||||
|
||||
export function BoardView() {
|
||||
const { activeSubTab } = useSubMenu('board')
|
||||
const hasPermission = useAuthStore((s) => s.hasPermission)
|
||||
@ -181,17 +159,36 @@ export function BoardView() {
|
||||
══════════════════════════════════════════════ */
|
||||
const [manualCategory, setManualCategory] = useState<string>('전체')
|
||||
const [manualSearch, setManualSearch] = useState('')
|
||||
const [manualList, setManualList] = useState<ManualFile[]>(manualFiles)
|
||||
const [manualList, setManualList] = useState<ManualItem[]>([])
|
||||
const [manualLoading, setManualLoading] = useState(false)
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [editingManualId, setEditingManualId] = useState<number | null>(null)
|
||||
const [uploadForm, setUploadForm] = useState({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||||
|
||||
const manualCategories = ['전체', '방제매뉴얼', '대응매뉴얼', '교육자료', '법령·규정']
|
||||
const filteredManuals = manualList.filter(f => {
|
||||
const matchCat = manualCategory === '전체' || f.category === manualCategory
|
||||
const matchSearch = f.title.toLowerCase().includes(manualSearch.toLowerCase()) || f.author.toLowerCase().includes(manualSearch.toLowerCase())
|
||||
return matchCat && matchSearch
|
||||
})
|
||||
|
||||
const loadManuals = useCallback(async () => {
|
||||
setManualLoading(true)
|
||||
try {
|
||||
const items = await fetchManuals({
|
||||
category: manualCategory !== '전체' ? manualCategory : undefined,
|
||||
search: manualSearch || undefined,
|
||||
})
|
||||
setManualList(items)
|
||||
} catch (err) {
|
||||
console.error('[board] 매뉴얼 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setManualLoading(false)
|
||||
}
|
||||
}, [manualCategory, manualSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSubTab === 'manual') {
|
||||
loadManuals()
|
||||
}
|
||||
}, [loadManuals, activeSubTab])
|
||||
|
||||
const filteredManuals = manualList
|
||||
|
||||
const catColor = (cat: string) => {
|
||||
switch (cat) {
|
||||
@ -244,11 +241,16 @@ export function BoardView() {
|
||||
|
||||
{/* 그리드 */}
|
||||
<div className="flex-1 overflow-auto px-8 py-6">
|
||||
{manualLoading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>로딩 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))' }}>
|
||||
{filteredManuals.map(file => {
|
||||
const cc = catColor(file.category)
|
||||
const cc = catColor(file.catgNm)
|
||||
return (
|
||||
<div key={file.id} className="rounded-xl p-4 transition-all" style={{
|
||||
<div key={file.manualSn} className="rounded-xl p-4 transition-all" style={{
|
||||
background: 'var(--bg3)', border: '1px solid var(--bd)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@ -257,7 +259,7 @@ export function BoardView() {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold" style={{ background: cc.bg, color: cc.text }}>
|
||||
{file.category}
|
||||
{file.catgNm}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded" style={{ background: 'rgba(59,130,246,.1)', color: '#3b82f6' }}>
|
||||
{file.version}
|
||||
@ -269,20 +271,20 @@ export function BoardView() {
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded" style={{ background: 'rgba(239,68,68,.08)' }}>
|
||||
<span style={{ fontSize: 12 }}>📄</span>
|
||||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileType}</span>
|
||||
<span className="text-[10px] font-semibold" style={{ color: '#ef4444' }}>{file.fileTp || 'PDF'}</span>
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSize}</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{file.fileSz}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1 mb-2">
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setEditingManualId(file.id)
|
||||
setEditingManualId(file.manualSn)
|
||||
setUploadForm({
|
||||
category: file.category,
|
||||
category: file.catgNm,
|
||||
title: file.title,
|
||||
version: file.version,
|
||||
fileName: `${file.title}.${file.fileType.toLowerCase()}`,
|
||||
fileSize: file.fileSize,
|
||||
version: file.version || '',
|
||||
fileName: `${file.title}.${(file.fileTp || 'pdf').toLowerCase()}`,
|
||||
fileSize: file.fileSz || '',
|
||||
})
|
||||
setShowUploadModal(true)
|
||||
}}
|
||||
@ -291,10 +293,15 @@ export function BoardView() {
|
||||
title="수정">
|
||||
✏️ 수정
|
||||
</button>
|
||||
<button onClick={(e) => {
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm(`"${file.title}" 매뉴얼을 삭제하시겠습니까?`)) {
|
||||
setManualList(prev => prev.filter(f => f.id !== file.id))
|
||||
try {
|
||||
await deleteManual(file.manualSn)
|
||||
loadManuals()
|
||||
} catch (err) {
|
||||
alert((err as { message?: string })?.message || '삭제에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold transition-all"
|
||||
@ -305,26 +312,29 @@ export function BoardView() {
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-3" style={{ borderTop: '1px solid var(--bd)' }}>
|
||||
<div className="flex items-center gap-3 text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||||
<span>{file.author}</span>
|
||||
<span>{file.uploadDate}</span>
|
||||
<span>{file.authorNm}</span>
|
||||
<span>{new Date(file.regDtm).toLocaleDateString('ko-KR')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px]" style={{ color: 'var(--t3)', fontFamily: 'var(--fM)' }}>
|
||||
⬇ {file.downloads}
|
||||
⬇ {file.dwnldCnt}
|
||||
</span>
|
||||
<button onClick={(e) => {
|
||||
<button onClick={async (e) => {
|
||||
e.stopPropagation()
|
||||
setManualList(prev => prev.map(f => f.id === file.id ? { ...f, downloads: f.downloads + 1 } : f))
|
||||
try {
|
||||
await incrementManualDownload(file.manualSn)
|
||||
setManualList(prev => prev.map(f => f.manualSn === file.manualSn ? { ...f, dwnldCnt: f.dwnldCnt + 1 } : f))
|
||||
} catch { /* ignore */ }
|
||||
const content = [
|
||||
`═══════════════════════════════════════════`,
|
||||
` ${file.title}`,
|
||||
`═══════════════════════════════════════════`,
|
||||
``,
|
||||
` 카테고리: ${file.category}`,
|
||||
` 카테고리: ${file.catgNm}`,
|
||||
` 버전: ${file.version}`,
|
||||
` 작성자: ${file.author}`,
|
||||
` 등록일: ${file.uploadDate}`,
|
||||
` 파일크기: ${file.fileSize}`,
|
||||
` 작성자: ${file.authorNm}`,
|
||||
` 등록일: ${new Date(file.regDtm).toLocaleDateString('ko-KR')}`,
|
||||
` 파일크기: ${file.fileSz}`,
|
||||
``,
|
||||
`───────────────────────────────────────────`,
|
||||
` 본 문서는 해양경찰청 WING 시스템에서`,
|
||||
@ -353,8 +363,9 @@ export function BoardView() {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredManuals.length === 0 && (
|
||||
{!manualLoading && filteredManuals.length === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div style={{ fontSize: 32, opacity: 0.3, marginBottom: 8 }}>📘</div>
|
||||
<p className="text-sm" style={{ color: 'var(--t3)', fontFamily: 'var(--fK)' }}>검색 결과가 없습니다.</p>
|
||||
@ -422,10 +433,10 @@ export function BoardView() {
|
||||
input.type = 'file'
|
||||
input.accept = '.pdf,.doc,.docx,.hwp,.xlsx'
|
||||
input.onchange = (ev) => {
|
||||
const file = (ev.target as HTMLInputElement).files?.[0]
|
||||
if (file) {
|
||||
const sizeMB = (file.size / (1024 * 1024)).toFixed(1)
|
||||
setUploadForm(prev => ({ ...prev, fileName: file.name, fileSize: `${sizeMB} MB` }))
|
||||
const f = (ev.target as HTMLInputElement).files?.[0]
|
||||
if (f) {
|
||||
const sizeMB = (f.size / (1024 * 1024)).toFixed(1)
|
||||
setUploadForm(prev => ({ ...prev, fileName: f.name, fileSize: `${sizeMB} MB` }))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
@ -455,37 +466,35 @@ export function BoardView() {
|
||||
style={{ padding: '8px 20px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'var(--bg3)', border: '1px solid var(--bd)', color: 'var(--t3)', fontFamily: 'var(--fK)', cursor: 'pointer' }}>
|
||||
취소
|
||||
</button>
|
||||
<button onClick={() => {
|
||||
<button onClick={async () => {
|
||||
if (!uploadForm.title.trim()) { alert('제목을 입력하세요.'); return }
|
||||
if (!uploadForm.fileName) { alert('파일을 선택하세요.'); return }
|
||||
const ext = uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF'
|
||||
if (editingManualId) {
|
||||
setManualList(prev => prev.map(f => f.id === editingManualId ? {
|
||||
...f,
|
||||
category: uploadForm.category,
|
||||
title: uploadForm.title,
|
||||
version: uploadForm.version || f.version,
|
||||
fileType: ext,
|
||||
fileSize: uploadForm.fileSize || f.fileSize,
|
||||
uploadDate: new Date().toISOString().split('T')[0],
|
||||
} : f))
|
||||
} else {
|
||||
const newFile: ManualFile = {
|
||||
id: Math.max(...manualList.map(f => f.id)) + 1,
|
||||
category: uploadForm.category,
|
||||
title: uploadForm.title,
|
||||
version: uploadForm.version || 'v1.0',
|
||||
fileType: ext,
|
||||
fileSize: uploadForm.fileSize,
|
||||
uploadDate: new Date().toISOString().split('T')[0],
|
||||
author: '현재 사용자',
|
||||
downloads: 0,
|
||||
if (!editingManualId && !uploadForm.fileName) { alert('파일을 선택하세요.'); return }
|
||||
const ext = uploadForm.fileName ? uploadForm.fileName.split('.').pop()?.toUpperCase() || 'PDF' : undefined
|
||||
try {
|
||||
if (editingManualId) {
|
||||
await updateManual(editingManualId, {
|
||||
catgNm: uploadForm.category,
|
||||
title: uploadForm.title,
|
||||
version: uploadForm.version || undefined,
|
||||
fileTp: ext,
|
||||
fileSz: uploadForm.fileSize || undefined,
|
||||
})
|
||||
} else {
|
||||
await createManual({
|
||||
catgNm: uploadForm.category,
|
||||
title: uploadForm.title,
|
||||
version: uploadForm.version || 'v1.0',
|
||||
fileTp: ext,
|
||||
fileSz: uploadForm.fileSize,
|
||||
})
|
||||
}
|
||||
setManualList(prev => [newFile, ...prev])
|
||||
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||||
setEditingManualId(null)
|
||||
setShowUploadModal(false)
|
||||
loadManuals()
|
||||
} catch (err) {
|
||||
alert((err as { message?: string })?.message || '저장에 실패했습니다.')
|
||||
}
|
||||
setUploadForm({ category: '방제매뉴얼', title: '', version: '', fileName: '', fileSize: '' })
|
||||
setEditingManualId(null)
|
||||
setShowUploadModal(false)
|
||||
}}
|
||||
style={{ padding: '8px 24px', borderRadius: 6, fontSize: 12, fontWeight: 600, background: 'rgba(6,182,212,.2)', border: '1px solid rgba(6,182,212,.35)', color: '#22d3ee', fontFamily: 'var(--fK)', cursor: 'pointer' }}>
|
||||
{editingManualId ? '✏️ 수정' : '📤 업로드'}
|
||||
|
||||
@ -73,3 +73,64 @@ export async function updateBoardPost(sn: number, input: UpdateBoardPostInput):
|
||||
export async function deleteBoardPost(sn: number): Promise<void> {
|
||||
await api.delete(`/board/${sn}`);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 매뉴얼 API
|
||||
// ============================================================
|
||||
|
||||
export interface ManualItem {
|
||||
manualSn: number;
|
||||
catgNm: string;
|
||||
title: string;
|
||||
version: string | null;
|
||||
fileTp: string | null;
|
||||
fileSz: string | null;
|
||||
filePath: string | null;
|
||||
authorNm: string | null;
|
||||
dwnldCnt: number;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
export interface CreateManualInput {
|
||||
catgNm: string;
|
||||
title: string;
|
||||
version?: string;
|
||||
fileTp?: string;
|
||||
fileSz?: string;
|
||||
filePath?: string;
|
||||
authorNm?: string;
|
||||
}
|
||||
|
||||
export interface UpdateManualInput {
|
||||
catgNm?: string;
|
||||
title?: string;
|
||||
version?: string;
|
||||
fileTp?: string;
|
||||
fileSz?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export async function fetchManuals(params?: {
|
||||
category?: string;
|
||||
search?: string;
|
||||
}): Promise<ManualItem[]> {
|
||||
const response = await api.get<ManualItem[]>('/board/manual', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createManual(input: CreateManualInput): Promise<{ manualSn: number }> {
|
||||
const response = await api.post<{ manualSn: number }>('/board/manual', input);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateManual(sn: number, input: UpdateManualInput): Promise<void> {
|
||||
await api.put(`/board/manual/${sn}`, input);
|
||||
}
|
||||
|
||||
export async function deleteManual(sn: number): Promise<void> {
|
||||
await api.delete(`/board/manual/${sn}`);
|
||||
}
|
||||
|
||||
export async function incrementManualDownload(sn: number): Promise<void> {
|
||||
await api.post(`/board/manual/${sn}/download`);
|
||||
}
|
||||
|
||||
@ -1,156 +1,58 @@
|
||||
import { Dispatch, SetStateAction } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
|
||||
|
||||
interface HNSAnalysisListTableProps {
|
||||
onTabChange: Dispatch<SetStateAction<'analysis' | 'list'>>
|
||||
}
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
CRITICAL: '심각',
|
||||
HIGH: '위험',
|
||||
MEDIUM: '경고',
|
||||
LOW: '관찰',
|
||||
}
|
||||
|
||||
const RISK_STYLE: Record<string, { bg: string; color: string }> = {
|
||||
CRITICAL: { bg: 'rgba(239,68,68,0.2)', color: 'var(--red)' },
|
||||
HIGH: { bg: 'rgba(239,68,68,0.15)', color: 'var(--red)' },
|
||||
MEDIUM: { bg: 'rgba(249,115,22,0.15)', color: 'var(--orange)' },
|
||||
LOW: { bg: 'rgba(34,197,94,0.15)', color: 'var(--green)' },
|
||||
}
|
||||
|
||||
function formatDate(dtm: string | null, mode: 'full' | 'date') {
|
||||
if (!dtm) return '—'
|
||||
const d = new Date(dtm)
|
||||
if (mode === 'date') return d.toISOString().slice(0, 10)
|
||||
return d.toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
function substanceTag(sbstNm: string | null): string {
|
||||
if (!sbstNm) return '—'
|
||||
const match = sbstNm.match(/\(([^)]+)\)/)
|
||||
if (match) return match[1]
|
||||
return sbstNm.length > 6 ? sbstNm.slice(0, 6) : sbstNm
|
||||
}
|
||||
|
||||
export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps) {
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
name: '울산 온산항 톨루엔 누출',
|
||||
substance: '톨루엔 (Toluene)',
|
||||
substanceTag: '톨루엔',
|
||||
datetime: '2025-02-11 14:15',
|
||||
dateOnly: '2025-02-11',
|
||||
location: '부산항 신항',
|
||||
amount: '12.0 kL',
|
||||
algorithm: 'ALOHA',
|
||||
predictionTime: '24H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '위험',
|
||||
damageRadius: '1.8 km',
|
||||
analyst: '운영팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '여수 엠프시아 누출',
|
||||
substance: '벤젠 (Benzene)',
|
||||
substanceTag: '벤젠',
|
||||
datetime: '2025-02-09 08:40',
|
||||
dateOnly: '2025-02-09',
|
||||
location: '여수항',
|
||||
amount: '5.0 톤',
|
||||
algorithm: 'ALOHA',
|
||||
predictionTime: '12H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '위험',
|
||||
damageRadius: '2.4 km',
|
||||
analyst: '남해팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '부산 수소 추진연 폭발',
|
||||
substance: '수소 (Hydrogen)',
|
||||
substanceTag: 'H₂',
|
||||
datetime: '2025-02-07 12:15',
|
||||
dateOnly: '2025-02-07',
|
||||
location: '부산항',
|
||||
amount: '0.8 톤',
|
||||
algorithm: 'CAMEO',
|
||||
predictionTime: '6H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '심각',
|
||||
damageRadius: '0.22 km',
|
||||
analyst: '남해팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '인천항 메탄올 유출',
|
||||
substance: '메탄올 (Methanol)',
|
||||
substanceTag: 'MeOH',
|
||||
datetime: '2025-02-03 16:50',
|
||||
dateOnly: '2025-02-03',
|
||||
location: '인천항',
|
||||
amount: '8.5 kL',
|
||||
algorithm: 'ALOHA',
|
||||
predictionTime: '24H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '경고',
|
||||
damageRadius: '1.2 km',
|
||||
analyst: '중부팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '평택항 LPG 누출',
|
||||
substance: 'LPG',
|
||||
substanceTag: 'LPG',
|
||||
datetime: '2025-01-28 09:20',
|
||||
dateOnly: '2025-01-28',
|
||||
location: '평택항',
|
||||
amount: '3.2 톤',
|
||||
algorithm: 'CAMEO',
|
||||
predictionTime: '12H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '경고',
|
||||
damageRadius: '0.95 km',
|
||||
analyst: '중부팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '광양항 벤젠 누출',
|
||||
substance: '벤젠 (Benzene)',
|
||||
substanceTag: '벤젠',
|
||||
datetime: '2025-01-22 11:30',
|
||||
dateOnly: '2025-01-22',
|
||||
location: '광양항',
|
||||
amount: '6.0 kL',
|
||||
algorithm: 'ALOHA',
|
||||
predictionTime: '24H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '관찰',
|
||||
damageRadius: '1.5 km',
|
||||
analyst: '남해팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '목포항 염소스가 유출',
|
||||
substance: '염소 (Chlorine)',
|
||||
substanceTag: 'Cl₂',
|
||||
datetime: '2025-01-15 14:10',
|
||||
dateOnly: '2025-01-15',
|
||||
location: '서구항',
|
||||
amount: '2.0 톤',
|
||||
algorithm: 'CAMEO',
|
||||
predictionTime: '12H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '관찰',
|
||||
damageRadius: '1.0 km',
|
||||
analyst: '서해팀, 방재팀'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '제주 에탄올링 탱크 파열',
|
||||
substance: '에탄올 (Ethanol)',
|
||||
substanceTag: 'C₂H₆',
|
||||
datetime: '2025-01-08 07:55',
|
||||
dateOnly: '2025-01-08',
|
||||
location: '제주항, 일대팀',
|
||||
amount: '1.5 톤',
|
||||
algorithm: 'CAMEO',
|
||||
predictionTime: '6H',
|
||||
aegl3: true,
|
||||
aegl2: true,
|
||||
aegl1: true,
|
||||
riskLevel: '관찰',
|
||||
damageRadius: '0.8 km',
|
||||
analyst: '제주팀, 일대팀'
|
||||
const [analyses, setAnalyses] = useState<HnsAnalysisItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchHnsAnalyses()
|
||||
setAnalyses(items)
|
||||
} catch (err) {
|
||||
console.error('[hns] 분석 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
]
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@ -189,7 +91,7 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
borderRadius: '12px',
|
||||
fontFamily: 'var(--fM)'
|
||||
}}>
|
||||
총 {mockData.length}건
|
||||
총 {analyses.length}건
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
@ -231,6 +133,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
|
||||
{/* Table */}
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}>로딩 중...</div>
|
||||
) : (
|
||||
<table style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
@ -274,9 +179,19 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{mockData.map((item, index) => (
|
||||
{analyses.map((item, index) => {
|
||||
const rslt = item.rsltData as Record<string, unknown> | null
|
||||
const riskLabel = RISK_LABEL[item.riskCd || ''] || item.riskCd || '—'
|
||||
const riskStyle = RISK_STYLE[item.riskCd || ''] || { bg: 'rgba(100,100,100,0.1)', color: 'var(--t3)' }
|
||||
const aegl3 = rslt?.aegl3 as boolean | undefined
|
||||
const aegl2 = rslt?.aegl2 as boolean | undefined
|
||||
const aegl1 = rslt?.aegl1 as boolean | undefined
|
||||
const damageRadius = (rslt?.damageRadius as string) || '—'
|
||||
const amount = item.spilQty != null ? `${item.spilQty} ${item.spilUnitCd || 'KL'}` : '—'
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.id}
|
||||
key={item.hnsAnlysSn}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--bd)',
|
||||
cursor: 'pointer',
|
||||
@ -286,8 +201,8 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--bg2)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.background = index % 2 === 0 ? 'transparent' : 'rgba(255,255,255,0.01)'}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{item.id}</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.name}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)' }}>{item.hnsAnlysSn}</td>
|
||||
<td style={{ padding: '12px 16px', color: 'var(--t1)', fontWeight: 500 }}>{item.anlysNm}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
@ -297,13 +212,13 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
background: 'rgba(249,115,22,0.12)',
|
||||
color: 'var(--orange)'
|
||||
}}>
|
||||
{item.substanceTag}
|
||||
{substanceTag(item.sbstNm)}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{item.datetime}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{item.dateOnly}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)' }}>{item.location}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.amount}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{formatDate(item.acdntDtm, 'full')}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontFamily: 'var(--fM)', fontSize: '10px' }}>{formatDate(item.regDtm, 'date')}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)' }}>{item.locNm || '—'}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{amount}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
padding: '4px 8px',
|
||||
@ -313,18 +228,18 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
background: 'rgba(6,182,212,0.12)',
|
||||
color: 'var(--cyan)'
|
||||
}}>
|
||||
{item.algorithm}
|
||||
{item.algoCd || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.predictionTime}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.fcstHr ? `${item.fcstHr}H` : '—'}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
<div style={{
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: item.aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
background: aegl3 ? 'rgba(239,68,68,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
margin: '0 auto',
|
||||
border: item.aegl3 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl3 ? 'none' : '1px solid var(--bd)'
|
||||
}} />
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
@ -332,9 +247,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: item.aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
background: aegl2 ? 'rgba(249,115,22,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
margin: '0 auto',
|
||||
border: item.aegl2 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl2 ? 'none' : '1px solid var(--bd)'
|
||||
}} />
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
@ -342,9 +257,9 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '50%',
|
||||
background: item.aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
background: aegl1 ? 'rgba(234,179,8,0.8)' : 'rgba(255,255,255,0.1)',
|
||||
margin: '0 auto',
|
||||
border: item.aegl1 ? 'none' : '1px solid var(--bd)'
|
||||
border: aegl1 ? 'none' : '1px solid var(--bd)'
|
||||
}} />
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center' }}>
|
||||
@ -353,26 +268,24 @@ export function HNSAnalysisListTable({ onTabChange }: HNSAnalysisListTableProps)
|
||||
borderRadius: '4px',
|
||||
fontSize: '9px',
|
||||
fontWeight: 600,
|
||||
background:
|
||||
item.riskLevel === '위험' ? 'rgba(239,68,68,0.15)' :
|
||||
item.riskLevel === '심각' ? 'rgba(239,68,68,0.2)' :
|
||||
item.riskLevel === '경고' ? 'rgba(249,115,22,0.15)' :
|
||||
'rgba(34,197,94,0.15)',
|
||||
color:
|
||||
item.riskLevel === '위험' ? 'var(--red)' :
|
||||
item.riskLevel === '심각' ? 'var(--red)' :
|
||||
item.riskLevel === '경고' ? 'var(--orange)' :
|
||||
'var(--green)'
|
||||
background: riskStyle.bg,
|
||||
color: riskStyle.color,
|
||||
}}>
|
||||
{item.riskLevel}
|
||||
{riskLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{item.damageRadius}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '10px' }}>{item.analyst}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t2)', fontFamily: 'var(--fM)' }}>{damageRadius}</td>
|
||||
<td style={{ padding: '12px 16px', textAlign: 'center', color: 'var(--t3)', fontSize: '10px' }}>{item.analystNm || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!loading && analyses.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '80px 0', color: 'var(--t3)', fontSize: '12px' }}>분석 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { createHnsAnalysis } from '../services/hnsApi'
|
||||
|
||||
interface HNSRecalcModalProps {
|
||||
isOpen: boolean
|
||||
@ -44,15 +45,33 @@ export function HNSRecalcModal({ isOpen, onClose, onSubmit }: HNSRecalcModalProp
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
const handleRun = () => {
|
||||
const handleRun = async () => {
|
||||
setPhase('running')
|
||||
setTimeout(() => {
|
||||
try {
|
||||
await createHnsAnalysis({
|
||||
anlysNm: `HNS 재계산 — ${substance}`,
|
||||
lon,
|
||||
lat,
|
||||
sbstNm: substance,
|
||||
spilQty: amount,
|
||||
spilUnitCd: unit,
|
||||
fcstHr: predTime,
|
||||
algoCd: model,
|
||||
windSpd: windSpeed,
|
||||
windDir,
|
||||
temp,
|
||||
atmStblCd: stability.charAt(0),
|
||||
})
|
||||
setPhase('done')
|
||||
setTimeout(() => {
|
||||
onSubmit()
|
||||
onClose()
|
||||
}, 1000)
|
||||
}, 2500)
|
||||
}, 800)
|
||||
} catch (err) {
|
||||
console.error('[hns] 재계산 실패:', err)
|
||||
setPhase('editing')
|
||||
alert('재계산 실행에 실패했습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { fetchHnsAnalyses, type HnsAnalysisItem } from '../services/hnsApi'
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────
|
||||
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED'
|
||||
@ -36,14 +37,7 @@ const SEVERITY_STYLE: Record<Severity, { bg: string; color: string }> = {
|
||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e' },
|
||||
}
|
||||
|
||||
// ─── Mock Data ──────────────────────────────────────────
|
||||
const INCIDENTS = [
|
||||
'HNS-2024-041 · 울산 온산항 톨루엔 유출',
|
||||
'HNS-2024-039 · 여수 암모니아 누출',
|
||||
'HNS-2024-035 · 부산 수소 추진선 폭발',
|
||||
'HNS-2024-033 · 인천항 메탄올 유출',
|
||||
]
|
||||
|
||||
// ─── Mock Data (시나리오 시뮬레이션 엔진 미구현 — 프론트 상수 유지) ──
|
||||
const MOCK_SCENARIOS: HnsScenario[] = [
|
||||
{
|
||||
id: 'S-01', name: '유출 직후 (초기 확산)', severity: 'CRITICAL',
|
||||
@ -109,6 +103,7 @@ const MATERIALS: HnsMaterial[] = [
|
||||
|
||||
// ─── Main Component ─────────────────────────────────────
|
||||
export function HNSScenarioView() {
|
||||
const [incidents, setIncidents] = useState<HnsAnalysisItem[]>([])
|
||||
const [selectedIncident, setSelectedIncident] = useState(0)
|
||||
const [scenarios, setScenarios] = useState(MOCK_SCENARIOS)
|
||||
const [selectedIdx, setSelectedIdx] = useState(0)
|
||||
@ -116,6 +111,14 @@ export function HNSScenarioView() {
|
||||
const [activeView, setActiveView] = useState<ViewTab>(0)
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchHnsAnalyses()
|
||||
.then(items => { if (!cancelled) setIncidents(items) })
|
||||
.catch(err => console.error('[hns] 사고 목록 조회 실패:', err))
|
||||
return () => { cancelled = true }
|
||||
}, [])
|
||||
|
||||
const selected = scenarios[selectedIdx]
|
||||
|
||||
const toggleCheck = (idx: number) => {
|
||||
@ -151,7 +154,14 @@ export function HNSScenarioView() {
|
||||
className="prd-i"
|
||||
style={{ width: '280px', fontSize: '11px' }}
|
||||
>
|
||||
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
|
||||
{incidents.length === 0
|
||||
? <option value={0}>분석 데이터 없음</option>
|
||||
: incidents.map((inc, i) => (
|
||||
<option key={inc.hnsAnlysSn} value={i}>
|
||||
HNS-{String(inc.hnsAnlysSn).padStart(3, '0')} · {inc.anlysNm}
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setModalOpen(true)}
|
||||
|
||||
@ -8,6 +8,7 @@ import { HNSSubstanceView } from './HNSSubstanceView'
|
||||
import { HNSScenarioView } from './HNSScenarioView'
|
||||
import { HNSRecalcModal } from './HNSRecalcModal'
|
||||
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
|
||||
import { createHnsAnalysis } from '../services/hnsApi'
|
||||
|
||||
/* ─── HNS 매뉴얼 뷰어 컴포넌트 ─── */
|
||||
function HNSManualViewer() {
|
||||
@ -268,15 +269,17 @@ export function HNSView() {
|
||||
setIsRunningPrediction(true)
|
||||
|
||||
try {
|
||||
console.log('대기확산 예측 실행 요청:', {
|
||||
location: incidentCoord
|
||||
const { hnsAnlysSn } = await createHnsAnalysis({
|
||||
anlysNm: `HNS 분석 ${new Date().toLocaleDateString('ko-KR')}`,
|
||||
lon: incidentCoord.lon,
|
||||
lat: incidentCoord.lat,
|
||||
locNm: `${incidentCoord.lon.toFixed(4)}, ${incidentCoord.lat.toFixed(4)}`,
|
||||
})
|
||||
|
||||
// TODO: 백엔드 API 호출
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
// 시뮬레이션 엔진 미구현 — 프론트 임시 결과 생성
|
||||
const windAngle = 225
|
||||
const result = {
|
||||
hnsAnlysSn,
|
||||
zones: [
|
||||
{ level: 'AEGL-3', color: 'rgba(239,68,68,0.4)', radius: 500, angle: windAngle },
|
||||
{ level: 'AEGL-2', color: 'rgba(249,115,22,0.3)', radius: 1000, angle: windAngle },
|
||||
@ -289,7 +292,6 @@ export function HNSView() {
|
||||
}
|
||||
|
||||
setDispersionResult(result)
|
||||
console.log('대기확산 예측 완료:', result)
|
||||
} catch (error) {
|
||||
console.error('대기확산 예측 오류:', error)
|
||||
alert('대기확산 예측 중 오류가 발생했습니다.')
|
||||
|
||||
70
frontend/src/tabs/hns/services/hnsApi.ts
Normal file
70
frontend/src/tabs/hns/services/hnsApi.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
// ============================================================
|
||||
// HNS 분석 API
|
||||
// ============================================================
|
||||
|
||||
export interface HnsAnalysisItem {
|
||||
hnsAnlysSn: number;
|
||||
anlysNm: string;
|
||||
acdntDtm: string | null;
|
||||
locNm: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
sbstNm: string | null;
|
||||
spilQty: number | null;
|
||||
spilUnitCd: string | null;
|
||||
fcstHr: number | null;
|
||||
algoCd: string | null;
|
||||
critMdlCd: string | null;
|
||||
windSpd: number | null;
|
||||
windDir: string | null;
|
||||
execSttsCd: string;
|
||||
riskCd: string | null;
|
||||
analystNm: string | null;
|
||||
rsltData: Record<string, unknown> | null;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
export interface CreateHnsAnalysisInput {
|
||||
anlysNm: string;
|
||||
acdntDtm?: string;
|
||||
locNm?: string;
|
||||
lon?: number;
|
||||
lat?: number;
|
||||
sbstNm?: string;
|
||||
spilQty?: number;
|
||||
spilUnitCd?: string;
|
||||
fcstHr?: number;
|
||||
algoCd?: string;
|
||||
critMdlCd?: string;
|
||||
windSpd?: number;
|
||||
windDir?: string;
|
||||
temp?: number;
|
||||
humid?: number;
|
||||
atmStblCd?: string;
|
||||
analystNm?: string;
|
||||
}
|
||||
|
||||
export async function fetchHnsAnalyses(params?: {
|
||||
status?: string;
|
||||
substance?: string;
|
||||
search?: string;
|
||||
}): Promise<HnsAnalysisItem[]> {
|
||||
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function fetchHnsAnalysis(sn: number): Promise<HnsAnalysisItem> {
|
||||
const response = await api.get<HnsAnalysisItem>(`/hns/analyses/${sn}`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function createHnsAnalysis(input: CreateHnsAnalysisInput): Promise<{ hnsAnlysSn: number }> {
|
||||
const response = await api.post<{ hnsAnlysSn: number }>('/hns/analyses', input);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteHnsAnalysis(sn: number): Promise<void> {
|
||||
await api.delete(`/hns/analyses/${sn}`);
|
||||
}
|
||||
@ -1,198 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { fetchPredictionAnalyses } from '../services/predictionApi'
|
||||
import type { PredictionAnalysis } from '../services/predictionApi'
|
||||
|
||||
export interface Analysis {
|
||||
id: number
|
||||
name: string
|
||||
occurredAt: string
|
||||
analysisDate: string
|
||||
requestor: string
|
||||
duration: string
|
||||
oilType: string
|
||||
volume: number
|
||||
incidentStatus: string
|
||||
kospsStatus: 'completed' | 'running' | 'pending' | 'error'
|
||||
poseidonStatus: 'completed' | 'running' | 'pending' | 'error'
|
||||
opendriftStatus: 'completed' | 'running' | 'pending' | 'error'
|
||||
backtracking: 'completed' | 'running' | 'pending' | 'error'
|
||||
analyst: string
|
||||
lat: number
|
||||
lon: number
|
||||
location: string
|
||||
}
|
||||
|
||||
// Mock 데이터
|
||||
const mockAnalyses: Analysis[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: '여수 유조선 충돌',
|
||||
occurredAt: '2025-02-18 06:30',
|
||||
analysisDate: '2025-02-18',
|
||||
requestor: '김정훈',
|
||||
duration: '72H',
|
||||
oilType: 'BUNKER_C',
|
||||
volume: 350.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'completed',
|
||||
analyst: '남해청, 방재과',
|
||||
lat: 34.7312, lon: 127.6845, location: '여수 돌산 남방 5NM',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '통영 화물선 파손',
|
||||
occurredAt: '2025-02-08 14:20',
|
||||
analysisDate: '2025-02-08',
|
||||
requestor: '박민수',
|
||||
duration: '48H',
|
||||
oilType: 'DIESEL',
|
||||
volume: 120.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'running',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'completed',
|
||||
analyst: '남해청, 통영지',
|
||||
lat: 34.8342, lon: 128.4331, location: '통영항 동방 3NM',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '군산항 송유관 파열',
|
||||
occurredAt: '2025-02-09 09:15',
|
||||
analysisDate: '2025-02-09',
|
||||
requestor: '이승호',
|
||||
duration: '72H',
|
||||
oilType: 'CRUDE_OIL',
|
||||
volume: 580.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'running',
|
||||
backtracking: 'completed',
|
||||
analyst: '서해청, 군산지',
|
||||
lat: 35.9838, lon: 126.5650, location: '군산항 내항 부두',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: '인천항 기름선 파손',
|
||||
occurredAt: '2025-02-05 11:40',
|
||||
analysisDate: '2025-02-05',
|
||||
requestor: '최영진',
|
||||
duration: '48H',
|
||||
oilType: 'BUNKER_C',
|
||||
volume: 85.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'running',
|
||||
analyst: '중부청, 인천지',
|
||||
lat: 37.4563, lon: 126.5922, location: '인천항 남방 2NM',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '제주 담배 해양사',
|
||||
occurredAt: '2025-01-28 07:50',
|
||||
analysisDate: '2025-01-28',
|
||||
requestor: '한지원',
|
||||
duration: '24H',
|
||||
oilType: 'DIESEL',
|
||||
volume: 45.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'error',
|
||||
backtracking: 'running',
|
||||
analyst: '제주청, 제주지',
|
||||
lat: 33.5097, lon: 126.5312, location: '제주항 북방 1NM',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '포항 영일만 탱커',
|
||||
occurredAt: '2025-01-25 16:00',
|
||||
analysisDate: '2025-01-25',
|
||||
requestor: '정우성',
|
||||
duration: '72H',
|
||||
oilType: 'CRUDE_OIL',
|
||||
volume: 220.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'completed',
|
||||
analyst: '동해청, 포항지',
|
||||
lat: 36.0190, lon: 129.3650, location: '영일만 입구 동방 4NM',
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: '목포 벙커링 유출',
|
||||
occurredAt: '2025-01-20 13:10',
|
||||
analysisDate: '2025-01-20',
|
||||
requestor: '송태호',
|
||||
duration: '48H',
|
||||
oilType: 'BUNKER_C',
|
||||
volume: 95.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'completed',
|
||||
analyst: '서해청, 목포지',
|
||||
lat: 34.7936, lon: 126.3815, location: '목포항 외항 남방',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: '부산 감천항 충돌',
|
||||
occurredAt: '2025-01-15 22:10',
|
||||
analysisDate: '2025-01-14',
|
||||
requestor: '윤서연',
|
||||
duration: '12H',
|
||||
oilType: 'BUNKER_C',
|
||||
volume: 28.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'running',
|
||||
backtracking: 'running',
|
||||
analyst: '남해청, 부산지',
|
||||
lat: 35.0761, lon: 129.0148, location: '감천항 내항',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: '태안 해역 유출',
|
||||
occurredAt: '2025-01-12 04:45',
|
||||
analysisDate: '2025-01-12',
|
||||
requestor: '강민재',
|
||||
duration: '72H',
|
||||
oilType: 'CRUDE_OIL',
|
||||
volume: 1200.00,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'completed',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'completed',
|
||||
analyst: '중부청, 태안지',
|
||||
lat: 36.7765, lon: 126.1320, location: '태안 만리포 서방 8NM',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: '울산항 윤활유 유출',
|
||||
occurredAt: '2025-01-08 10:30',
|
||||
analysisDate: '2025-01-08',
|
||||
requestor: '오현수',
|
||||
duration: '24H',
|
||||
oilType: 'LUBE_OIL',
|
||||
volume: 12.50,
|
||||
incidentStatus: '진행중',
|
||||
kospsStatus: 'completed',
|
||||
poseidonStatus: 'error',
|
||||
opendriftStatus: 'completed',
|
||||
backtracking: 'running',
|
||||
analyst: '남해청, 울산지',
|
||||
lat: 35.5040, lon: 129.3870, location: '울산항 남방 1NM',
|
||||
},
|
||||
]
|
||||
export type Analysis = PredictionAnalysis
|
||||
|
||||
interface AnalysisListTableProps {
|
||||
onTabChange: (tab: string) => void
|
||||
@ -200,11 +10,28 @@ interface AnalysisListTableProps {
|
||||
}
|
||||
|
||||
export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisListTableProps) {
|
||||
const [analyses] = useState<Analysis[]>(mockAnalyses)
|
||||
const [analyses, setAnalyses] = useState<Analysis[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const itemsPerPage = 10
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchPredictionAnalyses({ search: searchTerm || undefined })
|
||||
setAnalyses(items)
|
||||
} catch (err) {
|
||||
console.error('[prediction] 분석 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
@ -317,32 +144,33 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-text-3 text-sm">로딩 중...</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-bg-1 border-b border-border z-10">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">번호</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고명</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">사고일시</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">분석일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">예측시간</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">유종</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-bold text-text-3 uppercase tracking-wider">유출량</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">사고상태</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">KOSPS</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">POSEIDON</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">OpenDrift</th>
|
||||
<th className="px-4 py-3 text-center text-xs font-bold text-text-3 uppercase tracking-wider">역추적</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">분석담당자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">담당자</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-bold text-text-3 uppercase tracking-wider">소속</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{currentAnalyses.map((analysis) => (
|
||||
<tr
|
||||
key={analysis.id}
|
||||
key={analysis.acdntSn}
|
||||
className="hover:bg-bg-2 transition-colors cursor-pointer group"
|
||||
>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.id}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.acdntSn}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-status-red animate-pulse" />
|
||||
@ -355,30 +183,31 @@ export function AnalysisListTable({ onTabChange, onSelectAnalysis }: AnalysisLis
|
||||
}
|
||||
}}
|
||||
>
|
||||
{analysis.name}
|
||||
{analysis.acdntNm}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analysisDate}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.occurredAt ? new Date(analysis.occurredAt).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2 font-mono">{analysis.duration}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.oilType}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-1 font-mono text-right font-semibold">
|
||||
{analysis.volume.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<span className="w-2 h-2 rounded-full bg-status-red inline-block animate-pulse" />
|
||||
{analysis.volume != null ? analysis.volume.toFixed(2) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.kospsStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.poseidonStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.opendriftStatus)}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtracking)}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.requestor}</td>
|
||||
<td className="px-4 py-3 text-center">{getStatusBadge(analysis.backtrackStatus)}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.analyst}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-2">{analysis.officeName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{!loading && analyses.length === 0 && (
|
||||
<div className="text-center py-20 text-text-3 text-sm">분석 데이터가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { LeftPanel } from './LeftPanel'
|
||||
import { RightPanel } from './RightPanel'
|
||||
import { MapView } from '@common/components/map/MapView'
|
||||
@ -10,9 +10,10 @@ import { RecalcModal } from './RecalcModal'
|
||||
import { BacktrackReplayBar } from '@common/components/map/BacktrackReplayBar'
|
||||
import { useSubMenu, navigateToTab, setReportGenCategory } from '@common/hooks/useSubMenu'
|
||||
import type { BoomLine, AlgorithmSettings, ContainmentResult, BoomLineCoord } from '@common/types/boomLine'
|
||||
import type { BacktrackPhase, BacktrackVessel } from '@common/types/backtrack'
|
||||
import type { BacktrackPhase, BacktrackVessel, BacktrackConditions, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
import { TOTAL_REPLAY_FRAMES } from '@common/types/backtrack'
|
||||
import { MOCK_CONDITIONS, MOCK_VESSELS, MOCK_REPLAY_SHIPS, MOCK_COLLISION } from '@common/mock/backtrackMockData'
|
||||
import { fetchBacktrackByAcdnt, createBacktrack, fetchPredictionDetail } from '../services/predictionApi'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
@ -63,6 +64,16 @@ export function OilSpillView() {
|
||||
|
||||
// 선택된 분석 (목록에서 클릭 시)
|
||||
const [selectedAnalysis, setSelectedAnalysis] = useState<Analysis | null>(null)
|
||||
// 분석 상세 (API에서 가져온 선박/기상 정보)
|
||||
const [analysisDetail, setAnalysisDetail] = useState<PredictionDetail | null>(null)
|
||||
|
||||
// 역추적 API 데이터
|
||||
const [backtrackConditions, setBacktrackConditions] = useState<BacktrackConditions>({
|
||||
estimatedSpillTime: '', analysisRange: '±12시간', searchRadius: '10 NM',
|
||||
spillLocation: { lat: 34.7312, lon: 127.6845 }, totalVessels: 0,
|
||||
})
|
||||
const [replayShips, setReplayShips] = useState<ReplayShip[]>([])
|
||||
const [collisionEvent, setCollisionEvent] = useState<CollisionEvent | null>(null)
|
||||
|
||||
// 재계산 상태
|
||||
const [recalcModalOpen, setRecalcModalOpen] = useState(false)
|
||||
@ -79,19 +90,93 @@ export function OilSpillView() {
|
||||
})
|
||||
}
|
||||
|
||||
// 역추적: API에서 기존 결과 로딩
|
||||
const loadBacktrackData = useCallback(async (acdntSn: number) => {
|
||||
try {
|
||||
const bt = await fetchBacktrackByAcdnt(acdntSn)
|
||||
if (bt && bt.execSttsCd === 'completed' && bt.rsltData) {
|
||||
const rslt = bt.rsltData as Record<string, unknown>
|
||||
if (Array.isArray(rslt.vessels)) {
|
||||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||||
}
|
||||
if (Array.isArray(rslt.replayShips)) {
|
||||
setReplayShips(rslt.replayShips as ReplayShip[])
|
||||
}
|
||||
if (rslt.collisionEvent) {
|
||||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
||||
}
|
||||
setBacktrackConditions({
|
||||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
||||
analysisRange: bt.anlysRange || '±12시간',
|
||||
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
|
||||
spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon },
|
||||
totalVessels: bt.totalVessels || 0,
|
||||
})
|
||||
setBacktrackPhase('results')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[prediction] 역추적 데이터 로딩 실패:', err)
|
||||
}
|
||||
// 기존 결과 없으면 conditions 상태 유지
|
||||
setBacktrackPhase('conditions')
|
||||
setBacktrackVessels([])
|
||||
setReplayShips([])
|
||||
setCollisionEvent(null)
|
||||
}, [incidentCoord])
|
||||
|
||||
// 역추적 핸들러
|
||||
const handleOpenBacktrack = () => {
|
||||
setBacktrackModalOpen(true)
|
||||
setBacktrackPhase('conditions')
|
||||
setBacktrackVessels([])
|
||||
setBacktrackConditions(prev => ({
|
||||
...prev,
|
||||
spillLocation: incidentCoord,
|
||||
}))
|
||||
if (selectedAnalysis) {
|
||||
loadBacktrackData(selectedAnalysis.acdntSn)
|
||||
} else {
|
||||
setBacktrackPhase('conditions')
|
||||
setBacktrackVessels([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunBacktrackAnalysis = () => {
|
||||
const handleRunBacktrackAnalysis = async () => {
|
||||
setBacktrackPhase('analyzing')
|
||||
setTimeout(() => {
|
||||
setBacktrackVessels(MOCK_VESSELS)
|
||||
setBacktrackPhase('results')
|
||||
}, 2000)
|
||||
try {
|
||||
if (selectedAnalysis) {
|
||||
const { backtrackSn } = await createBacktrack({
|
||||
acdntSn: selectedAnalysis.acdntSn,
|
||||
lon: incidentCoord.lon,
|
||||
lat: incidentCoord.lat,
|
||||
})
|
||||
// 생성 후 기존 결과 로딩 (시드 데이터 또는 엔진 처리 결과)
|
||||
const bt = await fetchBacktrackByAcdnt(selectedAnalysis.acdntSn)
|
||||
if (bt && bt.rsltData) {
|
||||
const rslt = bt.rsltData as Record<string, unknown>
|
||||
if (Array.isArray(rslt.vessels)) {
|
||||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||||
}
|
||||
if (Array.isArray(rslt.replayShips)) {
|
||||
setReplayShips(rslt.replayShips as ReplayShip[])
|
||||
}
|
||||
if (rslt.collisionEvent) {
|
||||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
||||
}
|
||||
setBacktrackConditions(prev => ({
|
||||
...prev,
|
||||
totalVessels: bt.totalVessels || 0,
|
||||
}))
|
||||
setBacktrackPhase('results')
|
||||
} else {
|
||||
// 엔진 미구현 — PENDING 상태, 일단 빈 결과
|
||||
console.info('[prediction] 역추적 생성 완료 (SN:', backtrackSn, '), 엔진 미구현')
|
||||
setBacktrackPhase('conditions')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[prediction] 역추적 분석 실패:', err)
|
||||
setBacktrackPhase('conditions')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartReplay = () => {
|
||||
@ -128,15 +213,17 @@ export function OilSpillView() {
|
||||
}, [isReplayPlaying, replayFrame, replaySpeed])
|
||||
|
||||
// 분석 목록에서 사고명 클릭 시
|
||||
const handleSelectAnalysis = (analysis: Analysis) => {
|
||||
const handleSelectAnalysis = async (analysis: Analysis) => {
|
||||
setSelectedAnalysis(analysis)
|
||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||
if (analysis.lon != null && analysis.lat != null) {
|
||||
setIncidentCoord({ lon: analysis.lon, lat: analysis.lat })
|
||||
}
|
||||
// 유종 매핑
|
||||
const oilTypeMap: Record<string, string> = {
|
||||
'BUNKER_C': '벙커C유', 'DIESEL': '경유', 'CRUDE_OIL': '원유', 'LUBE_OIL': '윤활유',
|
||||
}
|
||||
setOilType(oilTypeMap[analysis.oilType] || '벙커C유')
|
||||
setSpillAmount(analysis.volume)
|
||||
setSpillAmount(analysis.volume ?? 100)
|
||||
setPredictionTime(parseInt(analysis.duration) || 48)
|
||||
// 모델 상태에 따라 선택 모델 설정
|
||||
const models = new Set<PredictionModel>()
|
||||
@ -144,6 +231,13 @@ export function OilSpillView() {
|
||||
if (analysis.poseidonStatus !== 'pending') models.add('POSEIDON')
|
||||
if (analysis.opendriftStatus !== 'pending') models.add('OpenDrift')
|
||||
setSelectedModels(models)
|
||||
// 분석 상세 로딩 (선박/기상 정보)
|
||||
try {
|
||||
const detail = await fetchPredictionDetail(analysis.acdntSn)
|
||||
setAnalysisDetail(detail)
|
||||
} catch (err) {
|
||||
console.error('[prediction] 분석 상세 로딩 실패:', err)
|
||||
}
|
||||
// 분석 화면으로 전환
|
||||
setActiveSubTab('analysis')
|
||||
}
|
||||
@ -261,10 +355,10 @@ export function OilSpillView() {
|
||||
drawingPoints={drawingPoints}
|
||||
layerOpacity={layerOpacity}
|
||||
layerBrightness={layerBrightness}
|
||||
backtrackReplay={isReplayActive ? {
|
||||
backtrackReplay={isReplayActive && replayShips.length > 0 ? {
|
||||
isActive: true,
|
||||
ships: MOCK_REPLAY_SHIPS,
|
||||
collisionEvent: MOCK_COLLISION,
|
||||
ships: replayShips,
|
||||
collisionEvent: collisionEvent || undefined,
|
||||
replayFrame,
|
||||
totalFrames: TOTAL_REPLAY_FRAMES,
|
||||
incidentCoord,
|
||||
@ -427,8 +521,8 @@ export function OilSpillView() {
|
||||
onSeek={setReplayFrame}
|
||||
onSpeedChange={setReplaySpeed}
|
||||
onClose={handleCloseReplay}
|
||||
replayShips={MOCK_REPLAY_SHIPS}
|
||||
collisionEvent={MOCK_COLLISION}
|
||||
replayShips={replayShips}
|
||||
collisionEvent={collisionEvent || undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -436,7 +530,7 @@ export function OilSpillView() {
|
||||
</div>
|
||||
|
||||
{/* Right Panel */}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} />}
|
||||
{activeSubTab === 'analysis' && <RightPanel onOpenBacktrack={handleOpenBacktrack} onOpenRecalc={() => setRecalcModalOpen(true)} onOpenReport={() => { setReportGenCategory(0); navigateToTab('reports', 'generate') }} detail={analysisDetail} />}
|
||||
|
||||
{/* 재계산 모달 */}
|
||||
<RecalcModal
|
||||
@ -464,7 +558,7 @@ export function OilSpillView() {
|
||||
isOpen={backtrackModalOpen}
|
||||
onClose={() => setBacktrackModalOpen(false)}
|
||||
phase={backtrackPhase}
|
||||
conditions={MOCK_CONDITIONS}
|
||||
conditions={backtrackConditions}
|
||||
vessels={backtrackVessels}
|
||||
onRunAnalysis={handleRunBacktrackAnalysis}
|
||||
onStartReplay={handleStartReplay}
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import type { PredictionDetail } from '../services/predictionApi'
|
||||
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void }) {
|
||||
export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport, detail }: { onOpenBacktrack?: () => void; onOpenRecalc?: () => void; onOpenReport?: () => void; detail?: PredictionDetail | null }) {
|
||||
const vessel = detail?.vessels?.[0]
|
||||
const vessel2 = detail?.vessels?.[1]
|
||||
const spill = detail?.spill
|
||||
const insurance = vessel?.insuranceData as Array<{ type: string; insurer: string; value: string; currency: string }> | null
|
||||
const [shipExpanded, setShipExpanded] = useState(false)
|
||||
const [insuranceExpanded, setInsuranceExpanded] = useState(false)
|
||||
|
||||
@ -38,7 +43,7 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
|
||||
{/* 오염 종합 상황 */}
|
||||
<Section title="오염 종합 상황" badge="위험" badgeColor="red">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px', fontSize: '9px' }}>
|
||||
<StatBox label="유출량" value="350.00" unit="kl" color="var(--t1)" />
|
||||
<StatBox label="유출량" value={spill?.volume != null ? spill.volume.toFixed(2) : '—'} unit={spill?.unit || 'kl'} color="var(--t1)" />
|
||||
<StatBox label="풍화량" value="0.43" unit="kl" color="var(--orange)" />
|
||||
<StatBox label="해상잔존" value="9.57" unit="kl" color="var(--blue)" />
|
||||
<StatBox label="연안부착" value="0.00" unit="kl" color="var(--red)" />
|
||||
@ -98,35 +103,37 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
|
||||
fontSize: '15px'
|
||||
}}>🚢</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="text-[11px] font-bold text-text-1 font-korean">ORIENTAL GLORY</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">IMO 9412856 · MMSI 440123456 · 유조선</div>
|
||||
<div className="text-[11px] font-bold text-text-1 font-korean">{vessel?.vesselNm || '—'}</div>
|
||||
<div className="text-[8px] text-text-3 font-mono">IMO {vessel?.imoNo || '—'} · {vessel?.vesselTp || '—'}</div>
|
||||
</div>
|
||||
<span className="text-[7px] px-2 py-0.5 rounded bg-[rgba(239,68,68,0.12)] text-status-red font-bold">사고</span>
|
||||
</div>
|
||||
|
||||
{/* 제원 */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px' }}>
|
||||
<SpecCard value="174.0" label="전장 LOA(m)" color="var(--purple)" />
|
||||
<SpecCard value="32.2" label="형폭 B(m)" color="var(--cyan)" />
|
||||
<SpecCard value="11.2" label="흘수 d(m)" color="var(--green)" />
|
||||
<SpecCard value={vessel?.loaM?.toFixed(1) || '—'} label="전장 LOA(m)" color="var(--purple)" />
|
||||
<SpecCard value={vessel?.breadthM?.toFixed(1) || '—'} label="형폭 B(m)" color="var(--cyan)" />
|
||||
<SpecCard value={vessel?.draftM?.toFixed(1) || '—'} label="흘수 d(m)" color="var(--green)" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-0.5 text-[9px] font-korean">
|
||||
<InfoRow label="총톤수(GT)" value="38,642톤" />
|
||||
<InfoRow label="재화중량(DWT)" value="72,850톤" />
|
||||
<InfoRow label="건조" value="2008 현대미포" />
|
||||
<InfoRow label="주기관" value="MAN B&W 9,480kW" mono />
|
||||
<InfoRow label="선적/선급" value="🇰🇷 대한민국 · KR" />
|
||||
<InfoRow label="호출부호" value="HLBK" mono />
|
||||
<InfoRow label="총톤수(GT)" value={vessel?.gt ? `${vessel.gt.toLocaleString()}톤` : '—'} />
|
||||
<InfoRow label="재화중량(DWT)" value={vessel?.dwt ? `${vessel.dwt.toLocaleString()}톤` : '—'} />
|
||||
<InfoRow label="건조" value={vessel?.builtYr ? `${vessel.builtYr}` : '—'} />
|
||||
<InfoRow label="주기관" value={vessel?.engineDc || '—'} mono />
|
||||
<InfoRow label="선적" value={vessel?.flagCd || '—'} />
|
||||
<InfoRow label="호출부호" value={vessel?.callsign || '—'} mono />
|
||||
</div>
|
||||
|
||||
{/* 충돌 상대 */}
|
||||
{vessel2 && (
|
||||
<div className="p-1.5 bg-[rgba(249,115,22,0.04)] border border-[rgba(249,115,22,0.12)] rounded">
|
||||
<div className="text-[8px] font-bold text-status-orange font-korean mb-1">⚠ 충돌 상대: HAI FENG 168</div>
|
||||
<div className="text-[8px] font-bold text-status-orange font-korean mb-1">⚠ 충돌 상대: {vessel2.vesselNm}</div>
|
||||
<div className="text-[8px] text-text-3 font-korean leading-relaxed">
|
||||
🇨🇳 중국 벌크선 52,340GT · 좌현 35° 충돌 · No.1P 파공 1.2m×0.8m
|
||||
{vessel2.flagCd} {vessel2.vesselTp} {vessel2.gt ? `${vessel2.gt.toLocaleString()}GT` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
|
||||
@ -137,48 +144,30 @@ export function RightPanel({ onOpenBacktrack, onOpenRecalc, onOpenReport }: { on
|
||||
onToggle={() => setInsuranceExpanded(!insuranceExpanded)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-0.5 text-[9px] font-korean mb-2">
|
||||
<InfoRow label="선주" value="대한해운(주)" />
|
||||
<InfoRow label="운항사" value="대한해운(주)" />
|
||||
<InfoRow label="P&I" value="한국선주상호보험" />
|
||||
</div>
|
||||
|
||||
{/* 선체보험 */}
|
||||
<InsuranceCard
|
||||
title="🚢 선체보험 (H&M)"
|
||||
color="cyan"
|
||||
items={[
|
||||
{ label: '보험사', value: '삼성화재해상보험' },
|
||||
{ label: '보험가액', value: 'USD 28,500,000', mono: true },
|
||||
{ label: '보험기간', value: '2025.01~2026.01', valueColor: 'var(--green)' },
|
||||
{ label: '면책금', value: 'USD 150,000', mono: true }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 화물보험 */}
|
||||
<InsuranceCard
|
||||
title="📦 화물보험 (Cargo)"
|
||||
color="purple"
|
||||
items={[
|
||||
{ label: '보험사', value: 'DB손해보험' },
|
||||
{ label: '보험가액', value: 'USD 42,100,000', mono: true },
|
||||
{ label: '적하물', value: '벙커C유 72,850톤' },
|
||||
{ label: '조건', value: 'ICC(A) All Risks' }
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 유류오염배상 */}
|
||||
<InsuranceCard
|
||||
title="🛢 유류오염배상 (CLC/IOPC)"
|
||||
color="red"
|
||||
items={[
|
||||
{ label: '배상보증서', value: '유효 (2025-12-31)', valueColor: 'var(--green)' },
|
||||
{ label: 'CLC 한도', value: '89.77M SDR', mono: true },
|
||||
{ label: 'IOPC 기금', value: '203M SDR', mono: true },
|
||||
{ label: '추가기금', value: '750M SDR', mono: true },
|
||||
{ label: '발급기관', value: '한국선주상호보험' }
|
||||
]}
|
||||
/>
|
||||
{insurance && insurance.length > 0 ? (
|
||||
<>
|
||||
{insurance.filter(ins => ins.type === 'P&I').map((ins, i) => (
|
||||
<InsuranceCard key={`pi-${i}`} title="🚢 P&I" color="cyan" items={[
|
||||
{ label: '보험사', value: ins.insurer },
|
||||
{ label: '한도', value: `${ins.currency} ${ins.value}`, mono: true },
|
||||
]} />
|
||||
))}
|
||||
{insurance.filter(ins => ins.type === 'H&M').map((ins, i) => (
|
||||
<InsuranceCard key={`hm-${i}`} title="🚢 선체보험 (H&M)" color="cyan" items={[
|
||||
{ label: '보험사', value: ins.insurer },
|
||||
{ label: '보험가액', value: `${ins.currency} ${ins.value}`, mono: true },
|
||||
]} />
|
||||
))}
|
||||
{insurance.filter(ins => ins.type === 'CLC').map((ins, i) => (
|
||||
<InsuranceCard key={`clc-${i}`} title="🛢 유류오염배상 (CLC)" color="red" items={[
|
||||
{ label: '발급기관', value: ins.insurer },
|
||||
{ label: 'CLC 한도', value: `${ins.currency} ${ins.value}`, mono: true },
|
||||
]} />
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[9px] text-text-3 font-korean text-center py-4">보험 정보가 없습니다.</div>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
115
frontend/src/tabs/prediction/services/predictionApi.ts
Normal file
115
frontend/src/tabs/prediction/services/predictionApi.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
export interface PredictionAnalysis {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
analysisDate: string;
|
||||
requestor: string;
|
||||
duration: string;
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
location: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
kospsStatus: string;
|
||||
poseidonStatus: string;
|
||||
opendriftStatus: string;
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
}
|
||||
|
||||
export interface PredictionDetail {
|
||||
acdnt: {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
location: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
};
|
||||
spill: {
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
unit: string;
|
||||
fcstHr: number | null;
|
||||
} | null;
|
||||
vessels: Array<{
|
||||
vesselInfoSn: number;
|
||||
imoNo: string;
|
||||
vesselNm: string;
|
||||
vesselTp: string;
|
||||
loaM: number | null;
|
||||
breadthM: number | null;
|
||||
draftM: number | null;
|
||||
gt: number | null;
|
||||
dwt: number | null;
|
||||
builtYr: number | null;
|
||||
flagCd: string;
|
||||
callsign: string;
|
||||
engineDc: string;
|
||||
insuranceData: unknown;
|
||||
}>;
|
||||
weather: Array<{
|
||||
weatherDtm: string;
|
||||
windSpd: number | null;
|
||||
windDir: string | null;
|
||||
waveHgt: number | null;
|
||||
currentSpd: number | null;
|
||||
currentDir: string | null;
|
||||
temp: number | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BacktrackResult {
|
||||
backtrackSn: number;
|
||||
acdntSn: number;
|
||||
estSpilDtm: string | null;
|
||||
anlysRange: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
srchRadiusNm: number | null;
|
||||
totalVessels: number | null;
|
||||
execSttsCd: string;
|
||||
rsltData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const fetchPredictionAnalyses = async (params?: {
|
||||
search?: string;
|
||||
}): Promise<PredictionAnalysis[]> => {
|
||||
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchPredictionDetail = async (acdntSn: number): Promise<PredictionDetail> => {
|
||||
const response = await api.get<PredictionDetail>(`/prediction/analyses/${acdntSn}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchBacktrack = async (sn: number): Promise<BacktrackResult> => {
|
||||
const response = await api.get<BacktrackResult>(`/prediction/backtrack/${sn}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchBacktrackByAcdnt = async (
|
||||
acdntSn: number,
|
||||
): Promise<BacktrackResult | null> => {
|
||||
const response = await api.get<BacktrackResult[]>('/prediction/backtrack', {
|
||||
params: { acdntSn },
|
||||
});
|
||||
return response.data.length > 0 ? response.data[0] : null;
|
||||
};
|
||||
|
||||
export const createBacktrack = async (input: {
|
||||
acdntSn: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
srchRadiusNm?: number;
|
||||
anlysRange?: string;
|
||||
}): Promise<{ backtrackSn: number }> => {
|
||||
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
|
||||
return response.data;
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi'
|
||||
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi'
|
||||
|
||||
/* ─── Types ─── */
|
||||
type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'RESOLVED'
|
||||
@ -29,151 +31,6 @@ const SEV_STYLE: Record<Severity, { bg: string; color: string; label: string }>
|
||||
RESOLVED: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'RESOLVED' },
|
||||
}
|
||||
|
||||
/* ─── 사고 목록 ─── */
|
||||
const INCIDENTS = [
|
||||
'RSC-2024-0127 · M/V SEA GUARDIAN (충돌/좌초)',
|
||||
'RSC-2024-0125 · M/V PACIFIC STAR (기관 고장)',
|
||||
'RSC-2024-0118 · F/V DONG JIN (침수/전복위험)',
|
||||
]
|
||||
|
||||
/* ─── Mock 시나리오 데이터 ─── */
|
||||
const MOCK_SCENARIOS: RescueScenario[] = [
|
||||
{
|
||||
id: 'S-01', name: '사고 발생 직후', severity: 'CRITICAL',
|
||||
timeStep: 'T+0h', datetime: '2024.10.27 10:30 KST',
|
||||
gm: '0.8', list: '15', trim: '2.5', buoyancy: 30, oilRate: '100 L/min', bmRatio: '92%',
|
||||
description: '선수 #1·좌현 #3 침수. 복원력 급격 저하. 전복 위험 경고.',
|
||||
compartments: [
|
||||
{ name: '선수 #1 Hold', status: '완전 침수 (100%)', color: 'var(--red)' },
|
||||
{ name: '좌현 #3 DB Tank', status: '완전 침수 (100%)', color: 'var(--red)' },
|
||||
{ name: '기관실 하부', status: '일부 침수 (30%)', color: 'var(--orange)' },
|
||||
{ name: '우현 #2 DB Tank', status: '정상', color: 'var(--green)' },
|
||||
{ name: '선미 Void', status: '정상', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '전복 위험', value: 'CRITICAL — GM 0.8m 미만', color: 'var(--red)' },
|
||||
{ label: '침몰 위험', value: 'HIGH — 잔존부력 30%', color: 'var(--orange)' },
|
||||
{ label: '구조적 파손', value: 'MEDIUM — BM 92% 한계 근접', color: 'var(--yellow)' },
|
||||
{ label: '유류오염', value: 'HIGH — 100 L/min 유출 중', color: 'var(--orange)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '10:30', text: 'SOS 발신, 해경 통보', color: 'var(--red)' },
|
||||
{ time: '10:35', text: '구조헬기(B-703) 출동 명령', color: 'var(--orange)' },
|
||||
{ time: '10:40', text: '전 승조원 구명조끼 착용 지시', color: 'var(--yellow)' },
|
||||
{ time: '10:45', text: '비상배수펌프 가동', color: 'var(--cyan)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'S-02', name: '침수 확대 단계', severity: 'CRITICAL',
|
||||
timeStep: 'T+2h', datetime: '2024.10.27 12:30 KST',
|
||||
gm: '0.4', list: '22', trim: '3.8', buoyancy: 18, oilRate: '180 L/min', bmRatio: '105%',
|
||||
description: 'DB탱크 추가 침수. GM 0.4m 하락. 전복 임박 경고.',
|
||||
compartments: [
|
||||
{ name: '선수 #1 Hold', status: '완전 침수 (100%)', color: 'var(--red)' },
|
||||
{ name: '좌현 #3 DB Tank', status: '완전 침수 (100%)', color: 'var(--red)' },
|
||||
{ name: '기관실 하부', status: '대부분 침수 (70%)', color: 'var(--red)' },
|
||||
{ name: '우현 #2 DB Tank', status: '일부 침수 (40%)', color: 'var(--orange)' },
|
||||
{ name: '선미 Void', status: '정상', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '전복 위험', value: 'CRITICAL — GM 0.4m, 전복 임박', color: 'var(--red)' },
|
||||
{ label: '침몰 위험', value: 'CRITICAL — 잔존부력 18%', color: 'var(--red)' },
|
||||
{ label: '구조적 파손', value: 'HIGH — BM 105% 초과', color: 'var(--red)' },
|
||||
{ label: '유류오염', value: 'CRITICAL — 180 L/min 유출', color: 'var(--red)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '11:00', text: '밸러스트 이동 시도 (우현→좌현)', color: 'var(--cyan)' },
|
||||
{ time: '11:30', text: '예인선 2척 출동 요청', color: 'var(--orange)' },
|
||||
{ time: '12:00', text: '승조원 부분 퇴선 실시', color: 'var(--red)' },
|
||||
{ time: '12:20', text: '비상배수 추가 투입', color: 'var(--cyan)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'S-03', name: '응급조치 적용', severity: 'HIGH',
|
||||
timeStep: 'T+6h', datetime: '2024.10.27 16:30 KST',
|
||||
gm: '1.1', list: '12', trim: '2.0', buoyancy: 35, oilRate: '60 L/min', bmRatio: '78%',
|
||||
description: '밸러스트 이동+배출 완료. 임시 패치 적용. GM 부분 회복.',
|
||||
compartments: [
|
||||
{ name: '선수 #1 Hold', status: '침수 유지 (90%)', color: 'var(--red)' },
|
||||
{ name: '좌현 #3 DB Tank', status: '배수 진행 (60%)', color: 'var(--orange)' },
|
||||
{ name: '기관실 하부', status: '배수 진행 (40%)', color: 'var(--orange)' },
|
||||
{ name: '우현 #2 DB Tank', status: '밸러스트 주입 (80%)', color: 'var(--cyan)' },
|
||||
{ name: '선미 Void', status: '정상', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '전복 위험', value: 'MEDIUM — GM 1.1m 부분 회복', color: 'var(--yellow)' },
|
||||
{ label: '침몰 위험', value: 'HIGH — 잔존부력 35%', color: 'var(--orange)' },
|
||||
{ label: '구조적 파손', value: 'LOW — BM 78% 안정', color: 'var(--green)' },
|
||||
{ label: '유류오염', value: 'MEDIUM — 60 L/min', color: 'var(--yellow)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '13:00', text: '밸러스트 이동 완료 (좌현 경사 보정)', color: 'var(--green)' },
|
||||
{ time: '14:00', text: '임시 패치(수중 용접) 적용', color: 'var(--cyan)' },
|
||||
{ time: '15:00', text: '오일펜스 전개 완료', color: 'var(--orange)' },
|
||||
{ time: '16:00', text: '배수 펌프 풀가동 → GM 회복', color: 'var(--green)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'S-04', name: '예인 개시', severity: 'MEDIUM',
|
||||
timeStep: 'T+12h', datetime: '2024.10.27 22:30 KST',
|
||||
gm: '1.2', list: '8', trim: '1.5', buoyancy: 40, oilRate: '25 L/min', bmRatio: '68%',
|
||||
description: '예인선 2척 도착. 예인 줄 연결 완료. 3kn 속도로 예인 개시.',
|
||||
compartments: [
|
||||
{ name: '선수 #1 Hold', status: '침수 유지 (85%)', color: 'var(--red)' },
|
||||
{ name: '좌현 #3 DB Tank', status: '배수 완료 (20%)', color: 'var(--yellow)' },
|
||||
{ name: '기관실 하부', status: '배수 완료 (15%)', color: 'var(--green)' },
|
||||
{ name: '우현 #2 DB Tank', status: '밸러스트 (80%)', color: 'var(--cyan)' },
|
||||
{ name: '선미 Void', status: '정상', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '전복 위험', value: 'LOW — GM 1.2m 안정', color: 'var(--green)' },
|
||||
{ label: '침몰 위험', value: 'MEDIUM — 잔존부력 40%', color: 'var(--yellow)' },
|
||||
{ label: '구조적 파손', value: 'LOW — BM 68% 안정', color: 'var(--green)' },
|
||||
{ label: '유류오염', value: 'LOW — 25 L/min (감소 추세)', color: 'var(--green)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '20:00', text: '예인선 2척 현장 도착', color: 'var(--cyan)' },
|
||||
{ time: '21:00', text: '예인 줄 연결 완료', color: 'var(--green)' },
|
||||
{ time: '22:00', text: '3kn 속도 예인 개시', color: 'var(--green)' },
|
||||
{ time: '22:30', text: '야간 항해등 점등, 경계 유지', color: 'var(--yellow)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'S-05', name: '항만 입항·구난 완료', severity: 'RESOLVED',
|
||||
timeStep: 'T+24h', datetime: '2024.10.28 10:30 KST',
|
||||
gm: '1.5', list: '3', trim: '0.8', buoyancy: 55, oilRate: '0 L/min', bmRatio: '52%',
|
||||
description: '인천항 안벽 접안 완료. 실종자 전원 구조. 구난 작전 종료.',
|
||||
compartments: [
|
||||
{ name: '선수 #1 Hold', status: '배수 진행 (50%)', color: 'var(--orange)' },
|
||||
{ name: '좌현 #3 DB Tank', status: '배수 완료 (5%)', color: 'var(--green)' },
|
||||
{ name: '기관실 하부', status: '배수 완료 (0%)', color: 'var(--green)' },
|
||||
{ name: '우현 #2 DB Tank', status: '정상', color: 'var(--green)' },
|
||||
{ name: '선미 Void', status: '정상', color: 'var(--green)' },
|
||||
],
|
||||
assessment: [
|
||||
{ label: '전복 위험', value: 'RESOLVED — GM 1.5m 안정', color: 'var(--green)' },
|
||||
{ label: '침몰 위험', value: 'RESOLVED — 잔존부력 55%', color: 'var(--green)' },
|
||||
{ label: '구조적 파손', value: 'RESOLVED — 접안 완료', color: 'var(--green)' },
|
||||
{ label: '유류오염', value: 'RESOLVED — 유출 차단', color: 'var(--green)' },
|
||||
],
|
||||
actions: [
|
||||
{ time: '06:00', text: '인천항 진입 허가', color: 'var(--green)' },
|
||||
{ time: '08:00', text: '도선사 승선', color: 'var(--cyan)' },
|
||||
{ time: '09:30', text: '안벽 접안 완료', color: 'var(--green)' },
|
||||
{ time: '10:30', text: '실종자 전원 구조 확인 — 작전 종료', color: 'var(--green)' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/* ─── Chart Data ─── */
|
||||
const CHART_DATA = [
|
||||
{ id: 'S-01', label: 'T+0h', gm: 0.8, list: 15, buoy: 30, oil: 100, bm: 92, severity: 'CRITICAL' as Severity },
|
||||
{ id: 'S-02', label: 'T+2h', gm: 0.4, list: 22, buoy: 18, oil: 180, bm: 105, severity: 'CRITICAL' as Severity },
|
||||
{ id: 'S-03', label: 'T+6h', gm: 1.1, list: 12, buoy: 35, oil: 60, bm: 78, severity: 'HIGH' as Severity },
|
||||
{ id: 'S-04', label: 'T+12h', gm: 1.2, list: 8, buoy: 40, oil: 25, bm: 68, severity: 'MEDIUM' as Severity },
|
||||
{ id: 'S-05', label: 'T+24h', gm: 1.5, list: 3, buoy: 55, oil: 0, bm: 52, severity: 'RESOLVED' as Severity },
|
||||
]
|
||||
|
||||
const SEV_COLOR: Record<Severity, string> = { CRITICAL: '#f87171', HIGH: '#fb923c', MEDIUM: '#fbbf24', RESOLVED: '#22c55e' }
|
||||
|
||||
/* ─── Color helpers ─── */
|
||||
@ -182,19 +39,118 @@ function listColor(v: number) { return v > 20 ? 'var(--red)' : v > 10 ? 'var(--y
|
||||
function buoyColor(v: number) { return v < 30 ? 'var(--red)' : v < 50 ? 'var(--yellow)' : 'var(--green)' }
|
||||
function oilColor(v: number) { return v > 100 ? 'var(--red)' : v > 30 ? 'var(--orange)' : v > 0 ? 'var(--yellow)' : 'var(--green)' }
|
||||
|
||||
/* ─── API 시나리오 → 로컬 타입 변환 ─── */
|
||||
function toRescueScenario(s: RescueScenarioItem, i: number): RescueScenario {
|
||||
return {
|
||||
id: `S-${String(i + 1).padStart(2, '0')}`,
|
||||
name: s.description?.split('.')[0] ?? s.timeStep,
|
||||
severity: s.svrtCd as Severity,
|
||||
timeStep: s.timeStep,
|
||||
datetime: s.scenarioDtm
|
||||
? new Date(s.scenarioDtm).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}) + ' KST'
|
||||
: '—',
|
||||
gm: String(s.gmM ?? 0),
|
||||
list: String(s.listDeg ?? 0),
|
||||
trim: String(s.trimM ?? 0),
|
||||
buoyancy: s.buoyancyPct ?? 0,
|
||||
oilRate: s.oilRateLpm != null ? `${s.oilRateLpm} L/min` : '— L/min',
|
||||
bmRatio: s.bmRatioPct != null ? `${s.bmRatioPct}%` : '—%',
|
||||
description: s.description ?? '',
|
||||
compartments: s.compartments ?? [],
|
||||
assessment: s.assessment ?? [],
|
||||
actions: s.actions ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── ChartData 타입 ─── */
|
||||
interface ChartDataItem {
|
||||
id: string
|
||||
label: string
|
||||
gm: number
|
||||
list: number
|
||||
buoy: number
|
||||
oil: number
|
||||
bm: number
|
||||
severity: Severity
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════════
|
||||
RescueScenarioView
|
||||
═══════════════════════════════════════════════════════════════════ */
|
||||
export function RescueScenarioView() {
|
||||
const [ops, setOps] = useState<RescueOpsItem[]>([])
|
||||
const [apiScenarios, setApiScenarios] = useState<RescueScenarioItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedIncident, setSelectedIncident] = useState(0)
|
||||
const [scenarios] = useState<RescueScenario[]>(MOCK_SCENARIOS)
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set(['S-01', 'S-02', 'S-03', 'S-04', 'S-05']))
|
||||
const [selectedId, setSelectedId] = useState('S-01')
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set())
|
||||
const [selectedId, setSelectedId] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'time' | 'risk'>('time')
|
||||
const [detailView, setDetailView] = useState<DetailView>(0)
|
||||
const [newScnModalOpen, setNewScnModalOpen] = useState(false)
|
||||
|
||||
const selected = scenarios.find(s => s.id === selectedId)!
|
||||
const loadScenarios = useCallback(async (opsSn: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchRescueScenarios(opsSn)
|
||||
setApiScenarios(items)
|
||||
} catch (err) {
|
||||
console.error('[rescue] 시나리오 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadOps = useCallback(async () => {
|
||||
try {
|
||||
const items = await fetchRescueOps()
|
||||
setOps(items)
|
||||
if (items.length > 0) {
|
||||
loadScenarios(items[0].rescueOpsSn)
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[rescue] 구난 작전 목록 조회 실패:', err)
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loadScenarios])
|
||||
|
||||
useEffect(() => { loadOps() }, [loadOps])
|
||||
|
||||
useEffect(() => {
|
||||
if (ops.length > 0 && ops[selectedIncident]) {
|
||||
loadScenarios(ops[selectedIncident].rescueOpsSn)
|
||||
}
|
||||
}, [selectedIncident, ops, loadScenarios])
|
||||
|
||||
/* API 시나리오 → 로컬 타입 변환 */
|
||||
const scenarios: RescueScenario[] = apiScenarios.map(toRescueScenario)
|
||||
|
||||
/* checked / selectedId: apiScenarios 변경 시 초기화 */
|
||||
useEffect(() => {
|
||||
setChecked(new Set(scenarios.map(s => s.id)))
|
||||
if (scenarios.length > 0) setSelectedId(scenarios[0].id)
|
||||
}, [apiScenarios]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* chartData: scenarios에서 파생 */
|
||||
const chartData: ChartDataItem[] = scenarios.map(s => ({
|
||||
id: s.id,
|
||||
label: s.timeStep,
|
||||
gm: parseFloat(s.gm),
|
||||
list: parseFloat(s.list),
|
||||
buoy: s.buoyancy,
|
||||
oil: parseFloat(s.oilRate),
|
||||
bm: parseFloat(s.bmRatio),
|
||||
severity: s.severity,
|
||||
}))
|
||||
|
||||
const selected = scenarios.find(s => s.id === selectedId)
|
||||
|
||||
const sorted = [...scenarios].sort((a, b) => {
|
||||
if (sortBy === 'risk') {
|
||||
@ -225,7 +181,7 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select value={selectedIncident} onChange={e => setSelectedIncident(Number(e.target.value))} style={{ padding: '6px 12px', borderRadius: 6, border: '1px solid var(--bd)', background: 'var(--bg3)', color: 'var(--t1)', fontSize: 10, fontFamily: 'var(--fK)', outline: 'none' }}>
|
||||
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
|
||||
{ops.map((op, i) => <option key={op.rescueOpsSn} value={i}>{op.opsCd} · {op.vesselNm}</option>)}
|
||||
</select>
|
||||
<button onClick={() => setNewScnModalOpen(true)} style={{ padding: '6px 14px', borderRadius: 6, border: 'none', background: 'linear-gradient(135deg,var(--cyan),#3b82f6)', color: '#fff', fontSize: 10, fontWeight: 700, cursor: 'pointer', fontFamily: 'var(--fK)' }}>+ 신규 시나리오</button>
|
||||
</div>
|
||||
@ -248,6 +204,9 @@ export function RescueScenarioView() {
|
||||
|
||||
{/* Card list */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8, scrollbarWidth: 'thin', scrollbarColor: 'var(--bdL) transparent' }}>
|
||||
{loading && scenarios.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '40px 0', fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>시나리오 로딩 중...</div>
|
||||
)}
|
||||
{sorted.map(sc => {
|
||||
const isSel = selectedId === sc.id
|
||||
const sev = SEV_STYLE[sc.severity]
|
||||
@ -383,7 +342,7 @@ export function RescueScenarioView() {
|
||||
)}
|
||||
|
||||
{/* ─── VIEW 1: 비교 차트 ─── */}
|
||||
{detailView === 1 && <ScenarioComparison />}
|
||||
{detailView === 1 && <ScenarioComparison chartData={chartData} />}
|
||||
|
||||
{/* ─── VIEW 2: 지도 오버레이 ─── */}
|
||||
{detailView === 2 && (
|
||||
@ -393,7 +352,7 @@ export function RescueScenarioView() {
|
||||
<div style={{ fontSize: 13, fontWeight: 700, fontFamily: 'var(--fK)', color: 'var(--t1)', marginBottom: 6 }}>GIS 기반 시나리오 비교</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--t3)', fontFamily: 'var(--fK)', lineHeight: 1.6, marginBottom: 16 }}>선택된 시나리오의 침수 구역을 지도 위에 오버레이하여 비교합니다.</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
{MOCK_SCENARIOS.map(sc => (
|
||||
{scenarios.map(sc => (
|
||||
<div key={sc.id} style={{ padding: '6px 12px', borderRadius: 6, border: `1px solid ${SEV_STYLE[sc.severity].color}40`, background: SEV_STYLE[sc.severity].bg, fontSize: 9, fontFamily: 'var(--fK)' }}>
|
||||
<span style={{ fontWeight: 700, color: SEV_STYLE[sc.severity].color }}>{sc.id}</span>
|
||||
<span style={{ color: 'var(--t2)', marginLeft: 6 }}>{sc.name}</span>
|
||||
@ -411,13 +370,13 @@ export function RescueScenarioView() {
|
||||
</div>
|
||||
|
||||
{/* ═══ 신규 시나리오 모달 ═══ */}
|
||||
{newScnModalOpen && <NewScenarioModal onClose={() => setNewScnModalOpen(false)} />}
|
||||
{newScnModalOpen && <NewScenarioModal ops={ops} onClose={() => setNewScnModalOpen(false)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ═══ 신규 시나리오 생성 모달 ═══ */
|
||||
function NewScenarioModal({ onClose }: { onClose: () => void }) {
|
||||
function NewScenarioModal({ ops, onClose }: { ops: RescueOpsItem[]; onClose: () => void }) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
@ -471,7 +430,7 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) {
|
||||
<div>
|
||||
<label style={labelSt}>연계 사고 <span style={{ color: '#f87171' }}>*</span></label>
|
||||
<select defaultValue="0" style={selSt}>
|
||||
{INCIDENTS.map((inc, i) => <option key={i} value={i}>{inc}</option>)}
|
||||
{ops.map((op, i) => <option key={op.rescueOpsSn} value={i}>{op.opsCd} · {op.vesselNm}</option>)}
|
||||
<option value="new">+ 신규 사고 등록...</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -725,10 +684,18 @@ function NewScenarioModal({ onClose }: { onClose: () => void }) {
|
||||
}
|
||||
|
||||
/* ═══ 비교 차트 컴포넌트 ═══ */
|
||||
function ScenarioComparison() {
|
||||
function ScenarioComparison({ chartData }: { chartData: ChartDataItem[] }) {
|
||||
const W = 480, H = 180, PX = 50, PY = 20
|
||||
const pw = W - PX * 2, ph = H - PY * 2
|
||||
const xStep = pw / (CHART_DATA.length - 1)
|
||||
const xStep = chartData.length > 1 ? pw / (chartData.length - 1) : pw
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 40, textAlign: 'center', fontSize: 11, color: 'var(--t3)', fontFamily: 'var(--fK)' }}>
|
||||
비교할 시나리오 데이터가 없습니다.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20 }}>
|
||||
@ -745,10 +712,10 @@ function ScenarioComparison() {
|
||||
<line x1={PX} x2={W - PX} y1={PY + ph - (1.0 / 2.0) * ph} y2={PY + ph - (1.0 / 2.0) * ph} stroke="rgba(239,68,68,.4)" strokeDasharray="4" />
|
||||
<text x={W - PX + 4} y={PY + ph - (1.0 / 2.0) * ph + 3} fill="var(--red)" fontSize={7}>GM=1.0 위험</text>
|
||||
{/* Area */}
|
||||
<polygon points={`${PX},${PY + ph} ${CHART_DATA.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} ${PX + (CHART_DATA.length - 1) * xStep},${PY + ph}`} fill="rgba(6,182,212,.08)" />
|
||||
<polygon points={`${PX},${PY + ph} ${chartData.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} ${PX + (chartData.length - 1) * xStep},${PY + ph}`} fill="rgba(6,182,212,.08)" />
|
||||
{/* Line + dots */}
|
||||
<polyline points={CHART_DATA.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} fill="none" stroke="var(--cyan)" strokeWidth={2} />
|
||||
{CHART_DATA.map((d, i) => (
|
||||
<polyline points={chartData.map((d, i) => `${PX + i * xStep},${PY + ph - (d.gm / 2.0) * ph}`).join(' ')} fill="none" stroke="var(--cyan)" strokeWidth={2} />
|
||||
{chartData.map((d, i) => (
|
||||
<g key={d.id}>
|
||||
<circle cx={PX + i * xStep} cy={PY + ph - (d.gm / 2.0) * ph} r={4} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={1.5} />
|
||||
<text x={PX + i * xStep} y={PY + ph + 14} textAnchor="middle" fill="var(--t3)" fontSize={8} fontFamily="var(--fK)">{d.label}</text>
|
||||
@ -769,8 +736,8 @@ function ScenarioComparison() {
|
||||
return <g key={v}><line x1={PX} x2={W - PX} y1={y} y2={y} stroke="rgba(255,255,255,.06)" /><text x={PX - 6} y={y + 3} textAnchor="end" fill="var(--t3)" fontSize={7} fontFamily="var(--fM)">{v}</text></g>
|
||||
})}
|
||||
<line x1={PX} x2={W - PX} y1={PY + ph - (15 / 25) * ph} y2={PY + ph - (15 / 25) * ph} stroke="rgba(239,68,68,.3)" strokeDasharray="4" />
|
||||
<polyline points={CHART_DATA.map((d, i) => `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} />
|
||||
{CHART_DATA.map((d, i) => (
|
||||
<polyline points={chartData.map((d, i) => `${PX + i * xStep},${PY + ph - (d.list / 25) * ph}`).join(' ')} fill="none" stroke="var(--orange)" strokeWidth={2} />
|
||||
{chartData.map((d, i) => (
|
||||
<g key={d.id}>
|
||||
<circle cx={PX + i * xStep} cy={PY + ph - (d.list / 25) * ph} r={3.5} fill={SEV_COLOR[d.severity]} stroke="#0d1117" strokeWidth={1.5} />
|
||||
<text x={PX + i * xStep} y={PY + ph + 14} textAnchor="middle" fill="var(--t3)" fontSize={7} fontFamily="var(--fK)">{d.label}</text>
|
||||
@ -787,7 +754,7 @@ function ScenarioComparison() {
|
||||
const y = PY + ph - (v / 200) * ph
|
||||
return <g key={v}><line x1={PX} x2={W - PX} y1={y} y2={y} stroke="rgba(255,255,255,.06)" /><text x={PX - 6} y={y + 3} textAnchor="end" fill="var(--t3)" fontSize={7} fontFamily="var(--fM)">{v}</text></g>
|
||||
})}
|
||||
{CHART_DATA.map((d, i) => {
|
||||
{chartData.map((d, i) => {
|
||||
const barW = xStep * 0.5
|
||||
const barH = (d.oil / 200) * ph
|
||||
return (
|
||||
@ -809,23 +776,23 @@ function ScenarioComparison() {
|
||||
<thead>
|
||||
<tr style={{ background: 'rgba(6,182,212,.06)' }}>
|
||||
<th style={{ padding: '7px 8px', textAlign: 'left', borderBottom: '2px solid var(--bdL)', color: 'var(--cyan)' }}>지표</th>
|
||||
{CHART_DATA.map(d => (
|
||||
{chartData.map(d => (
|
||||
<th key={d.id} style={{ padding: '7px 8px', textAlign: 'center', borderBottom: '2px solid var(--bdL)', color: SEV_COLOR[d.severity] }}>{d.id}<br /><span style={{ fontWeight: 400, fontSize: 8, color: 'var(--t3)' }}>{d.label}</span></th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[
|
||||
{ label: 'GM (m)', key: 'gm', fmt: (d: typeof CHART_DATA[0]) => d.gm.toFixed(1), clr: (d: typeof CHART_DATA[0]) => gmColor(d.gm) },
|
||||
{ label: '횡경사 (°)', key: 'list', fmt: (d: typeof CHART_DATA[0]) => `${d.list}°`, clr: (d: typeof CHART_DATA[0]) => listColor(d.list) },
|
||||
{ label: '잔존부력 (%)', key: 'buoy', fmt: (d: typeof CHART_DATA[0]) => `${d.buoy}%`, clr: (d: typeof CHART_DATA[0]) => buoyColor(d.buoy) },
|
||||
{ label: '유출률 (L/min)', key: 'oil', fmt: (d: typeof CHART_DATA[0]) => `${d.oil}`, clr: (d: typeof CHART_DATA[0]) => oilColor(d.oil) },
|
||||
{ label: 'BM 비율 (%)', key: 'bm', fmt: (d: typeof CHART_DATA[0]) => `${d.bm}%`, clr: (d: typeof CHART_DATA[0]) => d.bm > 100 ? 'var(--red)' : d.bm > 85 ? 'var(--orange)' : 'var(--green)' },
|
||||
{ label: '위험 등급', key: 'sev', fmt: (d: typeof CHART_DATA[0]) => d.severity, clr: (d: typeof CHART_DATA[0]) => SEV_COLOR[d.severity] },
|
||||
{ label: 'GM (m)', key: 'gm', fmt: (d: ChartDataItem) => d.gm.toFixed(1), clr: (d: ChartDataItem) => gmColor(d.gm) },
|
||||
{ label: '횡경사 (°)', key: 'list', fmt: (d: ChartDataItem) => `${d.list}°`, clr: (d: ChartDataItem) => listColor(d.list) },
|
||||
{ label: '잔존부력 (%)', key: 'buoy', fmt: (d: ChartDataItem) => `${d.buoy}%`, clr: (d: ChartDataItem) => buoyColor(d.buoy) },
|
||||
{ label: '유출률 (L/min)', key: 'oil', fmt: (d: ChartDataItem) => `${d.oil}`, clr: (d: ChartDataItem) => oilColor(d.oil) },
|
||||
{ label: 'BM 비율 (%)', key: 'bm', fmt: (d: ChartDataItem) => `${d.bm}%`, clr: (d: ChartDataItem) => d.bm > 100 ? 'var(--red)' : d.bm > 85 ? 'var(--orange)' : 'var(--green)' },
|
||||
{ label: '위험 등급', key: 'sev', fmt: (d: ChartDataItem) => d.severity, clr: (d: ChartDataItem) => SEV_COLOR[d.severity] },
|
||||
].map(row => (
|
||||
<tr key={row.label} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}>
|
||||
<td style={{ padding: '6px 8px', fontWeight: 600, color: 'var(--t2)' }}>{row.label}</td>
|
||||
{CHART_DATA.map(d => (
|
||||
{chartData.map(d => (
|
||||
<td key={d.id} style={{ padding: '6px 8px', textAlign: 'center', fontFamily: 'var(--fM)', fontWeight: 700, color: row.clr(d) }}>{row.fmt(d)}</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSubMenu } from '@common/hooks/useSubMenu'
|
||||
import { RescueTheoryView } from './RescueTheoryView'
|
||||
import { RescueScenarioView } from './RescueScenarioView'
|
||||
import { fetchRescueOps } from '../services/rescueApi'
|
||||
import type { RescueOpsItem } from '../services/rescueApi'
|
||||
|
||||
/* ─── Types ─── */
|
||||
type AccidentType = 'collision' | 'grounding' | 'turning' | 'capsizing' | 'sharpTurn' | 'flooding' | 'sinking'
|
||||
@ -812,26 +814,66 @@ function MetricCard({ label, value, unit, color, sub, subColor }: {
|
||||
|
||||
/* ─── 긴급구난 목록 탭 ─── */
|
||||
function RescueListView() {
|
||||
const listData = [
|
||||
{ status: '대응중', statusColor: 'var(--red)', no: 'RSC-2026-001', vessel: 'M/V SEA GUARDIAN', type: '충돌/좌초', date: '2026-02-17 10:30', location: '37°28\'N 126°15\'E', crew: '15/20' },
|
||||
{ status: '대응중', statusColor: 'var(--orange)', no: 'RSC-2026-002', vessel: 'M/V EASTERN GLORY', type: '침수/전복', date: '2026-02-15 14:20', location: '35°05\'N 129°02\'E', crew: '22/28' },
|
||||
{ status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-048', vessel: 'M/V PACIFIC WAVE', type: '충돌', date: '2025-12-03 08:15', location: '34°45\'N 128°30\'E', crew: '18/18' },
|
||||
{ status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-047', vessel: 'M/V HARMONY', type: '좌초', date: '2025-11-20 22:40', location: '36°12\'N 126°50\'E', crew: '25/25' },
|
||||
{ status: '종료', statusColor: 'var(--green)', no: 'RSC-2025-046', vessel: 'M/V GRAND FORTUNE', type: '침몰', date: '2025-10-08 05:30', location: '33°30\'N 127°15\'E', crew: '10/22' },
|
||||
]
|
||||
const [opsList, setOpsList] = useState<RescueOpsItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
|
||||
const loadOps = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const items = await fetchRescueOps({ search: searchTerm || undefined })
|
||||
setOpsList(items)
|
||||
} catch (err) {
|
||||
console.error('[rescue] 구난 작전 목록 조회 실패:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [searchTerm])
|
||||
|
||||
useEffect(() => {
|
||||
loadOps()
|
||||
}, [loadOps])
|
||||
|
||||
const getStatusLabel = (sttsCd: string) => {
|
||||
switch (sttsCd) {
|
||||
case 'ACTIVE': return { label: '대응중', color: 'var(--red)' }
|
||||
case 'STANDBY': return { label: '대기', color: 'var(--orange)' }
|
||||
case 'COMPLETED': return { label: '종료', color: 'var(--green)' }
|
||||
default: return { label: sttsCd, color: 'var(--t3)' }
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeLabel = (tpCd: string) => {
|
||||
const map: Record<string, string> = {
|
||||
collision: '충돌', grounding: '좌초', turning: '선회',
|
||||
capsizing: '전복', sharpTurn: '급선회', flooding: '침수', sinking: '침몰',
|
||||
}
|
||||
return map[tpCd] ?? tpCd
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="px-5 py-4 flex items-center justify-between border-b border-border">
|
||||
<span className="text-sm font-bold font-korean">긴급구난 사고 목록</span>
|
||||
<div className="flex gap-2 items-center">
|
||||
<input type="text" placeholder="선박명 / 사고번호 검색..." className="px-3 py-1.5 bg-bg-0 border border-border rounded-md text-text-2 font-korean text-[11px] w-[200px] outline-none focus:border-[var(--cyan)]" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="선박명 / 사고번호 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="px-3 py-1.5 bg-bg-0 border border-border rounded-md text-text-2 font-korean text-[11px] w-[200px] outline-none focus:border-[var(--cyan)]"
|
||||
/>
|
||||
<button className="px-3.5 py-1.5 bg-[rgba(6,182,212,0.12)] border border-[rgba(6,182,212,0.3)] rounded-md text-[var(--cyan)] text-[11px] font-semibold cursor-pointer font-korean hover:bg-[rgba(6,182,212,0.2)]">
|
||||
+ 신규 사고 등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 pb-4">
|
||||
{loading ? (
|
||||
<div className="text-center py-20 text-text-3 text-sm">로딩 중...</div>
|
||||
) : opsList.length === 0 ? (
|
||||
<div className="text-center py-20 text-text-3 text-sm">구난 작전 데이터가 없습니다.</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-[11px] mt-3">
|
||||
<thead>
|
||||
<tr className="bg-bg-3 border-b border-border">
|
||||
@ -841,23 +883,27 @@ function RescueListView() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{listData.map((r, i) => (
|
||||
<tr key={i} className="border-b border-border hover:bg-bg-hover cursor-pointer">
|
||||
<td className="py-2 px-2.5">
|
||||
<span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{
|
||||
background: `color-mix(in srgb, ${r.statusColor} 15%, transparent)`, color: r.statusColor
|
||||
}}>{r.status}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2.5 font-mono text-[var(--cyan)] font-semibold">{r.no}</td>
|
||||
<td className="py-2 px-2.5 font-korean text-text-1 font-semibold">{r.vessel}</td>
|
||||
<td className="py-2 px-2.5 font-korean">{r.type}</td>
|
||||
<td className="py-2 px-2.5 font-mono text-text-3">{r.date}</td>
|
||||
<td className="py-2 px-2.5 font-mono text-text-3 text-[10px]">{r.location}</td>
|
||||
<td className="py-2 px-2.5 font-mono">{r.crew}</td>
|
||||
</tr>
|
||||
))}
|
||||
{opsList.map((r) => {
|
||||
const status = getStatusLabel(r.sttsCd)
|
||||
return (
|
||||
<tr key={r.rescueOpsSn} className="border-b border-border hover:bg-bg-hover cursor-pointer">
|
||||
<td className="py-2 px-2.5">
|
||||
<span className="px-2 py-0.5 rounded-xl text-[9px] font-bold" style={{
|
||||
background: `color-mix(in srgb, ${status.color} 15%, transparent)`, color: status.color
|
||||
}}>{status.label}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2.5 font-mono text-[var(--cyan)] font-semibold">{r.opsCd}</td>
|
||||
<td className="py-2 px-2.5 font-korean text-text-1 font-semibold">{r.vesselNm}</td>
|
||||
<td className="py-2 px-2.5 font-korean">{getTypeLabel(r.acdntTpCd)}</td>
|
||||
<td className="py-2 px-2.5 font-mono text-text-3">{r.regDtm ? new Date(r.regDtm).toLocaleString('ko-KR') : '—'}</td>
|
||||
<td className="py-2 px-2.5 font-mono text-text-3 text-[10px]">{r.locDc ?? '—'}</td>
|
||||
<td className="py-2 px-2.5 font-mono">{r.survivors ?? 0}/{r.totalCrew ?? 0}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
72
frontend/src/tabs/rescue/services/rescueApi.ts
Normal file
72
frontend/src/tabs/rescue/services/rescueApi.ts
Normal file
@ -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;
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user