develop #48
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;
|
||||
429
backend/src/prediction/predictionService.ts
Normal file
429
backend/src/prediction/predictionService.ts
Normal file
@ -0,0 +1,429 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
interface PredictionAnalysis {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
analysisDate: string;
|
||||
requestor: string;
|
||||
duration: string;
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
location: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
kospsStatus: string;
|
||||
poseidonStatus: string;
|
||||
opendriftStatus: string;
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
}
|
||||
|
||||
interface PredictionDetail {
|
||||
acdnt: {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
location: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
};
|
||||
spill: {
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
unit: string;
|
||||
fcstHr: number | null;
|
||||
} | null;
|
||||
vessels: Array<{
|
||||
vesselInfoSn: number;
|
||||
imoNo: string;
|
||||
vesselNm: string;
|
||||
vesselTp: string;
|
||||
loaM: number | null;
|
||||
breadthM: number | null;
|
||||
draftM: number | null;
|
||||
gt: number | null;
|
||||
dwt: number | null;
|
||||
builtYr: number | null;
|
||||
flagCd: string;
|
||||
callsign: string;
|
||||
engineDc: string;
|
||||
insuranceData: unknown;
|
||||
}>;
|
||||
weather: Array<{
|
||||
obsDtm: string;
|
||||
locNm: string;
|
||||
temp: string;
|
||||
weatherDc: string;
|
||||
wind: string;
|
||||
wave: string;
|
||||
humid: string;
|
||||
vis: string;
|
||||
sst: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface BacktrackResult {
|
||||
backtrackSn: number;
|
||||
acdntSn: number;
|
||||
estSpilDtm: string | null;
|
||||
anlysRange: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
srchRadiusNm: number | null;
|
||||
totalVessels: number | null;
|
||||
execSttsCd: string;
|
||||
rsltData: unknown;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface CreateBacktrackInput {
|
||||
acdntSn: number;
|
||||
lat: number;
|
||||
lon: number;
|
||||
estSpilDtm?: string;
|
||||
anlysRange?: string;
|
||||
srchRadiusNm?: number;
|
||||
}
|
||||
|
||||
interface SaveBoomLineInput {
|
||||
acdntSn: number;
|
||||
boomNm: string;
|
||||
priorityOrd?: number;
|
||||
geojson: unknown;
|
||||
lengthM?: number;
|
||||
efficiencyPct?: number;
|
||||
}
|
||||
|
||||
interface BoomLineItem {
|
||||
boomLineSn: number;
|
||||
acdntSn: number;
|
||||
boomNm: string;
|
||||
priorityOrd: number;
|
||||
geom: unknown;
|
||||
lengthM: number | null;
|
||||
efficiencyPct: number | null;
|
||||
sttsCd: string;
|
||||
regDtm: string;
|
||||
}
|
||||
|
||||
interface ListAnalysesInput {
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function listAnalyses(input: ListAnalysesInput): Promise<PredictionAnalysis[]> {
|
||||
const params: unknown[] = [];
|
||||
const conditions: string[] = ["A.USE_YN = 'Y'"];
|
||||
|
||||
if (input.search) {
|
||||
params.push(`%${input.search}%`);
|
||||
conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`);
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
|
||||
const sql = `
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
A.LOC_DC,
|
||||
A.ANALYST_NM,
|
||||
A.OFFICE_NM,
|
||||
A.REGION_NM,
|
||||
S.OIL_TP_CD,
|
||||
S.SPIL_QTY,
|
||||
S.SPIL_UNIT_CD,
|
||||
S.FCST_HR,
|
||||
P.KOSPS_STATUS,
|
||||
P.POSEIDON_STATUS,
|
||||
P.OPENDRIFT_STATUS,
|
||||
B.BACKTRACK_STATUS
|
||||
FROM ACDNT A
|
||||
LEFT JOIN SPIL_DATA S ON S.ACDNT_SN = A.ACDNT_SN
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ACDNT_SN,
|
||||
MAX(CASE WHEN ALGO_CD = 'KOSPS' THEN EXEC_STTS_CD END) AS KOSPS_STATUS,
|
||||
MAX(CASE WHEN ALGO_CD = 'POSEIDON' THEN EXEC_STTS_CD END) AS POSEIDON_STATUS,
|
||||
MAX(CASE WHEN ALGO_CD = 'OPENDRIFT' THEN EXEC_STTS_CD END) AS OPENDRIFT_STATUS
|
||||
FROM PRED_EXEC
|
||||
GROUP BY ACDNT_SN
|
||||
) P ON P.ACDNT_SN = A.ACDNT_SN
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
ACDNT_SN,
|
||||
MAX(CASE WHEN B.EXEC_STTS_CD IS NOT NULL THEN B.EXEC_STTS_CD ELSE 'pending' END) AS BACKTRACK_STATUS
|
||||
FROM BACKTRACK B
|
||||
GROUP BY ACDNT_SN
|
||||
) B ON B.ACDNT_SN = A.ACDNT_SN
|
||||
${whereClause}
|
||||
ORDER BY A.OCCRN_DTM DESC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
return rows.map((row: Record<string, unknown>) => ({
|
||||
acdntSn: Number(row['acdnt_sn']),
|
||||
acdntNm: String(row['acdnt_nm'] ?? ''),
|
||||
occurredAt: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
||||
analysisDate: row['occrn_dtm'] ? String(row['occrn_dtm']) : '',
|
||||
requestor: String(row['analyst_nm'] ?? ''),
|
||||
duration: row['fcst_hr'] != null ? `${row['fcst_hr']}hr` : '',
|
||||
oilType: String(row['oil_tp_cd'] ?? ''),
|
||||
volume: row['spil_qty'] != null ? parseFloat(String(row['spil_qty'])) : null,
|
||||
location: String(row['loc_dc'] ?? ''),
|
||||
lat: row['lat'] != null ? parseFloat(String(row['lat'])) : null,
|
||||
lon: row['lng'] != null ? parseFloat(String(row['lng'])) : null,
|
||||
kospsStatus: String(row['kosps_status'] ?? 'pending').toLowerCase(),
|
||||
poseidonStatus: String(row['poseidon_status'] ?? 'pending').toLowerCase(),
|
||||
opendriftStatus: String(row['opendrift_status'] ?? 'pending').toLowerCase(),
|
||||
backtrackStatus: String(row['backtrack_status'] ?? 'pending').toLowerCase(),
|
||||
analyst: String(row['analyst_nm'] ?? ''),
|
||||
officeName: String(row['office_nm'] ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAnalysisDetail(acdntSn: number): Promise<PredictionDetail | null> {
|
||||
const acdntSql = `
|
||||
SELECT
|
||||
A.ACDNT_SN,
|
||||
A.ACDNT_NM,
|
||||
A.OCCRN_DTM,
|
||||
A.LAT,
|
||||
A.LNG,
|
||||
A.LOC_DC,
|
||||
A.ANALYST_NM,
|
||||
A.OFFICE_NM
|
||||
FROM ACDNT A
|
||||
WHERE A.ACDNT_SN = $1
|
||||
AND A.USE_YN = 'Y'
|
||||
`;
|
||||
const { rows: acdntRows } = await wingPool.query(acdntSql, [acdntSn]);
|
||||
if (acdntRows.length === 0) return null;
|
||||
|
||||
const a = acdntRows[0] as Record<string, unknown>;
|
||||
|
||||
const spillSql = `
|
||||
SELECT
|
||||
OIL_TP_CD,
|
||||
SPIL_QTY,
|
||||
SPIL_UNIT_CD,
|
||||
FCST_HR
|
||||
FROM SPIL_DATA
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY SPIL_DATA_SN ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
const { rows: spillRows } = await wingPool.query(spillSql, [acdntSn]);
|
||||
|
||||
const vesselSql = `
|
||||
SELECT
|
||||
VESSEL_INFO_SN,
|
||||
IMO_NO,
|
||||
VESSEL_NM,
|
||||
VESSEL_TP,
|
||||
LOA_M,
|
||||
BREADTH_M,
|
||||
DRAFT_M,
|
||||
GT,
|
||||
DWT,
|
||||
BUILT_YR,
|
||||
FLAG_CD,
|
||||
CALLSIGN,
|
||||
ENGINE_DC,
|
||||
INSURANCE_DATA
|
||||
FROM VESSEL_INFO
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY VESSEL_INFO_SN ASC
|
||||
`;
|
||||
const { rows: vesselRows } = await wingPool.query(vesselSql, [acdntSn]);
|
||||
|
||||
const weatherSql = `
|
||||
SELECT
|
||||
OBS_DTM,
|
||||
LOC_NM,
|
||||
TEMP,
|
||||
WEATHER_DC,
|
||||
WIND,
|
||||
WAVE,
|
||||
HUMID,
|
||||
VIS,
|
||||
SST
|
||||
FROM ACDNT_WEATHER
|
||||
WHERE ACDNT_SN = $1
|
||||
ORDER BY OBS_DTM ASC
|
||||
`;
|
||||
const { rows: weatherRows } = await wingPool.query(weatherSql, [acdntSn]);
|
||||
|
||||
const spill =
|
||||
spillRows.length > 0
|
||||
? (() => {
|
||||
const s = spillRows[0] as Record<string, unknown>;
|
||||
return {
|
||||
oilType: String(s['oil_tp_cd'] ?? ''),
|
||||
volume: s['spil_qty'] != null ? parseFloat(String(s['spil_qty'])) : null,
|
||||
unit: String(s['spil_unit_cd'] ?? ''),
|
||||
fcstHr: s['fcst_hr'] != null ? parseFloat(String(s['fcst_hr'])) : null,
|
||||
};
|
||||
})()
|
||||
: null;
|
||||
|
||||
const vessels = vesselRows.map((v: Record<string, unknown>) => ({
|
||||
vesselInfoSn: Number(v['vessel_info_sn']),
|
||||
imoNo: String(v['imo_no'] ?? ''),
|
||||
vesselNm: String(v['vessel_nm'] ?? ''),
|
||||
vesselTp: String(v['vessel_tp'] ?? ''),
|
||||
loaM: v['loa_m'] != null ? parseFloat(String(v['loa_m'])) : null,
|
||||
breadthM: v['breadth_m'] != null ? parseFloat(String(v['breadth_m'])) : null,
|
||||
draftM: v['draft_m'] != null ? parseFloat(String(v['draft_m'])) : null,
|
||||
gt: v['gt'] != null ? parseFloat(String(v['gt'])) : null,
|
||||
dwt: v['dwt'] != null ? parseFloat(String(v['dwt'])) : null,
|
||||
builtYr: v['built_yr'] != null ? Number(v['built_yr']) : null,
|
||||
flagCd: String(v['flag_cd'] ?? ''),
|
||||
callsign: String(v['callsign'] ?? ''),
|
||||
engineDc: String(v['engine_dc'] ?? ''),
|
||||
insuranceData: v['insurance_data'] ?? null,
|
||||
}));
|
||||
|
||||
const weather = weatherRows.map((w: Record<string, unknown>) => ({
|
||||
obsDtm: w['obs_dtm'] ? String(w['obs_dtm']) : '',
|
||||
locNm: String(w['loc_nm'] ?? ''),
|
||||
temp: String(w['temp'] ?? ''),
|
||||
weatherDc: String(w['weather_dc'] ?? ''),
|
||||
wind: String(w['wind'] ?? ''),
|
||||
wave: String(w['wave'] ?? ''),
|
||||
humid: String(w['humid'] ?? ''),
|
||||
vis: String(w['vis'] ?? ''),
|
||||
sst: String(w['sst'] ?? ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
acdnt: {
|
||||
acdntSn: Number(a['acdnt_sn']),
|
||||
acdntNm: String(a['acdnt_nm'] ?? ''),
|
||||
occurredAt: a['occrn_dtm'] ? String(a['occrn_dtm']) : '',
|
||||
lat: a['lat'] != null ? parseFloat(String(a['lat'])) : null,
|
||||
lon: a['lng'] != null ? parseFloat(String(a['lng'])) : null,
|
||||
location: String(a['loc_dc'] ?? ''),
|
||||
analyst: String(a['analyst_nm'] ?? ''),
|
||||
officeName: String(a['office_nm'] ?? ''),
|
||||
},
|
||||
spill,
|
||||
vessels,
|
||||
weather,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getBacktrack(sn: number): Promise<BacktrackResult | null> {
|
||||
const sql = `
|
||||
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
||||
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
||||
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
||||
FROM BACKTRACK
|
||||
WHERE BACKTRACK_SN = $1 AND USE_YN = 'Y'
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [sn]);
|
||||
if (rows.length === 0) return null;
|
||||
return rowToBacktrack(rows[0] as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export async function listBacktracksByAcdnt(acdntSn: number): Promise<BacktrackResult[]> {
|
||||
const sql = `
|
||||
SELECT BACKTRACK_SN, ACDNT_SN, EST_SPIL_DTM, ANLYS_RANGE,
|
||||
LON, LAT, SRCH_RADIUS_NM, TOTAL_VESSELS,
|
||||
EXEC_STTS_CD, RSLT_DATA, REG_DTM
|
||||
FROM BACKTRACK
|
||||
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
||||
ORDER BY REG_DTM DESC
|
||||
`;
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
return rows.map((r: Record<string, unknown>) => rowToBacktrack(r));
|
||||
}
|
||||
|
||||
function rowToBacktrack(r: Record<string, unknown>): BacktrackResult {
|
||||
return {
|
||||
backtrackSn: Number(r['backtrack_sn']),
|
||||
acdntSn: Number(r['acdnt_sn']),
|
||||
estSpilDtm: r['est_spil_dtm'] ? String(r['est_spil_dtm']) : null,
|
||||
anlysRange: r['anlys_range'] ? String(r['anlys_range']) : null,
|
||||
lon: r['lon'] != null ? parseFloat(String(r['lon'])) : null,
|
||||
lat: r['lat'] != null ? parseFloat(String(r['lat'])) : null,
|
||||
srchRadiusNm: r['srch_radius_nm'] != null ? parseFloat(String(r['srch_radius_nm'])) : null,
|
||||
totalVessels: r['total_vessels'] != null ? Number(r['total_vessels']) : null,
|
||||
execSttsCd: String(r['exec_stts_cd'] ?? ''),
|
||||
rsltData: r['rslt_data'] ?? null,
|
||||
regDtm: String(r['reg_dtm'] ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBacktrack(
|
||||
input: CreateBacktrackInput,
|
||||
): Promise<{ backtrackSn: number }> {
|
||||
const { acdntSn, lat, lon, estSpilDtm, anlysRange, srchRadiusNm } = input;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO BACKTRACK (ACDNT_SN, LAT, LON, GEOM, LOC_DC, EST_SPIL_DTM, ANLYS_RANGE, SRCH_RADIUS_NM, EXEC_STTS_CD)
|
||||
VALUES (
|
||||
$1, $2, $3,
|
||||
ST_SetSRID(ST_MakePoint($3::float, $2::float), 4326),
|
||||
$3 || ' + ' || $2,
|
||||
$4, $5, $6, 'PENDING'
|
||||
)
|
||||
RETURNING BACKTRACK_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [
|
||||
acdntSn, lat, lon,
|
||||
estSpilDtm || null, anlysRange || null, srchRadiusNm || null,
|
||||
]);
|
||||
|
||||
return { backtrackSn: Number((rows[0] as Record<string, unknown>)['backtrack_sn']) };
|
||||
}
|
||||
|
||||
export async function saveBoomLine(input: SaveBoomLineInput): Promise<{ boomLineSn: number }> {
|
||||
const { acdntSn, boomNm, priorityOrd = 0, geojson, lengthM, efficiencyPct } = input;
|
||||
|
||||
const sql = `
|
||||
INSERT INTO BOOM_LINE (ACDNT_SN, BOOM_NM, PRIORITY_ORD, GEOM, LENGTH_M, EFFICIENCY_PCT)
|
||||
VALUES ($1, $2, $3, ST_GeomFromGeoJSON($4), $5, $6)
|
||||
RETURNING BOOM_LINE_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [
|
||||
acdntSn, boomNm, priorityOrd,
|
||||
JSON.stringify(geojson),
|
||||
lengthM || null, efficiencyPct || null,
|
||||
]);
|
||||
|
||||
return { boomLineSn: Number((rows[0] as Record<string, unknown>)['boom_line_sn']) };
|
||||
}
|
||||
|
||||
export async function listBoomLines(acdntSn: number): Promise<BoomLineItem[]> {
|
||||
const sql = `
|
||||
SELECT BOOM_LINE_SN, ACDNT_SN, BOOM_NM, PRIORITY_ORD,
|
||||
ST_AsGeoJSON(GEOM) AS GEOM, LENGTH_M, EFFICIENCY_PCT, STTS_CD, REG_DTM
|
||||
FROM BOOM_LINE
|
||||
WHERE ACDNT_SN = $1 AND USE_YN = 'Y'
|
||||
ORDER BY PRIORITY_ORD ASC
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [acdntSn]);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
boomLineSn: Number(r['boom_line_sn']),
|
||||
acdntSn: Number(r['acdnt_sn']),
|
||||
boomNm: String(r['boom_nm'] ?? ''),
|
||||
priorityOrd: Number(r['priority_ord'] ?? 0),
|
||||
geom: r['geom'] != null ? JSON.parse(String(r['geom'])) : null,
|
||||
lengthM: r['length_m'] != null ? parseFloat(String(r['length_m'])) : null,
|
||||
efficiencyPct: r['efficiency_pct'] != null ? parseFloat(String(r['efficiency_pct'])) : null,
|
||||
sttsCd: String(r['stts_cd'] ?? 'PLANNED'),
|
||||
regDtm: String(r['reg_dtm'] ?? ''),
|
||||
}));
|
||||
}
|
||||
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(),
|
||||
}));
|
||||
}
|
||||
62
backend/src/scat/scatRouter.ts
Normal file
62
backend/src/scat/scatRouter.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { requireAuth } from '../auth/authMiddleware.js';
|
||||
import { listZones, listSections, getSection } from './scatService.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ============================================================
|
||||
// GET /api/scat/zones — 조사구역 목록
|
||||
// ============================================================
|
||||
router.get('/zones', requireAuth, async (_req, res) => {
|
||||
try {
|
||||
const zones = await listZones();
|
||||
res.json(zones);
|
||||
} catch (err) {
|
||||
console.error('[scat] 조사구역 목록 조회 오류:', err);
|
||||
res.status(500).json({ error: '조사구역 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/scat/sections — 해안구간 목록 (필터링)
|
||||
// ============================================================
|
||||
router.get('/sections', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { zone, status, sensitivity, jurisdiction, search } = req.query as {
|
||||
zone?: string;
|
||||
status?: string;
|
||||
sensitivity?: string;
|
||||
jurisdiction?: string;
|
||||
search?: string;
|
||||
};
|
||||
const sections = await listSections({ zone, status, sensitivity, jurisdiction, search });
|
||||
res.json(sections);
|
||||
} catch (err) {
|
||||
console.error('[scat] 해안구간 목록 조회 오류:', err);
|
||||
res.status(500).json({ error: '해안구간 목록 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/scat/sections/:sn — 해안구간 상세
|
||||
// ============================================================
|
||||
router.get('/sections/:sn', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const sn = parseInt(req.params.sn as string, 10);
|
||||
if (isNaN(sn)) {
|
||||
res.status(400).json({ error: '유효하지 않은 구간 번호입니다.' });
|
||||
return;
|
||||
}
|
||||
const section = await getSection(sn);
|
||||
if (!section) {
|
||||
res.status(404).json({ error: '해안구간을 찾을 수 없습니다.' });
|
||||
return;
|
||||
}
|
||||
res.json(section);
|
||||
} catch (err) {
|
||||
console.error('[scat] 해안구간 상세 조회 오류:', err);
|
||||
res.status(500).json({ error: '해안구간 상세 조회 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
212
backend/src/scat/scatService.ts
Normal file
212
backend/src/scat/scatService.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { wingPool } from '../db/wingDb.js';
|
||||
|
||||
// ============================================================
|
||||
// 인터페이스
|
||||
// ============================================================
|
||||
|
||||
interface ZoneItem {
|
||||
cstSrvyZoneSn: number;
|
||||
zoneCd: string;
|
||||
zoneNm: string;
|
||||
jrsdNm: string;
|
||||
sectCnt: number;
|
||||
latCenter: number;
|
||||
lngCenter: number;
|
||||
latRange: number;
|
||||
lngRange: number;
|
||||
}
|
||||
|
||||
interface SectionListItem {
|
||||
cstSectSn: number;
|
||||
sectCd: string;
|
||||
sectNm: string;
|
||||
cstTpCd: string;
|
||||
esiCd: string;
|
||||
esiNum: number;
|
||||
lenM: number;
|
||||
snstvtCd: string;
|
||||
srvySttsCd: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
tags: string[];
|
||||
zoneCd: string;
|
||||
zoneNm: string;
|
||||
jrsdNm: string;
|
||||
}
|
||||
|
||||
interface SectionDetail {
|
||||
cstSectSn: number;
|
||||
sectCd: string;
|
||||
sectNm: string;
|
||||
cstTpCd: string;
|
||||
esiCd: string;
|
||||
esiNum: number;
|
||||
lenM: number;
|
||||
snstvtCd: string;
|
||||
srvySttsCd: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
tags: string[];
|
||||
geomJson: Record<string, unknown> | null;
|
||||
zoneCd: string;
|
||||
zoneNm: string;
|
||||
jrsdNm: string;
|
||||
shoreTp: string | null;
|
||||
accessDc: string | null;
|
||||
accessPt: string | null;
|
||||
sensitiveInfo: { t: string; v: string }[];
|
||||
cleanupMethods: string[];
|
||||
endCriteria: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 조사구역 목록 조회
|
||||
// ============================================================
|
||||
|
||||
export async function listZones(): Promise<ZoneItem[]> {
|
||||
const sql = `
|
||||
SELECT CST_SRVY_ZONE_SN, ZONE_CD, ZONE_NM, JRSD_NM,
|
||||
SECT_CNT, LAT_CENTER, LNG_CENTER, LAT_RANGE, LNG_RANGE
|
||||
FROM wing.CST_SRVY_ZONE
|
||||
WHERE USE_YN = 'Y'
|
||||
ORDER BY CST_SRVY_ZONE_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql);
|
||||
|
||||
// pg QueryResult rows — NUMERIC은 string 반환, 타입 단언 불가피
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
cstSrvyZoneSn: r.cst_srvy_zone_sn as number,
|
||||
zoneCd: r.zone_cd as string,
|
||||
zoneNm: r.zone_nm as string,
|
||||
jrsdNm: r.jrsd_nm as string,
|
||||
sectCnt: r.sect_cnt as number,
|
||||
latCenter: parseFloat(r.lat_center as string),
|
||||
lngCenter: parseFloat(r.lng_center as string),
|
||||
latRange: parseFloat(r.lat_range as string),
|
||||
lngRange: parseFloat(r.lng_range as string),
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 해안구간 목록 조회 (필터링)
|
||||
// ============================================================
|
||||
|
||||
export async function listSections(filters: {
|
||||
zone?: string;
|
||||
status?: string;
|
||||
sensitivity?: string;
|
||||
jurisdiction?: string;
|
||||
search?: string;
|
||||
}): Promise<SectionListItem[]> {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filters.zone) {
|
||||
conditions.push(`z.ZONE_CD = $${idx++}`);
|
||||
params.push(filters.zone);
|
||||
}
|
||||
if (filters.status) {
|
||||
conditions.push(`s.SRVY_STTS_CD = $${idx++}`);
|
||||
params.push(filters.status);
|
||||
}
|
||||
if (filters.sensitivity) {
|
||||
conditions.push(`s.SNSTVT_CD = $${idx++}`);
|
||||
params.push(filters.sensitivity);
|
||||
}
|
||||
if (filters.jurisdiction) {
|
||||
conditions.push(`z.JRSD_NM ILIKE '%' || $${idx++} || '%'`);
|
||||
params.push(filters.jurisdiction);
|
||||
}
|
||||
if (filters.search) {
|
||||
conditions.push(`s.SECT_NM ILIKE '%' || $${idx++} || '%'`);
|
||||
params.push(filters.search);
|
||||
}
|
||||
|
||||
conditions.push("s.USE_YN = 'Y'");
|
||||
conditions.push("z.USE_YN = 'Y'");
|
||||
const where = 'WHERE ' + conditions.join(' AND ');
|
||||
|
||||
const sql = `
|
||||
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
|
||||
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
|
||||
s.LAT, s.LNG, s.TAGS,
|
||||
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
|
||||
FROM wing.CST_SECT s
|
||||
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
|
||||
${where}
|
||||
ORDER BY s.CST_SECT_SN
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, params);
|
||||
|
||||
return rows.map((r: Record<string, unknown>) => ({
|
||||
cstSectSn: r.cst_sect_sn as number,
|
||||
sectCd: r.sect_cd as string,
|
||||
sectNm: r.sect_nm as string,
|
||||
cstTpCd: r.cst_tp_cd as string,
|
||||
esiCd: r.esi_cd as string,
|
||||
esiNum: r.esi_num as number,
|
||||
lenM: r.len_m as number,
|
||||
snstvtCd: r.snstvt_cd as string,
|
||||
srvySttsCd: r.srvy_stts_cd as string,
|
||||
lat: parseFloat(r.lat as string),
|
||||
lng: parseFloat(r.lng as string),
|
||||
tags: (r.tags as string[]) ?? [],
|
||||
zoneCd: r.zone_cd as string,
|
||||
zoneNm: r.zone_nm as string,
|
||||
jrsdNm: r.jrsd_nm as string,
|
||||
}));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 해안구간 단건 상세 조회 (JSONB 포함)
|
||||
// ============================================================
|
||||
|
||||
export async function getSection(sn: number): Promise<SectionDetail | null> {
|
||||
const sql = `
|
||||
SELECT s.CST_SECT_SN, s.SECT_CD, s.SECT_NM, s.CST_TP_CD,
|
||||
s.ESI_CD, s.ESI_NUM, s.LEN_M, s.SNSTVT_CD, s.SRVY_STTS_CD,
|
||||
s.LAT, s.LNG, s.TAGS,
|
||||
ST_AsGeoJSON(s.GEOM)::jsonb AS geom_json,
|
||||
s.SHORE_TP, s.ACCESS_DC, s.ACCESS_PT,
|
||||
s.SENSITIVE_INFO, s.CLEANUP_METHODS, s.END_CRITERIA, s.NOTES,
|
||||
z.ZONE_CD, z.ZONE_NM, z.JRSD_NM
|
||||
FROM wing.CST_SECT s
|
||||
JOIN wing.CST_SRVY_ZONE z ON z.CST_SRVY_ZONE_SN = s.CST_SRVY_ZONE_SN
|
||||
WHERE s.CST_SECT_SN = $1 AND s.USE_YN = 'Y'
|
||||
`;
|
||||
|
||||
const { rows } = await wingPool.query(sql, [sn]);
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const r = rows[0] as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
cstSectSn: r.cst_sect_sn as number,
|
||||
sectCd: r.sect_cd as string,
|
||||
sectNm: r.sect_nm as string,
|
||||
cstTpCd: r.cst_tp_cd as string,
|
||||
esiCd: r.esi_cd as string,
|
||||
esiNum: r.esi_num as number,
|
||||
lenM: r.len_m as number,
|
||||
snstvtCd: r.snstvt_cd as string,
|
||||
srvySttsCd: r.srvy_stts_cd as string,
|
||||
lat: parseFloat(r.lat as string),
|
||||
lng: parseFloat(r.lng as string),
|
||||
tags: (r.tags as string[]) ?? [],
|
||||
geomJson: (r.geom_json as Record<string, unknown>) ?? null,
|
||||
zoneCd: r.zone_cd as string,
|
||||
zoneNm: r.zone_nm as string,
|
||||
jrsdNm: r.jrsd_nm as string,
|
||||
shoreTp: (r.shore_tp as string) ?? null,
|
||||
accessDc: (r.access_dc as string) ?? null,
|
||||
accessPt: (r.access_pt as string) ?? null,
|
||||
sensitiveInfo: (r.sensitive_info as { t: string; v: string }[]) ?? [],
|
||||
cleanupMethods: (r.cleanup_methods as string[]) ?? [],
|
||||
endCriteria: (r.end_criteria as string[]) ?? [],
|
||||
notes: (r.notes as string[]) ?? [],
|
||||
};
|
||||
}
|
||||
@ -18,6 +18,10 @@ import hnsRouter from './hns/hnsRouter.js'
|
||||
import reportsRouter from './reports/reportsRouter.js'
|
||||
import 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,
|
||||
@ -45,7 +49,13 @@ app.use(helmet({
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'", "http://localhost:*", "https://*.gc-si.dev", "https://*.data.go.kr", "https://*.khoa.go.kr"],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
...(process.env.NODE_ENV !== 'production' ? ['http://localhost:*'] : []),
|
||||
'https://*.gc-si.dev',
|
||||
'https://*.data.go.kr',
|
||||
'https://*.khoa.go.kr',
|
||||
],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
@ -61,11 +71,12 @@ app.disable('x-powered-by')
|
||||
|
||||
// 3. CORS: 허용된 출처만 접근 가능
|
||||
const allowedOrigins = [
|
||||
'http://localhost:5173', // Vite dev server
|
||||
'http://localhost:5174',
|
||||
'http://localhost:3000',
|
||||
'https://wing-demo.gc-si.dev',
|
||||
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL (추가 도메인)
|
||||
process.env.FRONTEND_URL || 'https://wing-demo.gc-si.dev',
|
||||
...(process.env.NODE_ENV !== 'production' ? [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:5174',
|
||||
'http://localhost:3000',
|
||||
] : []),
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
app.use(cors({
|
||||
@ -147,6 +158,10 @@ app.use('/api/hns', hnsRouter)
|
||||
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) => {
|
||||
|
||||
31
database/migration/010_postgis_geom.sql
Normal file
31
database/migration/010_postgis_geom.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- ============================================================
|
||||
-- 010: PostGIS GEOMETRY 컬럼 일괄 추가
|
||||
-- 기존 NUMERIC LAT/LNG 유지 + GEOMETRY(Point, 4326) 추가
|
||||
-- ============================================================
|
||||
|
||||
SET search_path TO wing, public;
|
||||
|
||||
-- PostGIS 익스텐션 확인
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
-- ============================================================
|
||||
-- 1. ASSET_ORG — 방제자산 기관 위치
|
||||
-- ============================================================
|
||||
ALTER TABLE ASSET_ORG ADD COLUMN IF NOT EXISTS GEOM GEOMETRY(Point, 4326);
|
||||
|
||||
UPDATE ASSET_ORG
|
||||
SET GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
|
||||
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND GEOM IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_ASSET_ORG_GEOM ON ASSET_ORG USING GIST(GEOM);
|
||||
|
||||
-- ============================================================
|
||||
-- 2. ACDNT — 사고 위치
|
||||
-- ============================================================
|
||||
ALTER TABLE ACDNT ADD COLUMN IF NOT EXISTS LOC_GEOM GEOMETRY(Point, 4326);
|
||||
|
||||
UPDATE ACDNT
|
||||
SET LOC_GEOM = ST_SetSRID(ST_MakePoint(LNG, LAT), 4326)
|
||||
WHERE LAT IS NOT NULL AND LNG IS NOT NULL AND LOC_GEOM IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS IDX_ACDNT_LOC_GEOM ON ACDNT USING GIST(LOC_GEOM);
|
||||
1410
database/migration/011_scat.sql
Normal file
1410
database/migration/011_scat.sql
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
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
|
||||
);
|
||||
@ -6,6 +6,7 @@ import { LoginPage } from '@common/components/auth/LoginPage'
|
||||
import { registerMainTabSwitcher } from '@common/hooks/useSubMenu'
|
||||
import { useAuthStore } from '@common/store/authStore'
|
||||
import { useMenuStore } from '@common/store/menuStore'
|
||||
import { API_BASE_URL } from '@common/services/api'
|
||||
import { OilSpillView } from '@tabs/prediction'
|
||||
import { ReportsView } from '@tabs/reports'
|
||||
import { HNSView } from '@tabs/hns'
|
||||
@ -46,8 +47,7 @@ function App() {
|
||||
[JSON.stringify({ action: 'TAB_VIEW', detail: activeMainTab })],
|
||||
{ type: 'text/plain' }
|
||||
)
|
||||
const apiBase = import.meta.env.VITE_API_URL || 'http://localhost:3001/api'
|
||||
navigator.sendBeacon(`${apiBase}/audit/log`, blob)
|
||||
navigator.sendBeacon(`${API_BASE_URL}/audit/log`, blob)
|
||||
}, [activeMainTab, isAuthenticated])
|
||||
|
||||
// 세션 확인 중 스플래시
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
import type { BacktrackConditions, BacktrackVessel, ReplayShip, CollisionEvent } from '@common/types/backtrack'
|
||||
|
||||
export const MOCK_CONDITIONS: BacktrackConditions = {
|
||||
estimatedSpillTime: '02-10 06:30',
|
||||
analysisRange: '±12시간',
|
||||
searchRadius: '10 NM',
|
||||
spillLocation: { lat: 34.7312, lon: 127.6845 },
|
||||
totalVessels: 47,
|
||||
}
|
||||
|
||||
export const MOCK_VESSELS: BacktrackVessel[] = [
|
||||
{
|
||||
rank: 1,
|
||||
name: 'ORIENTAL GLORY',
|
||||
imo: '9412856',
|
||||
type: '유조선',
|
||||
flag: '🇰🇷',
|
||||
flagCountry: '대한민국',
|
||||
probability: 96.7,
|
||||
closestTime: '06:28',
|
||||
closestDistance: 0.02,
|
||||
speedChange: '급감속',
|
||||
aisStatus: '충돌신호',
|
||||
description: '06:28 HAI FENG 168과 충돌 → 06:30 No.1P 탱크 파공 → 벙커C유 유출 개시. AIS 충돌경보 발신, 속력 8.3kts→0.2kts 급감속.',
|
||||
color: '#ef4444',
|
||||
},
|
||||
{
|
||||
rank: 2,
|
||||
name: 'HAI FENG 168',
|
||||
imo: '9538721',
|
||||
type: '벌크선',
|
||||
flag: '🇨🇳',
|
||||
flagCountry: '중국',
|
||||
probability: 23.4,
|
||||
closestTime: '06:28',
|
||||
closestDistance: 0.02,
|
||||
speedChange: '급감속',
|
||||
aisStatus: '미확인',
|
||||
description: '충돌 당사선. 구상선수 손상으로 연료유탱크 미세 누유 가능성. 자체 연료(벙커C) 1,200톤 적재.',
|
||||
color: '#f97316',
|
||||
},
|
||||
{
|
||||
rank: 3,
|
||||
name: 'DONG JIN STAR',
|
||||
imo: '9287403',
|
||||
type: '케미컬탱커',
|
||||
flag: '🇰🇷',
|
||||
flagCountry: '대한민국',
|
||||
probability: 4.1,
|
||||
closestTime: '05:45',
|
||||
closestDistance: 1.8,
|
||||
speedChange: '정상',
|
||||
aisStatus: '정상',
|
||||
description: '',
|
||||
color: '#64788c',
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_REPLAY_SHIPS: ReplayShip[] = [
|
||||
{
|
||||
vesselName: 'ORIENTAL GLORY',
|
||||
color: '#ef4444',
|
||||
path: [
|
||||
{ lat: 34.82, lon: 127.58 },
|
||||
{ lat: 34.80, lon: 127.60 },
|
||||
{ lat: 34.78, lon: 127.62 },
|
||||
{ lat: 34.76, lon: 127.64 },
|
||||
{ lat: 34.75, lon: 127.66 },
|
||||
{ lat: 34.74, lon: 127.67 },
|
||||
{ lat: 34.73, lon: 127.68 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
],
|
||||
speedLabels: [
|
||||
'8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°',
|
||||
'8.3 kts · 215°', '8.3 kts · 215°', '8.3 kts · 215°',
|
||||
'2.1 kts · 215°', '0.2 kts · 정지', '0.2 kts · 정지',
|
||||
],
|
||||
},
|
||||
{
|
||||
vesselName: 'HAI FENG 168',
|
||||
color: '#f97316',
|
||||
path: [
|
||||
{ lat: 34.64, lon: 127.78 },
|
||||
{ lat: 34.66, lon: 127.76 },
|
||||
{ lat: 34.68, lon: 127.74 },
|
||||
{ lat: 34.70, lon: 127.72 },
|
||||
{ lat: 34.71, lon: 127.71 },
|
||||
{ lat: 34.72, lon: 127.70 },
|
||||
{ lat: 34.73, lon: 127.69 },
|
||||
{ lat: 34.7312, lon: 127.6845 },
|
||||
{ lat: 34.7315, lon: 127.6840 },
|
||||
],
|
||||
speedLabels: [
|
||||
'11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°',
|
||||
'11.2 kts · 038°', '11.2 kts · 038°', '11.2 kts · 038°',
|
||||
'3.4 kts · 038°', '0.5 kts · 정지', '0.5 kts · 정지',
|
||||
],
|
||||
},
|
||||
{
|
||||
vesselName: 'DONG JIN STAR',
|
||||
color: '#64788c',
|
||||
path: [
|
||||
{ lat: 34.82, lon: 127.52 },
|
||||
{ lat: 34.80, lon: 127.53 },
|
||||
{ lat: 34.78, lon: 127.54 },
|
||||
{ lat: 34.76, lon: 127.55 },
|
||||
{ lat: 34.74, lon: 127.56 },
|
||||
{ lat: 34.72, lon: 127.57 },
|
||||
{ lat: 34.70, lon: 127.58 },
|
||||
{ lat: 34.68, lon: 127.59 },
|
||||
{ lat: 34.66, lon: 127.60 },
|
||||
],
|
||||
speedLabels: [
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
'10.5 kts · 180°', '10.5 kts · 180°', '10.5 kts · 180°',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_COLLISION: CollisionEvent = {
|
||||
position: { lat: 34.7312, lon: 127.6845 },
|
||||
timeLabel: '06:28 충돌',
|
||||
progressPercent: 75,
|
||||
}
|
||||
@ -1,30 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
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;
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import type { InsuranceRow } from './assetTypes'
|
||||
|
||||
const DEFAULT_HAEWOON_API = import.meta.env.VITE_HAEWOON_API_URL || 'https://api.haewoon.or.kr/v1/insurance'
|
||||
|
||||
// 샘플 데이터 (외부 한국해운조합 API 연동 전 데모용)
|
||||
const INSURANCE_DEMO_DATA: InsuranceRow[] = [
|
||||
{ shipName: '유조선 한라호', mmsi: '440123456', imo: '9876001', insType: 'P&I 보험', insurer: '한국P&I클럽', policyNo: 'PI-2025-1234', start: '2025-07-01', expiry: '2026-06-30', limit: '50억' },
|
||||
@ -13,7 +15,7 @@ const INSURANCE_DEMO_DATA: InsuranceRow[] = [
|
||||
function ShipInsurance() {
|
||||
const [apiConnected, setApiConnected] = useState(false)
|
||||
const [showConfig, setShowConfig] = useState(false)
|
||||
const [configEndpoint, setConfigEndpoint] = useState('https://api.haewoon.or.kr/v1/insurance')
|
||||
const [configEndpoint, setConfigEndpoint] = useState(DEFAULT_HAEWOON_API)
|
||||
const [configApiKey, setConfigApiKey] = useState('')
|
||||
const [configKeyType, setConfigKeyType] = useState('mmsi')
|
||||
const [configRespType, setConfigRespType] = useState('json')
|
||||
|
||||
@ -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,11 @@ 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'
|
||||
import { api } from '@common/services/api'
|
||||
|
||||
export type PredictionModel = 'KOSPS' | 'POSEIDON' | 'OpenDrift'
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
@ -63,6 +65,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 +91,93 @@ export function OilSpillView() {
|
||||
})
|
||||
}
|
||||
|
||||
// 역추적: API에서 기존 결과 로딩
|
||||
const loadBacktrackData = useCallback(async (acdntSn: number) => {
|
||||
try {
|
||||
const bt = await fetchBacktrackByAcdnt(acdntSn)
|
||||
if (bt && bt.execSttsCd === 'completed' && bt.rsltData) {
|
||||
const rslt = bt.rsltData as Record<string, unknown>
|
||||
if (Array.isArray(rslt.vessels)) {
|
||||
setBacktrackVessels(rslt.vessels as BacktrackVessel[])
|
||||
}
|
||||
if (Array.isArray(rslt.replayShips)) {
|
||||
setReplayShips(rslt.replayShips as ReplayShip[])
|
||||
}
|
||||
if (rslt.collisionEvent) {
|
||||
setCollisionEvent(rslt.collisionEvent as CollisionEvent)
|
||||
}
|
||||
setBacktrackConditions({
|
||||
estimatedSpillTime: bt.estSpilDtm ? new Date(bt.estSpilDtm).toLocaleString('ko-KR', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : '',
|
||||
analysisRange: bt.anlysRange || '±12시간',
|
||||
searchRadius: bt.srchRadiusNm ? `${bt.srchRadiusNm} NM` : '10 NM',
|
||||
spillLocation: { lat: bt.lat || incidentCoord.lat, lon: bt.lon || incidentCoord.lon },
|
||||
totalVessels: bt.totalVessels || 0,
|
||||
})
|
||||
setBacktrackPhase('results')
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[prediction] 역추적 데이터 로딩 실패:', err)
|
||||
}
|
||||
// 기존 결과 없으면 conditions 상태 유지
|
||||
setBacktrackPhase('conditions')
|
||||
setBacktrackVessels([])
|
||||
setReplayShips([])
|
||||
setCollisionEvent(null)
|
||||
}, [incidentCoord])
|
||||
|
||||
// 역추적 핸들러
|
||||
const handleOpenBacktrack = () => {
|
||||
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 +214,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 +232,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')
|
||||
}
|
||||
@ -165,27 +260,16 @@ export function OilSpillView() {
|
||||
const models = Array.from(selectedModels)
|
||||
const results = await Promise.all(
|
||||
models.map(async (model) => {
|
||||
const response = await fetch('http://localhost:3001/api/simulation/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
lat: incidentCoord.lat,
|
||||
lon: incidentCoord.lon,
|
||||
duration_hours: predictionTime,
|
||||
oil_type: oilType,
|
||||
spill_amount: spillAmount,
|
||||
spill_type: spillType
|
||||
})
|
||||
const { data } = await api.post<{ trajectory: Array<{ lat: number; lon: number; time: number; particle?: number }> }>('/simulation/run', {
|
||||
model,
|
||||
lat: incidentCoord.lat,
|
||||
lon: incidentCoord.lon,
|
||||
duration_hours: predictionTime,
|
||||
oil_type: oilType,
|
||||
spill_amount: spillAmount,
|
||||
spill_type: spillType,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API 오류 (${model}): ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (data.trajectory as Array<{ lat: number; lon: number; time: number; particle?: number }>)
|
||||
.map(p => ({ ...p, model }))
|
||||
return data.trajectory.map(p => ({ ...p, model }))
|
||||
})
|
||||
)
|
||||
|
||||
@ -261,10 +345,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 +511,8 @@ export function OilSpillView() {
|
||||
onSeek={setReplayFrame}
|
||||
onSpeedChange={setReplaySpeed}
|
||||
onClose={handleCloseReplay}
|
||||
replayShips={MOCK_REPLAY_SHIPS}
|
||||
collisionEvent={MOCK_COLLISION}
|
||||
replayShips={replayShips}
|
||||
collisionEvent={collisionEvent || undefined}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@ -436,7 +520,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 +548,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>
|
||||
|
||||
117
frontend/src/tabs/prediction/services/predictionApi.ts
Normal file
117
frontend/src/tabs/prediction/services/predictionApi.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { api } from '@common/services/api';
|
||||
|
||||
export interface PredictionAnalysis {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
analysisDate: string;
|
||||
requestor: string;
|
||||
duration: string;
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
location: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
kospsStatus: string;
|
||||
poseidonStatus: string;
|
||||
opendriftStatus: string;
|
||||
backtrackStatus: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
}
|
||||
|
||||
export interface PredictionDetail {
|
||||
acdnt: {
|
||||
acdntSn: number;
|
||||
acdntNm: string;
|
||||
occurredAt: string;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
location: string;
|
||||
analyst: string;
|
||||
officeName: string;
|
||||
};
|
||||
spill: {
|
||||
oilType: string;
|
||||
volume: number | null;
|
||||
unit: string;
|
||||
fcstHr: number | null;
|
||||
} | null;
|
||||
vessels: Array<{
|
||||
vesselInfoSn: number;
|
||||
imoNo: string;
|
||||
vesselNm: string;
|
||||
vesselTp: string;
|
||||
loaM: number | null;
|
||||
breadthM: number | null;
|
||||
draftM: number | null;
|
||||
gt: number | null;
|
||||
dwt: number | null;
|
||||
builtYr: number | null;
|
||||
flagCd: string;
|
||||
callsign: string;
|
||||
engineDc: string;
|
||||
insuranceData: unknown;
|
||||
}>;
|
||||
weather: Array<{
|
||||
obsDtm: string;
|
||||
locNm: string;
|
||||
temp: string;
|
||||
weatherDc: string;
|
||||
wind: string;
|
||||
wave: string;
|
||||
humid: string;
|
||||
vis: string;
|
||||
sst: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface BacktrackResult {
|
||||
backtrackSn: number;
|
||||
acdntSn: number;
|
||||
estSpilDtm: string | null;
|
||||
anlysRange: string | null;
|
||||
lon: number | null;
|
||||
lat: number | null;
|
||||
srchRadiusNm: number | null;
|
||||
totalVessels: number | null;
|
||||
execSttsCd: string;
|
||||
rsltData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export const fetchPredictionAnalyses = async (params?: {
|
||||
search?: string;
|
||||
}): Promise<PredictionAnalysis[]> => {
|
||||
const response = await api.get<PredictionAnalysis[]>('/prediction/analyses', { params });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchPredictionDetail = async (acdntSn: number): Promise<PredictionDetail> => {
|
||||
const response = await api.get<PredictionDetail>(`/prediction/analyses/${acdntSn}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchBacktrack = async (sn: number): Promise<BacktrackResult> => {
|
||||
const response = await api.get<BacktrackResult>(`/prediction/backtrack/${sn}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchBacktrackByAcdnt = async (
|
||||
acdntSn: number,
|
||||
): Promise<BacktrackResult | null> => {
|
||||
const response = await api.get<BacktrackResult[]>('/prediction/backtrack', {
|
||||
params: { acdntSn },
|
||||
});
|
||||
return response.data.length > 0 ? response.data[0] : null;
|
||||
};
|
||||
|
||||
export const createBacktrack = async (input: {
|
||||
acdntSn: number;
|
||||
lon: number;
|
||||
lat: number;
|
||||
srchRadiusNm?: number;
|
||||
anlysRange?: string;
|
||||
}): Promise<{ backtrackSn: number }> => {
|
||||
const response = await api.post<{ backtrackSn: number }>('/prediction/backtrack', input);
|
||||
return response.data;
|
||||
};
|
||||
@ -1,4 +1,6 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { fetchRescueOps, fetchRescueScenarios } from '../services/rescueApi'
|
||||
import type { RescueOpsItem, RescueScenarioItem } from '../services/rescueApi'
|
||||
|
||||
/* ─── Types ─── */
|
||||
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;
|
||||
}
|
||||
@ -1,56 +1,120 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { ScatSegment, ScatDetail } from './scatTypes'
|
||||
import { allSegments, scatDetailData } from './scatConstants'
|
||||
import ScatLeftPanel from './ScatLeftPanel'
|
||||
import ScatMap from './ScatMap'
|
||||
import ScatTimeline from './ScatTimeline'
|
||||
import ScatPopup from './ScatPopup'
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { ScatSegment, ScatDetail } from './scatTypes';
|
||||
import { fetchSections, fetchSectionDetail, fetchZones } from '../services/scatApi';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import ScatLeftPanel from './ScatLeftPanel';
|
||||
import ScatMap from './ScatMap';
|
||||
import ScatTimeline from './ScatTimeline';
|
||||
import ScatPopup from './ScatPopup';
|
||||
|
||||
// ═══ Main PreScatView ═══
|
||||
|
||||
export function PreScatView() {
|
||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment>(allSegments[0])
|
||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)')
|
||||
const [areaFilter, setAreaFilter] = useState('전체')
|
||||
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)')
|
||||
const [statusFilter, setStatusFilter] = useState('전체')
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [popupData, setPopupData] = useState<ScatDetail | null>(null)
|
||||
const [timelineIdx, setTimelineIdx] = useState(6)
|
||||
const [segments, setSegments] = useState<ScatSegment[]>([]);
|
||||
const [zones, setZones] = useState<ApiZoneItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSeg, setSelectedSeg] = useState<ScatSegment | null>(null);
|
||||
const [jurisdictionFilter, setJurisdictionFilter] = useState('전체 (제주도)');
|
||||
const [areaFilter, setAreaFilter] = useState('전체');
|
||||
const [phaseFilter, setPhaseFilter] = useState('Pre-SCAT (사전조사)');
|
||||
const [statusFilter, setStatusFilter] = useState('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [popupData, setPopupData] = useState<ScatDetail | null>(null);
|
||||
const [timelineIdx, setTimelineIdx] = useState(6);
|
||||
|
||||
// API에서 구역 및 구간 데이터 로딩
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [zonesData, sectionsData] = await Promise.all([fetchZones(), fetchSections()]);
|
||||
if (cancelled) return;
|
||||
setZones(zonesData);
|
||||
setSegments(sectionsData);
|
||||
if (sectionsData.length > 0) {
|
||||
setSelectedSeg(sectionsData[0]);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SCAT] 데이터 로딩 오류:', err);
|
||||
if (!cancelled) setError('데이터를 불러오지 못했습니다.');
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 관할 기반 세그먼트 필터링
|
||||
const segments = allSegments.filter(s => {
|
||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포'
|
||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주'
|
||||
return true // 전체
|
||||
})
|
||||
const filteredSegments = segments.filter((s) => {
|
||||
if (jurisdictionFilter === '서귀포해양경비안전서') return s.jurisdiction === '서귀포';
|
||||
if (jurisdictionFilter === '제주해양경비안전서') return s.jurisdiction === '제주';
|
||||
return true; // 전체
|
||||
});
|
||||
|
||||
const handleOpenPopup = useCallback((idx: number) => {
|
||||
setPopupData(scatDetailData[idx] || scatDetailData[0])
|
||||
}, [])
|
||||
const handleOpenPopup = useCallback(async (sn: number) => {
|
||||
try {
|
||||
const detail = await fetchSectionDetail(sn);
|
||||
setPopupData(detail);
|
||||
} catch (err) {
|
||||
console.error('[SCAT] 상세 데이터 로딩 오류:', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClosePopup = useCallback(() => {
|
||||
setPopupData(null)
|
||||
}, [])
|
||||
setPopupData(null);
|
||||
}, []);
|
||||
|
||||
const handleTimelineSeek = useCallback((idx: number) => {
|
||||
if (idx === -1) {
|
||||
// advance signal from play
|
||||
setTimelineIdx(prev => {
|
||||
const next = (prev + 1) % Math.min(segments.length, 12)
|
||||
if (segments[next]) setSelectedSeg(segments[next])
|
||||
return next
|
||||
})
|
||||
} else {
|
||||
setTimelineIdx(idx)
|
||||
if (segments[idx]) setSelectedSeg(segments[idx])
|
||||
}
|
||||
}, [segments])
|
||||
const handleTimelineSeek = useCallback(
|
||||
(idx: number) => {
|
||||
if (idx === -1) {
|
||||
// advance signal from play
|
||||
setTimelineIdx((prev) => {
|
||||
const next = (prev + 1) % Math.min(filteredSegments.length, 12);
|
||||
if (filteredSegments[next]) setSelectedSeg(filteredSegments[next]);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setTimelineIdx(idx);
|
||||
if (filteredSegments[idx]) setSelectedSeg(filteredSegments[idx]);
|
||||
}
|
||||
},
|
||||
[filteredSegments],
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-0 items-center justify-center flex-col gap-3">
|
||||
<div className="text-status-red text-sm font-korean">{error}</div>
|
||||
<button
|
||||
onClick={() => { setError(null); setLoading(true); }}
|
||||
className="px-4 py-1.5 bg-primary-cyan text-white text-xs rounded font-korean"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || !selectedSeg) {
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-0 items-center justify-center">
|
||||
<div className="text-text-2 text-sm font-korean">SCAT 데이터 로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full h-full bg-bg-0 overflow-hidden">
|
||||
<ScatLeftPanel
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
zones={zones}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
@ -68,13 +132,13 @@ export function PreScatView() {
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<ScatMap
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
selectedSeg={selectedSeg}
|
||||
onSelectSeg={setSelectedSeg}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
/>
|
||||
<ScatTimeline
|
||||
segments={segments}
|
||||
segments={filteredSegments}
|
||||
currentIdx={timelineIdx}
|
||||
onSeek={handleTimelineSeek}
|
||||
/>
|
||||
@ -84,5 +148,5 @@ export function PreScatView() {
|
||||
<ScatPopup data={popupData} segCode={selectedSeg.code} onClose={handleClosePopup} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,28 @@
|
||||
import type { ScatSegment } from './scatTypes'
|
||||
import { esiColor, sensColor, statusColor, esiLevel, scatAreas, scatDetailData } from './scatConstants'
|
||||
import type { ScatSegment } from './scatTypes';
|
||||
import type { ApiZoneItem } from '../services/scatApi';
|
||||
import { esiColor, sensColor, statusColor, esiLevel } from './scatConstants';
|
||||
|
||||
interface ScatLeftPanelProps {
|
||||
segments: ScatSegment[]
|
||||
selectedSeg: ScatSegment
|
||||
onSelectSeg: (s: ScatSegment) => void
|
||||
onOpenPopup: (idx: number) => void
|
||||
jurisdictionFilter: string
|
||||
onJurisdictionChange: (v: string) => void
|
||||
areaFilter: string
|
||||
onAreaChange: (v: string) => void
|
||||
phaseFilter: string
|
||||
onPhaseChange: (v: string) => void
|
||||
statusFilter: string
|
||||
onStatusChange: (v: string) => void
|
||||
searchTerm: string
|
||||
onSearchChange: (v: string) => void
|
||||
segments: ScatSegment[];
|
||||
zones: ApiZoneItem[];
|
||||
selectedSeg: ScatSegment;
|
||||
onSelectSeg: (s: ScatSegment) => void;
|
||||
onOpenPopup: (sn: number) => void;
|
||||
jurisdictionFilter: string;
|
||||
onJurisdictionChange: (v: string) => void;
|
||||
areaFilter: string;
|
||||
onAreaChange: (v: string) => void;
|
||||
phaseFilter: string;
|
||||
onPhaseChange: (v: string) => void;
|
||||
statusFilter: string;
|
||||
onStatusChange: (v: string) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (v: string) => void;
|
||||
}
|
||||
|
||||
function ScatLeftPanel({
|
||||
segments,
|
||||
zones,
|
||||
selectedSeg,
|
||||
onSelectSeg,
|
||||
onOpenPopup,
|
||||
@ -34,12 +37,18 @@ function ScatLeftPanel({
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
}: ScatLeftPanelProps) {
|
||||
const filtered = segments.filter(s => {
|
||||
if (areaFilter !== '전체' && !s.area.includes(areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''))) return false
|
||||
if (statusFilter !== '전체' && s.status !== statusFilter) return false
|
||||
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false
|
||||
return true
|
||||
})
|
||||
const filtered = segments.filter((s) => {
|
||||
if (
|
||||
areaFilter !== '전체' &&
|
||||
!s.area.includes(
|
||||
areaFilter.replace('서귀포시 ', '').replace('제주시 ', '').replace(' 해안', ''),
|
||||
)
|
||||
)
|
||||
return false;
|
||||
if (statusFilter !== '전체' && s.status !== statusFilter) return false;
|
||||
if (searchTerm && !s.code.includes(searchTerm) && !s.name.includes(searchTerm)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-[340px] min-w-[340px] bg-bg-1 border-r border-border flex flex-col overflow-hidden">
|
||||
@ -51,8 +60,14 @@ function ScatLeftPanel({
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">관할 해경</label>
|
||||
<select value={jurisdictionFilter} onChange={e => onJurisdictionChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
관할 해경
|
||||
</label>
|
||||
<select
|
||||
value={jurisdictionFilter}
|
||||
onChange={(e) => onJurisdictionChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체 (제주도)</option>
|
||||
<option>서귀포해양경비안전서</option>
|
||||
<option>제주해양경비안전서</option>
|
||||
@ -60,18 +75,32 @@ function ScatLeftPanel({
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">해안 구역</label>
|
||||
<select value={areaFilter} onChange={e => onAreaChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
해안 구역
|
||||
</label>
|
||||
<select
|
||||
value={areaFilter}
|
||||
onChange={(e) => onAreaChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>전체</option>
|
||||
{scatAreas.map(a => (
|
||||
<option key={a.code}>{a.jurisdiction === '서귀포' ? '서귀포시' : '제주시'} {a.area} 해안</option>
|
||||
{zones.map((z) => (
|
||||
<option key={z.zoneCd}>
|
||||
{z.jrsdNm === '서귀포' ? '서귀포시' : '제주시'} {z.zoneNm} 해안
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-2.5">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">조사 단계</label>
|
||||
<select value={phaseFilter} onChange={e => onPhaseChange(e.target.value)} className="prd-i w-full">
|
||||
<label className="block text-[11px] font-medium text-text-1 mb-1 font-korean">
|
||||
조사 단계
|
||||
</label>
|
||||
<select
|
||||
value={phaseFilter}
|
||||
onChange={(e) => onPhaseChange(e.target.value)}
|
||||
className="prd-i w-full"
|
||||
>
|
||||
<option>Pre-SCAT (사전조사)</option>
|
||||
<option>SCAT (사고 시 조사)</option>
|
||||
<option>Post-SCAT (사후 확인)</option>
|
||||
@ -83,10 +112,14 @@ function ScatLeftPanel({
|
||||
type="text"
|
||||
placeholder="🔍 구간 검색..."
|
||||
value={searchTerm}
|
||||
onChange={e => onSearchChange(e.target.value)}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="prd-i flex-1"
|
||||
/>
|
||||
<select value={statusFilter} onChange={e => onStatusChange(e.target.value)} className="prd-i w-[70px]">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => onStatusChange(e.target.value)}
|
||||
className="prd-i w-[70px]"
|
||||
>
|
||||
<option>전체</option>
|
||||
<option>완료</option>
|
||||
<option>진행중</option>
|
||||
@ -102,54 +135,83 @@ function ScatLeftPanel({
|
||||
<span className="w-[3px] h-2.5 bg-status-green rounded-sm" />
|
||||
해안 구간 목록
|
||||
</span>
|
||||
<span className="text-primary-cyan font-mono text-[10px]">총 {filtered.length}개 구간</span>
|
||||
<span className="text-primary-cyan font-mono text-[10px]">
|
||||
총 {filtered.length}개 구간
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin flex flex-col gap-1.5">
|
||||
{filtered.map(seg => {
|
||||
const lvl = esiLevel(seg.esiNum)
|
||||
const borderColor = lvl === 'h' ? 'border-l-status-red' : lvl === 'm' ? 'border-l-status-orange' : 'border-l-status-green'
|
||||
const isSelected = selectedSeg.id === seg.id
|
||||
{filtered.map((seg) => {
|
||||
const lvl = esiLevel(seg.esiNum);
|
||||
const borderColor =
|
||||
lvl === 'h'
|
||||
? 'border-l-status-red'
|
||||
: lvl === 'm'
|
||||
? 'border-l-status-orange'
|
||||
: 'border-l-status-green';
|
||||
const isSelected = selectedSeg.id === seg.id;
|
||||
return (
|
||||
<div
|
||||
key={seg.id}
|
||||
onClick={() => { onSelectSeg(seg); onOpenPopup(seg.id % scatDetailData.length) }}
|
||||
onClick={() => {
|
||||
onSelectSeg(seg);
|
||||
onOpenPopup(seg.id);
|
||||
}}
|
||||
className={`bg-bg-3 border border-border rounded-sm p-2.5 px-3 cursor-pointer transition-all border-l-4 ${borderColor} ${
|
||||
isSelected ? 'border-status-green bg-[rgba(34,197,94,0.05)]' : 'hover:border-border-light hover:bg-bg-hover'
|
||||
isSelected
|
||||
? 'border-status-green bg-[rgba(34,197,94,0.05)]'
|
||||
: 'hover:border-border-light hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-semibold font-korean flex items-center gap-1.5">
|
||||
📍 {seg.code} {seg.area}
|
||||
</span>
|
||||
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white" style={{ background: esiColor(seg.esiNum) }}>
|
||||
<span
|
||||
className="text-[8px] font-bold px-1.5 py-0.5 rounded-lg text-white"
|
||||
style={{ background: esiColor(seg.esiNum) }}
|
||||
>
|
||||
ESI {seg.esi}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-text-2 font-korean">유형</span>
|
||||
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.type}</span>
|
||||
<span className="text-text-1 font-medium font-mono text-[11px]">
|
||||
{seg.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-text-2 font-korean">길이</span>
|
||||
<span className="text-text-1 font-medium font-mono text-[11px]">{seg.length}</span>
|
||||
<span className="text-text-1 font-medium font-mono text-[11px]">
|
||||
{seg.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-text-2 font-korean">민감</span>
|
||||
<span className="font-medium font-mono text-[11px]" style={{ color: sensColor[seg.sensitivity] }}>{seg.sensitivity}</span>
|
||||
<span
|
||||
className="font-medium font-mono text-[11px]"
|
||||
style={{ color: sensColor[seg.sensitivity] }}
|
||||
>
|
||||
{seg.sensitivity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-text-2 font-korean">현황</span>
|
||||
<span className="font-medium font-mono text-[11px]" style={{ color: statusColor[seg.status] }}>{seg.status}</span>
|
||||
<span
|
||||
className="font-medium font-mono text-[11px]"
|
||||
style={{ color: statusColor[seg.status] }}
|
||||
>
|
||||
{seg.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default ScatLeftPanel
|
||||
export default ScatLeftPanel;
|
||||
|
||||
@ -1,387 +1,104 @@
|
||||
import type { ScatSegment, ScatDetail } from './scatTypes'
|
||||
|
||||
// ═══ ESI 색상 ═══
|
||||
|
||||
export const esiColor = (n: number): string => {
|
||||
if (n >= 10) return '#991b1b'
|
||||
if (n >= 9) return '#b91c1c'
|
||||
if (n >= 8) return '#dc2626'
|
||||
if (n >= 7) return '#ef4444'
|
||||
if (n >= 6) return '#f97316'
|
||||
if (n >= 5) return '#fb923c'
|
||||
if (n >= 4) return '#facc15'
|
||||
if (n >= 3) return '#a3e635'
|
||||
if (n >= 2) return '#22c55e'
|
||||
return '#4ade80'
|
||||
}
|
||||
if (n >= 10) return '#991b1b';
|
||||
if (n >= 9) return '#b91c1c';
|
||||
if (n >= 8) return '#dc2626';
|
||||
if (n >= 7) return '#ef4444';
|
||||
if (n >= 6) return '#f97316';
|
||||
if (n >= 5) return '#fb923c';
|
||||
if (n >= 4) return '#facc15';
|
||||
if (n >= 3) return '#a3e635';
|
||||
if (n >= 2) return '#22c55e';
|
||||
return '#4ade80';
|
||||
};
|
||||
|
||||
export const sensColor: Record<string, string> = { '최상': 'var(--red)', '상': 'var(--red)', '중': 'var(--orange)', '하': 'var(--green)' }
|
||||
export const statusColor: Record<string, string> = { '완료': 'var(--green)', '진행중': 'var(--orange)', '미조사': 'var(--t3)' }
|
||||
export const esiLevel = (n: number) => n >= 8 ? 'h' : n >= 5 ? 'm' : 'l'
|
||||
export const sensColor: Record<string, string> = {
|
||||
최상: 'var(--red)',
|
||||
상: 'var(--red)',
|
||||
중: 'var(--orange)',
|
||||
하: 'var(--green)',
|
||||
};
|
||||
export const statusColor: Record<string, string> = {
|
||||
완료: 'var(--green)',
|
||||
진행중: 'var(--orange)',
|
||||
미조사: 'var(--t3)',
|
||||
};
|
||||
export const esiLevel = (n: number) => (n >= 8 ? 'h' : n >= 5 ? 'm' : 'l');
|
||||
|
||||
// ═══ Mock Data ═══
|
||||
// ═══ 제주도 해안선 좌표 (시계방향) ═══
|
||||
|
||||
// --- 서귀포시 (서귀포해양경비안전서 관할) ---
|
||||
export const sgAreas = [
|
||||
{ area: '성산읍', code: 'SGSS', cnt: 99, villages: ['시흥리', '오조리', '성산리', '고성리', '온평리', '신산리', '삼달리', '신풍리', '신천리'], jurisdiction: '서귀포' },
|
||||
{ area: '표선면', code: 'SGPS', cnt: 41, villages: ['하천리', '표선리', '세화리'], jurisdiction: '서귀포' },
|
||||
{ area: '남원읍', code: 'SGNW', cnt: 73, villages: ['신흥리', '태흥리', '남원리', '위미리', '신례리'], jurisdiction: '서귀포' },
|
||||
{ area: '하효동·보목동', code: 'SGHY', cnt: 8, villages: ['하효동', '보목동'], jurisdiction: '서귀포' },
|
||||
{ area: '토평동·동흥동', code: 'SGTP', cnt: 12, villages: ['토평동', '동흥동'], jurisdiction: '서귀포' },
|
||||
{ area: '서귀동·서홍동', code: 'SGSG', cnt: 20, villages: ['서귀동', '서홍동'], jurisdiction: '서귀포' },
|
||||
{ area: '호근동·법환동', code: 'SGHG', cnt: 6, villages: ['호근동', '서호동', '법환동'], jurisdiction: '서귀포' },
|
||||
{ area: '강정동', code: 'SGGJ', cnt: 21, villages: ['강정동'], jurisdiction: '서귀포' },
|
||||
{ area: '월평동·대포동', code: 'SGWP', cnt: 4, villages: ['월평동', '하원동', '대포동'], jurisdiction: '서귀포' },
|
||||
{ area: '중문동', code: 'SGJM', cnt: 8, villages: ['중문동'], jurisdiction: '서귀포' },
|
||||
{ area: '색달동·하예동', code: 'SGSE', cnt: 8, villages: ['색달동', '하예동'], jurisdiction: '서귀포' },
|
||||
{ area: '안덕면', code: 'SGAD', cnt: 38, villages: ['감산리', '사계리', '덕수리', '창천리', '대평리', '화순리'], jurisdiction: '서귀포' },
|
||||
{ area: '대정읍', code: 'SGDJ', cnt: 79, villages: ['상모리', '하모리', '영락리', '인성리', '보성리', '무릉리', '신도리'], jurisdiction: '서귀포' },
|
||||
]
|
||||
// --- 제주시 (제주해양경비안전서 관할) ---
|
||||
export const jjAreas = [
|
||||
{ area: '한경면', code: 'JJHG', cnt: 81, villages: ['고산리', '금등리', '두모리', '신창리', '용수리', '판포리'], jurisdiction: '제주' },
|
||||
{ area: '한림읍', code: 'JJHL', cnt: 87, villages: ['귀덕리', '금능리', '수원리', '옹포리', '월령리', '한림리', '한수리', '협재리'], jurisdiction: '제주' },
|
||||
{ area: '애월읍', code: 'JJAW', cnt: 89, villages: ['고내리', '곽지리', '구엄리', '금성리', '신엄리', '애월리', '하귀1리', '하귀2리'], jurisdiction: '제주' },
|
||||
{ area: '외도이동', code: 'JJOD', cnt: 19, villages: ['외도이동'], jurisdiction: '제주' },
|
||||
{ area: '내도동', code: 'JJND', cnt: 7, villages: ['내도동'], jurisdiction: '제주' },
|
||||
{ area: '이호일동', code: 'JJIH', cnt: 20, villages: ['이호일동'], jurisdiction: '제주' },
|
||||
{ area: '도두동', code: 'JJDD', cnt: 17, villages: ['도두일동', '도두이동'], jurisdiction: '제주' },
|
||||
{ area: '용담동', code: 'JJYD', cnt: 19, villages: ['용담삼동', '용담이동', '용담일동'], jurisdiction: '제주' },
|
||||
{ area: '삼도이동', code: 'JJSD', cnt: 2, villages: ['삼도2동'], jurisdiction: '제주' },
|
||||
{ area: '건입동', code: 'JJGI', cnt: 26, villages: ['건입동'], jurisdiction: '제주' },
|
||||
{ area: '화북일동', code: 'JJHB', cnt: 23, villages: ['화북일동'], jurisdiction: '제주' },
|
||||
{ area: '삼양삼동', code: 'JJYN', cnt: 19, villages: ['삼양삼동', '삼양이동', '삼양일동'], jurisdiction: '제주' },
|
||||
{ area: '삼양일동', code: 'JJSY', cnt: 24, villages: ['삼양이동', '삼양일동'], jurisdiction: '제주' },
|
||||
{ area: '조천읍', code: 'JJJC', cnt: 95, villages: ['북촌리', '신촌리', '신흥리', '조천리', '함덕리'], jurisdiction: '제주' },
|
||||
{ area: '구좌읍', code: 'JJGJ', cnt: 147, villages: ['김녕리', '동복리', '상도리', '월정리', '종달리', '평대리', '하도리', '한동리', '행원리'], jurisdiction: '제주' },
|
||||
]
|
||||
export const scatAreas = [...sgAreas, ...jjAreas]
|
||||
|
||||
export const scatSubstrates = ['투과성 인공호안', '수직호안', '모래', '모래자갈혼합', '자갈·왕자갈', '수평암반', '수직암반']
|
||||
export const substrateESI: Record<string, { esi: string; n: number }> = {
|
||||
'투과성 인공호안': { esi: '6B', n: 6 }, '수직호안': { esi: '1B', n: 1 },
|
||||
'모래': { esi: '3A', n: 3 }, '모래자갈혼합': { esi: '5', n: 5 },
|
||||
'자갈·왕자갈': { esi: '6A', n: 6 }, '수평암반': { esi: '8A', n: 8 }, '수직암반': { esi: '1A', n: 1 },
|
||||
}
|
||||
export const scatTagSets = [['🦪 양식장'], ['🏖 해수욕장'], ['⛵ 항구'], ['🪸 산호'], ['🌿 보호구역'], ['🐢 생태보전'], ['🏛 문화재'], ['⛰ 해안절벽'], ['🔧 인공구조물'], ['🌊 올레길']]
|
||||
const sensFromESI = (n: number): ScatSegment['sensitivity'] => n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하'
|
||||
const statusArr: ScatSegment['status'][] = ['완료', '완료', '완료', '완료', '진행중', '미조사']
|
||||
|
||||
// 지역별 좌표 범위 (제주도 전체 해안)
|
||||
export const areaCoords: Record<string, { latC: number; lngC: number; latR: number; lngR: number }> = {
|
||||
// 서귀포시 (남부 해안)
|
||||
SGSS: { latC: 33.39, lngC: 126.89, latR: 0.07, lngR: 0.05 },
|
||||
SGPS: { latC: 33.33, lngC: 126.81, latR: 0.03, lngR: 0.04 },
|
||||
SGNW: { latC: 33.26, lngC: 126.63, latR: 0.02, lngR: 0.05 },
|
||||
SGHY: { latC: 33.245, lngC: 126.59, latR: 0.005, lngR: 0.02 },
|
||||
SGTP: { latC: 33.245, lngC: 126.555, latR: 0.005, lngR: 0.015 },
|
||||
SGSG: { latC: 33.245, lngC: 126.53, latR: 0.005, lngR: 0.015 },
|
||||
SGHG: { latC: 33.245, lngC: 126.50, latR: 0.005, lngR: 0.02 },
|
||||
SGGJ: { latC: 33.245, lngC: 126.45, latR: 0.005, lngR: 0.03 },
|
||||
SGWP: { latC: 33.245, lngC: 126.40, latR: 0.005, lngR: 0.02 },
|
||||
SGJM: { latC: 33.245, lngC: 126.37, latR: 0.005, lngR: 0.015 },
|
||||
SGSE: { latC: 33.245, lngC: 126.34, latR: 0.005, lngR: 0.015 },
|
||||
SGAD: { latC: 33.24, lngC: 126.29, latR: 0.01, lngR: 0.035 },
|
||||
SGDJ: { latC: 33.25, lngC: 126.21, latR: 0.035, lngR: 0.05 },
|
||||
// 제주시 (북부 해안)
|
||||
JJHG: { latC: 33.31, lngC: 126.19, latR: 0.04, lngR: 0.04 },
|
||||
JJHL: { latC: 33.39, lngC: 126.26, latR: 0.04, lngR: 0.05 },
|
||||
JJAW: { latC: 33.46, lngC: 126.35, latR: 0.04, lngR: 0.06 },
|
||||
JJOD: { latC: 33.505, lngC: 126.43, latR: 0.005, lngR: 0.015 },
|
||||
JJND: { latC: 33.505, lngC: 126.44, latR: 0.003, lngR: 0.008 },
|
||||
JJIH: { latC: 33.50, lngC: 126.46, latR: 0.005, lngR: 0.012 },
|
||||
JJDD: { latC: 33.51, lngC: 126.49, latR: 0.005, lngR: 0.012 },
|
||||
JJYD: { latC: 33.515, lngC: 126.52, latR: 0.005, lngR: 0.015 },
|
||||
JJSD: { latC: 33.515, lngC: 126.525, latR: 0.003, lngR: 0.005 },
|
||||
JJGI: { latC: 33.52, lngC: 126.545, latR: 0.005, lngR: 0.015 },
|
||||
JJHB: { latC: 33.52, lngC: 126.565, latR: 0.005, lngR: 0.012 },
|
||||
JJYN: { latC: 33.52, lngC: 126.585, latR: 0.005, lngR: 0.012 },
|
||||
JJSY: { latC: 33.52, lngC: 126.59, latR: 0.005, lngR: 0.012 },
|
||||
JJJC: { latC: 33.535, lngC: 126.64, latR: 0.015, lngR: 0.04 },
|
||||
JJGJ: { latC: 33.53, lngC: 126.78, latR: 0.03, lngR: 0.10 },
|
||||
}
|
||||
|
||||
// 제주도 전체 해안선 좌표 (시계방향: 대정읍→서귀포→성산→조천→구좌→한경)
|
||||
export const jejuCoastCoords: [number, number][] = [
|
||||
// 서부 (대정읍~한경면)
|
||||
[33.2800, 126.1600], [33.2600, 126.1800], [33.2400, 126.2000],
|
||||
[33.28, 126.16],
|
||||
[33.26, 126.18],
|
||||
[33.24, 126.2],
|
||||
// 남부 (서귀포시 해안: 대정→안덕→중문→강정→서귀→남원→표선→성산)
|
||||
[33.2300, 126.2300], [33.2350, 126.2600], [33.2400, 126.2900], [33.2450, 126.3200],
|
||||
[33.2470, 126.3500], [33.2460, 126.3700], [33.2450, 126.4000], [33.2440, 126.4300],
|
||||
[33.2430, 126.4600], [33.2420, 126.4900], [33.2410, 126.5100], [33.2400, 126.5300],
|
||||
[33.2400, 126.5500], [33.2410, 126.5700], [33.2430, 126.5900], [33.2450, 126.6200],
|
||||
[33.2500, 126.6600], [33.2600, 126.7000], [33.2800, 126.7400], [33.3100, 126.7800],
|
||||
[33.3300, 126.8200], [33.3600, 126.8400], [33.3900, 126.8600], [33.4200, 126.8800],
|
||||
[33.4400, 126.9000], [33.4530, 126.9100], [33.4580, 126.9200], [33.4610, 126.9310],
|
||||
[33.23, 126.23],
|
||||
[33.235, 126.26],
|
||||
[33.24, 126.29],
|
||||
[33.245, 126.32],
|
||||
[33.247, 126.35],
|
||||
[33.246, 126.37],
|
||||
[33.245, 126.4],
|
||||
[33.244, 126.43],
|
||||
[33.243, 126.46],
|
||||
[33.242, 126.49],
|
||||
[33.241, 126.51],
|
||||
[33.24, 126.53],
|
||||
[33.24, 126.55],
|
||||
[33.241, 126.57],
|
||||
[33.243, 126.59],
|
||||
[33.245, 126.62],
|
||||
[33.25, 126.66],
|
||||
[33.26, 126.7],
|
||||
[33.28, 126.74],
|
||||
[33.31, 126.78],
|
||||
[33.33, 126.82],
|
||||
[33.36, 126.84],
|
||||
[33.39, 126.86],
|
||||
[33.42, 126.88],
|
||||
[33.44, 126.9],
|
||||
[33.453, 126.91],
|
||||
[33.458, 126.92],
|
||||
[33.461, 126.931],
|
||||
// 동부 (성산~구좌)
|
||||
[33.4700, 126.9200], [33.4900, 126.9100], [33.5100, 126.8700],
|
||||
[33.5200, 126.8500], [33.5350, 126.8200], [33.5450, 126.7900],
|
||||
[33.47, 126.92],
|
||||
[33.49, 126.91],
|
||||
[33.51, 126.87],
|
||||
[33.52, 126.85],
|
||||
[33.535, 126.82],
|
||||
[33.545, 126.79],
|
||||
// 북부 (제주시 해안: 구좌→조천→건입→이호→애월→한림→한경)
|
||||
[33.5500, 126.7600], [33.5500, 126.7300], [33.5450, 126.7000],
|
||||
[33.5400, 126.6800], [33.5350, 126.6600], [33.5300, 126.6400], [33.5250, 126.6200],
|
||||
[33.5200, 126.6000], [33.5200, 126.5800], [33.5200, 126.5600], [33.5180, 126.5400],
|
||||
[33.5160, 126.5200], [33.5140, 126.5000], [33.5120, 126.4800], [33.5100, 126.4600],
|
||||
[33.5050, 126.4400], [33.5000, 126.4200], [33.4950, 126.4000], [33.4850, 126.3800],
|
||||
[33.4700, 126.3500], [33.4550, 126.3300], [33.4400, 126.3100], [33.4200, 126.2900],
|
||||
[33.4000, 126.2700], [33.3800, 126.2500], [33.3600, 126.2350], [33.3400, 126.2200],
|
||||
[33.3200, 126.2050], [33.3100, 126.1900], [33.3000, 126.1750], [33.2930, 126.1620],
|
||||
]
|
||||
|
||||
function seededRandom(seed: number) {
|
||||
const x = Math.sin(seed) * 10000
|
||||
return x - Math.floor(x)
|
||||
}
|
||||
|
||||
const generateSegments = (): ScatSegment[] => {
|
||||
const segs: ScatSegment[] = []
|
||||
let idx = 0
|
||||
scatAreas.forEach(a => {
|
||||
const ac = areaCoords[a.code]
|
||||
for (let i = 0; i < a.cnt; i++) {
|
||||
const seed = idx * 137 + 42
|
||||
const village = a.villages[Math.floor(seededRandom(seed) * a.villages.length)]
|
||||
const substrate = scatSubstrates[Math.floor(seededRandom(seed + 1) * scatSubstrates.length)]
|
||||
const { esi: esiStr, n: esiNum } = substrateESI[substrate]
|
||||
const lengthM = Math.floor(seededRandom(seed + 3) * 900) + 100
|
||||
// 지역 좌표 범위 내 분포
|
||||
const progress = a.cnt > 1 ? i / (a.cnt - 1) : 0.5
|
||||
const lat = ac.latC + (progress - 0.5) * ac.latR * 2 + (seededRandom(seed + 6) - 0.5) * 0.003
|
||||
const lng = ac.lngC + (progress - 0.5) * ac.lngR * 2 + (seededRandom(seed + 7) - 0.5) * 0.003
|
||||
segs.push({
|
||||
id: idx,
|
||||
code: `${a.code}-${i + 1}`,
|
||||
area: a.area,
|
||||
name: `${village} 해안`,
|
||||
type: substrate,
|
||||
esi: esiStr,
|
||||
esiNum,
|
||||
length: `${lengthM.toLocaleString()}.0 m`,
|
||||
lengthM,
|
||||
sensitivity: sensFromESI(esiNum),
|
||||
status: statusArr[Math.floor(seededRandom(seed + 5) * statusArr.length)],
|
||||
lat, lng,
|
||||
tags: scatTagSets[Math.floor(seededRandom(seed + 8) * scatTagSets.length)],
|
||||
jurisdiction: a.jurisdiction,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
})
|
||||
return segs
|
||||
}
|
||||
|
||||
export const allSegments = generateSegments()
|
||||
|
||||
export const scatDetailData: ScatDetail[] = [
|
||||
// ═══ 서귀포시 (남부 해안) ═══
|
||||
// SGSS-1: 성산읍 시흥리 — 투과성 인공호안
|
||||
{
|
||||
code: 'SGSS-1', name: '서귀포시 성산읍 시흥리', esi: '6B', esiColor: '#f97316', lat: 33.4610, lng: 126.9310,
|
||||
type: '폐쇄형', substrate: '투과성 인공호안', length: '846.4m', sensitivity: '중', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1270-1, 12-64',
|
||||
sensitive: [{ t: '사회·경제적', v: '올레길1코스, 파래양식장' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// SGSS-6: 성산읍 시흥리 — 모래
|
||||
{
|
||||
code: 'SGSS-6', name: '서귀포시 성산읍 시흥리', esi: '3A', esiColor: '#a3e635', lat: 33.4580, lng: 126.9200,
|
||||
type: '폐쇄형', substrate: '모래', length: '131.3m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 성산읍 시흥리 1',
|
||||
sensitive: [{ t: '사회·경제적', v: '숙박시설, 조가비박물관' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
||||
},
|
||||
// SGSS-10: 성산읍 오조리 — 수평암반
|
||||
{
|
||||
code: 'SGSS-10', name: '서귀포시 성산읍 오조리', esi: '8A', esiColor: '#dc2626', lat: 33.4500, lng: 126.9050,
|
||||
type: '개방형', substrate: '수평암반', length: '433.6m', sensitivity: '상', status: '완료',
|
||||
access: '도보로 접근 가능, 인근구획에서 접근', accessPt: '서귀포시 성산읍 오조리 391',
|
||||
sensitive: [{ t: '사회·경제적', v: '교육시설(성산고등학교)' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||
},
|
||||
// SGPS-6: 표선면 표선리 — 모래 (표선해수욕장)
|
||||
{
|
||||
code: 'SGPS-6', name: '서귀포시 표선면 표선리', esi: '3A', esiColor: '#a3e635', lat: 33.3270, lng: 126.8320,
|
||||
type: '폐쇄형', substrate: '모래', length: '827.9m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 표선면 표선리 464-1',
|
||||
sensitive: [{ t: '사회·경제적', v: '표선해수욕장, 올레길3코스, 숙박시설, 민가' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||
},
|
||||
// SGNW-5: 남원읍 태흥리 — 수평암반
|
||||
{
|
||||
code: 'SGNW-5', name: '서귀포시 남원읍 태흥리', esi: '8A', esiColor: '#dc2626', lat: 33.2510, lng: 126.6650,
|
||||
type: '개방형', substrate: '수평암반', length: '432.8m', sensitivity: '상', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 7',
|
||||
sensitive: [{ t: '사회·경제적', v: '육상양식장' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||
},
|
||||
// SGNW-12: 남원읍 태흥리 — 모래자갈혼합
|
||||
{
|
||||
code: 'SGNW-12', name: '서귀포시 남원읍 태흥리', esi: '5', esiColor: '#fb923c', lat: 33.2480, lng: 126.6400,
|
||||
type: '폐쇄형', substrate: '모래자갈혼합', length: '237.3m', sensitivity: '중', status: '진행중',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 남원읍 태흥리 364-2',
|
||||
sensitive: [{ t: '사회·경제적', v: '올레길4코스, 민가' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능'],
|
||||
},
|
||||
// SGTP-5: 서귀동 — 투과성 인공호안 (서귀포항)
|
||||
{
|
||||
code: 'SGTP-5', name: '서귀포시 서귀동', esi: '6B', esiColor: '#f97316', lat: 33.2400, lng: 126.5550,
|
||||
type: '개방형', substrate: '투과성 인공호안', length: '701.6m', sensitivity: '중', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 서귀동 758-5',
|
||||
sensitive: [{ t: '사회·경제적', v: '서귀포항' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// SGGJ-5: 강정동 — 수직호안
|
||||
{
|
||||
code: 'SGGJ-5', name: '서귀포시 강정동', esi: '1B', esiColor: '#4ade80', lat: 33.2430, lng: 126.4500,
|
||||
type: '폐쇄형', substrate: '수직호안', length: '380.0m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 강정동 산1',
|
||||
sensitive: [{ t: '사회·경제적', v: '강정항' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적'],
|
||||
},
|
||||
// SGAD-5: 안덕면 감산리 — 수직호안 (대평항)
|
||||
{
|
||||
code: 'SGAD-5', name: '서귀포시 안덕면 감산리', esi: '1B', esiColor: '#4ade80', lat: 33.2400, lng: 126.2950,
|
||||
type: '폐쇄형', substrate: '수직호안', length: '246.9m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 982-1',
|
||||
sensitive: [{ t: '사회·경제적', v: '대평항' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// SGAD-7: 안덕면 감산리 — 자갈·왕자갈
|
||||
{
|
||||
code: 'SGAD-7', name: '서귀포시 안덕면 감산리', esi: '6A', esiColor: '#f97316', lat: 33.2380, lng: 126.2850,
|
||||
type: '폐쇄형', substrate: '자갈·왕자갈', length: '154.2m', sensitivity: '중', status: '진행중',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 안덕면 감산리 985',
|
||||
sensitive: [{ t: '사회·경제적', v: '올레길8코스(해안로), 민가' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
||||
},
|
||||
// SGDJ-5: 대정읍 상모리 — 수직호안 (산이수동항)
|
||||
{
|
||||
code: 'SGDJ-5', name: '서귀포시 대정읍 상모리', esi: '1B', esiColor: '#4ade80', lat: 33.2300, lng: 126.2350,
|
||||
type: '개방형', substrate: '수직호안', length: '202.0m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '서귀포시 대정읍 상모리 133',
|
||||
sensitive: [{ t: '사회·경제적', v: '산이수동항' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// SGDJ-7: 대정읍 상모리 — 모래 (송악산)
|
||||
{
|
||||
code: 'SGDJ-7', name: '서귀포시 대정읍 상모리', esi: '3A', esiColor: '#a3e635', lat: 33.2280, lng: 126.2280,
|
||||
type: '개방형', substrate: '모래', length: '179.6m', sensitivity: '하', status: '미조사',
|
||||
access: '도보로 접근 가능', accessPt: '서귀포시 대정읍 상모리 179-3',
|
||||
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||
},
|
||||
// SGDJ-8: 대정읍 상모리 — 수직암반 (송악산)
|
||||
{
|
||||
code: 'SGDJ-8', name: '서귀포시 대정읍 상모리', esi: '1A', esiColor: '#4ade80', lat: 33.2260, lng: 126.2200,
|
||||
type: '개방형', substrate: '수직암반', length: '585.1m', sensitivity: '하', status: '완료',
|
||||
access: '선박을 이용하여 접근', accessPt: '서귀포시 대정읍 상모리 179-3',
|
||||
sensitive: [{ t: '사회·경제적', v: '송악산' }, { t: '생물자원', v: '마라해안군립공원' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||
},
|
||||
// ═══ 제주시 (북부 해안) ═══
|
||||
// JJHG-1: 한경면 고산리 — 수평암반
|
||||
{
|
||||
code: 'JJHG-1', name: '제주시 한경면 고산리', esi: '8A', esiColor: '#dc2626', lat: 33.2930, lng: 126.1620,
|
||||
type: '개방형', substrate: '수평암반', length: '306.0m', sensitivity: '상', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한경면 고산리 3987',
|
||||
sensitive: [{ t: '사회·경제적', v: '육상양식장(도로 주변 농사구역)' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 30% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이', '부분적으로 모래와 암반이 형성되어 있음'],
|
||||
},
|
||||
// JJHG-8: 한경면 고산리 — 투과성 인공호안 (차귀도항)
|
||||
{
|
||||
code: 'JJHG-8', name: '제주시 한경면 고산리', esi: '6B', esiColor: '#f97316', lat: 33.3100, lng: 126.1750,
|
||||
type: '개방형', substrate: '투과성 인공호안', length: '201.8m', sensitivity: '중', status: '완료',
|
||||
access: '차량으로 접근 가능, 소형선박 이용 방제작업 가능', accessPt: '제주시 한경면 고산리 3616-10',
|
||||
sensitive: [{ t: '사회·경제적', v: '차귀도항, 잠수함 매표소' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업(흡착제,걸레)에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// JJHL-4: 한림읍 월령리 — 모래
|
||||
{
|
||||
code: 'JJHL-4', name: '제주시 한림읍 월령리', esi: '3A', esiColor: '#a3e635', lat: 33.3900, lng: 126.2400,
|
||||
type: '폐쇄형', substrate: '모래', length: '100.2m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 한림읍 월령리 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '월령항, 숙박시설' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대(upper swash zone)의 유류제거에 집중'],
|
||||
},
|
||||
// JJAW-8: 애월읍 곽지리 — 모래 (곽지해수욕장)
|
||||
{
|
||||
code: 'JJAW-8', name: '제주시 애월읍 곽지리', esi: '3A', esiColor: '#a3e635', lat: 33.4700, lng: 126.3400,
|
||||
type: '개방형', substrate: '모래', length: '573.6m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 애월읍 곽지리 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '곽지해수욕장, 캠핑장' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '퇴적물 갈기/파도세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['유출유는 해빈 전체에 표착될 가능성이 높음', '하부 지역 유출유가 창조시에 상부로 이동 가능', '포말대의 유류제거에 집중'],
|
||||
},
|
||||
// JJGI-3: 건입동 — 수직호안 (제주항)
|
||||
{
|
||||
code: 'JJGI-3', name: '제주시 건입동', esi: '1B', esiColor: '#4ade80', lat: 33.5200, lng: 126.5450,
|
||||
type: '폐쇄형', substrate: '수직호안', length: '365.8m', sensitivity: '하', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 건입동 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '제주항, 제주조선' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 호안의 거친 표면에 쉽게 표착될 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// JJJC-4: 조천읍 신촌리 — 자갈·왕자갈
|
||||
{
|
||||
code: 'JJJC-4', name: '제주시 조천읍 신촌리', esi: '6A', esiColor: '#f97316', lat: 33.5380, lng: 126.6400,
|
||||
type: '폐쇄형', substrate: '자갈·왕자갈', length: '360.4m', sensitivity: '중', status: '진행중',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 조천읍 신촌리 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '정치망어장(전면 270m)' }, { t: '생물자원', v: '폐류 서식지' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압세척', '범람(저수세정, Flooding)', '자갈세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 10% 미만', '타르볼 100m당 1개 미만'],
|
||||
notes: ['파도가 덮치는 지점 상부에 고인기름은 신속히 제거', '저압세척은 퇴적물로부터 표착유를 재부유시키며, 부유기름은 흡착재로 회수'],
|
||||
},
|
||||
// JJGJ-2: 구좌읍 동복리 — 투과성 인공호안
|
||||
{
|
||||
code: 'JJGJ-2', name: '제주시 구좌읍 동복리', esi: '6B', esiColor: '#f97316', lat: 33.5500, lng: 126.7300,
|
||||
type: '폐쇄형', substrate: '투과성 인공호안', length: '219.2m', sensitivity: '중', status: '완료',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '접안시설' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '저압/고압세척', '고온수 저압/고압세척', '스팀세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 블록의 거친 표면에 쉽게 표착되고 블록 사이로 깊게 침투할 가능성이 높음', '신선유 또는 액상일 때 고압세척 방제활동이 효과적', '중유·풍화 기름은 수작업으로 긁어내거나 고온수 고압세척 이용'],
|
||||
},
|
||||
// JJGJ-3: 구좌읍 동복리 — 수평암반
|
||||
{
|
||||
code: 'JJGJ-3', name: '제주시 구좌읍 동복리', esi: '8A', esiColor: '#dc2626', lat: 33.5480, lng: 126.7350,
|
||||
type: '개방형', substrate: '수평암반', length: '197.4m', sensitivity: '상', status: '미조사',
|
||||
access: '차량으로 접근 가능, 해안 뒤편에서 접근', accessPt: '제주시 구좌읍 동복리 3855',
|
||||
sensitive: [{ t: '사회·경제적', v: '산책로, 민가' }, { t: '생물자원', v: '없음' }],
|
||||
cleanup: ['수작업에 의한 제거', '흡착재를 이용한 회수/수거', '저압/고압세척'],
|
||||
endCriteria: ['유류 쓰레기가 없어야 함', '표면 접촉 시 기름이 묻어나지 않아야 함', '표면 coat/stain 두께 유류 20% 미만', '민감자원에 영향을 줄 수 있는 유막이 발생하지 않아야 함'],
|
||||
notes: ['유출유는 물기 있는 암반 표면에는 표착되지 않고 틈새와 퇴적층을 침투', '고조 시 접근 가능한 해안은 수작업으로 고농도 유출유 제거 용이'],
|
||||
},
|
||||
]
|
||||
[33.55, 126.76],
|
||||
[33.55, 126.73],
|
||||
[33.545, 126.7],
|
||||
[33.54, 126.68],
|
||||
[33.535, 126.66],
|
||||
[33.53, 126.64],
|
||||
[33.525, 126.62],
|
||||
[33.52, 126.6],
|
||||
[33.52, 126.58],
|
||||
[33.52, 126.56],
|
||||
[33.518, 126.54],
|
||||
[33.516, 126.52],
|
||||
[33.514, 126.5],
|
||||
[33.512, 126.48],
|
||||
[33.51, 126.46],
|
||||
[33.505, 126.44],
|
||||
[33.5, 126.42],
|
||||
[33.495, 126.4],
|
||||
[33.485, 126.38],
|
||||
[33.47, 126.35],
|
||||
[33.455, 126.33],
|
||||
[33.44, 126.31],
|
||||
[33.42, 126.29],
|
||||
[33.4, 126.27],
|
||||
[33.38, 126.25],
|
||||
[33.36, 126.235],
|
||||
[33.34, 126.22],
|
||||
[33.32, 126.205],
|
||||
[33.31, 126.19],
|
||||
[33.3, 126.175],
|
||||
[33.293, 126.162],
|
||||
];
|
||||
|
||||
135
frontend/src/tabs/scat/services/scatApi.ts
Normal file
135
frontend/src/tabs/scat/services/scatApi.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { api } from '@common/services/api';
|
||||
import type { ScatSegment, ScatDetail } from '../components/scatTypes';
|
||||
import { esiColor } from '../components/scatConstants';
|
||||
|
||||
// ============================================================
|
||||
// 백엔드 API 응답 타입
|
||||
// ============================================================
|
||||
|
||||
export interface ApiZoneItem {
|
||||
cstSrvyZoneSn: number;
|
||||
zoneCd: string;
|
||||
zoneNm: string;
|
||||
jrsdNm: string;
|
||||
sectCnt: number;
|
||||
latCenter: number;
|
||||
lngCenter: number;
|
||||
latRange: number;
|
||||
lngRange: number;
|
||||
}
|
||||
|
||||
export interface ApiSectionListItem {
|
||||
cstSectSn: number;
|
||||
sectCd: string;
|
||||
sectNm: string;
|
||||
cstTpCd: string;
|
||||
esiCd: string;
|
||||
esiNum: number;
|
||||
lenM: number;
|
||||
snstvtCd: string;
|
||||
srvySttsCd: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
tags: string[];
|
||||
zoneCd: string;
|
||||
zoneNm: string;
|
||||
jrsdNm: string;
|
||||
}
|
||||
|
||||
export interface ApiSectionDetail extends ApiSectionListItem {
|
||||
geomJson: Record<string, unknown> | null;
|
||||
shoreTp: string | null;
|
||||
accessDc: string | null;
|
||||
accessPt: string | null;
|
||||
sensitiveInfo: { t: string; v: string }[];
|
||||
cleanupMethods: string[];
|
||||
endCriteria: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 프론트 호환 변환 함수
|
||||
// ============================================================
|
||||
|
||||
const sensFromESI = (n: number): ScatSegment['sensitivity'] =>
|
||||
n >= 9 ? '최상' : n >= 7 ? '상' : n >= 5 ? '중' : '하';
|
||||
|
||||
function toScatSegment(item: ApiSectionListItem): ScatSegment {
|
||||
const lengthM = Number(item.lenM) || 0;
|
||||
return {
|
||||
id: item.cstSectSn,
|
||||
code: item.sectCd,
|
||||
area: item.zoneNm,
|
||||
name: item.sectNm,
|
||||
type: item.cstTpCd,
|
||||
esi: item.esiCd,
|
||||
esiNum: item.esiNum,
|
||||
length: `${lengthM.toLocaleString()}.0 m`,
|
||||
lengthM,
|
||||
sensitivity: (item.snstvtCd as ScatSegment['sensitivity']) || sensFromESI(item.esiNum),
|
||||
status: (item.srvySttsCd as ScatSegment['status']) || '미조사',
|
||||
lat: item.lat,
|
||||
lng: item.lng,
|
||||
tags: item.tags ?? [],
|
||||
jurisdiction: item.jrsdNm,
|
||||
};
|
||||
}
|
||||
|
||||
function toScatDetail(item: ApiSectionDetail): ScatDetail {
|
||||
const lengthM = Number(item.lenM) || 0;
|
||||
return {
|
||||
code: item.sectCd,
|
||||
name: item.sectNm,
|
||||
esi: item.esiCd,
|
||||
esiColor: esiColor(item.esiNum),
|
||||
lat: item.lat,
|
||||
lng: item.lng,
|
||||
type: item.shoreTp ?? item.cstTpCd,
|
||||
substrate: item.cstTpCd,
|
||||
length: `${lengthM.toLocaleString()}.0 m`,
|
||||
sensitivity: item.snstvtCd,
|
||||
status: item.srvySttsCd,
|
||||
access: item.accessDc ?? '',
|
||||
accessPt: item.accessPt ?? '',
|
||||
sensitive: item.sensitiveInfo ?? [],
|
||||
cleanup: item.cleanupMethods ?? [],
|
||||
endCriteria: item.endCriteria ?? [],
|
||||
notes: item.notes ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API 호출 함수
|
||||
// ============================================================
|
||||
|
||||
export async function fetchZones(): Promise<ApiZoneItem[]> {
|
||||
const { data } = await api.get<ApiZoneItem[]>('/scat/zones');
|
||||
return data;
|
||||
}
|
||||
|
||||
export interface SectionFilters {
|
||||
zone?: string;
|
||||
status?: string;
|
||||
sensitivity?: string;
|
||||
jurisdiction?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export async function fetchSections(filters?: SectionFilters): Promise<ScatSegment[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (filters?.zone) params.set('zone', filters.zone);
|
||||
if (filters?.status) params.set('status', filters.status);
|
||||
if (filters?.sensitivity) params.set('sensitivity', filters.sensitivity);
|
||||
if (filters?.jurisdiction) params.set('jurisdiction', filters.jurisdiction);
|
||||
if (filters?.search) params.set('search', filters.search);
|
||||
|
||||
const query = params.toString();
|
||||
const url = query ? `/scat/sections?${query}` : '/scat/sections';
|
||||
const { data } = await api.get<ApiSectionListItem[]>(url);
|
||||
return data.map(toScatSegment);
|
||||
}
|
||||
|
||||
export async function fetchSectionDetail(sn: number): Promise<ScatDetail> {
|
||||
const { data } = await api.get<ApiSectionDetail>(`/scat/sections/${sn}`);
|
||||
return toScatDetail(data);
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user