feat(incidents): 통합 분석 패널 HNS/구난 연동 및 사고 목록 wing.ACDNT 전환

- 우측 패널에 HNS 대기확산/긴급구난 완료 이력 목록 및 체크박스 연동
- incidents 목록에 hasHnsCompleted/hasRescueCompleted 플래그 추가
- hns/rescue 목록 API에 acdntSn 필터 추가
- /gsc/accidents 셀렉트박스 소스를 gsc.tgs_acdnt_info → wing.ACDNT 로 전환
- gsc → wing.ACDNT 동기화 마이그레이션 032 추가
This commit is contained in:
jeonghyo.k 2026-04-15 17:31:28 +09:00
부모 0daae3c807
커밋 1f66723060
11개의 변경된 파일424개의 추가작업 그리고 75개의 파일을 삭제

파일 보기

@ -8,43 +8,18 @@ export interface GscAccidentListItem {
lon: number | null; lon: number | null;
} }
const ACDNT_ASORT_CODES = [
'055001001',
'055001002',
'055001003',
'055001004',
'055001005',
'055001006',
'055003001',
'055003002',
'055003003',
'055003004',
'055003005',
'055004003',
];
export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> { export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[]> {
const sql = ` const sql = `
SELECT DISTINCT ON (a.acdnt_mng_no) SELECT
a.acdnt_mng_no AS "acdntMngNo", ACDNT_CD AS "acdntMngNo",
a.acdnt_title AS "pollNm", ACDNT_NM AS "pollNm",
to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate",
a.rcept_dt AS "rceptDt", LAT AS "lat",
b.la AS "lat", LNG AS "lon"
b.lo AS "lon" FROM wing.ACDNT
FROM gsc.tgs_acdnt_info AS a WHERE ACDNT_NM IS NOT NULL
LEFT JOIN gsc.tgs_acdnt_lc AS b ORDER BY OCCRN_DTM DESC NULLS LAST
ON a.acdnt_mng_no = b.acdnt_mng_no LIMIT $1
WHERE a.acdnt_asort_code = ANY($1::varchar[])
AND a.acdnt_title IS NOT NULL
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
`;
const orderedSql = `
SELECT "acdntMngNo", "pollNm", "pollDate", "lat", "lon"
FROM (${sql}) t
ORDER BY t."rceptDt" DESC NULLS LAST
LIMIT $2
`; `;
const result = await wingPool.query<{ const result = await wingPool.query<{
@ -53,7 +28,7 @@ export async function listGscAccidents(limit = 20): Promise<GscAccidentListItem[
pollDate: string | null; pollDate: string | null;
lat: string | null; lat: string | null;
lon: string | null; lon: string | null;
}>(orderedSql, [ACDNT_ASORT_CODES, limit]); }>(sql, [limit]);
return result.rows.map((row) => ({ return result.rows.map((row) => ({
acdntMngNo: row.acdntMngNo, acdntMngNo: row.acdntMngNo,

파일 보기

@ -12,11 +12,13 @@ const router = express.Router()
// GET /api/hns/analyses — 분석 목록 // GET /api/hns/analyses — 분석 목록
router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => {
try { try {
const { status, substance, search } = req.query const { status, substance, search, acdntSn } = req.query
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined
const items = await listAnalyses({ const items = await listAnalyses({
status: status as string | undefined, status: status as string | undefined,
substance: substance as string | undefined, substance: substance as string | undefined,
search: search as string | undefined, search: search as string | undefined,
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
}) })
res.json(items) res.json(items)
} catch (err) { } catch (err) {

파일 보기

@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) {
interface HnsAnalysisItem { interface HnsAnalysisItem {
hnsAnlysSn: number hnsAnlysSn: number
acdntSn: number | null
anlysNm: string anlysNm: string
acdntDtm: string | null acdntDtm: string | null
locNm: string | null locNm: string | null
@ -118,11 +119,13 @@ interface ListAnalysesInput {
status?: string status?: string
substance?: string substance?: string
search?: string search?: string
acdntSn?: number
} }
function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem { function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
return { return {
hnsAnlysSn: r.hns_anlys_sn as number, hnsAnlysSn: r.hns_anlys_sn as number,
acdntSn: (r.acdnt_sn as number) ?? null,
anlysNm: r.anlys_nm as string, anlysNm: r.anlys_nm as string,
acdntDtm: r.acdnt_dtm as string | null, acdntDtm: r.acdnt_dtm as string | null,
locNm: r.loc_nm as string | null, locNm: r.loc_nm as string | null,
@ -146,7 +149,7 @@ function rowToAnalysis(r: Record<string, unknown>): HnsAnalysisItem {
export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> { export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysisItem[]> {
const conditions: string[] = ["USE_YN = 'Y'"] const conditions: string[] = ["USE_YN = 'Y'"]
const params: string[] = [] const params: (string | number)[] = []
let idx = 1 let idx = 1
if (input.status) { if (input.status) {
@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
params.push(input.search) params.push(input.search)
idx++ idx++
} }
if (input.acdntSn != null) {
conditions.push(`ACDNT_SN = $${idx++}`)
params.push(input.acdntSn)
}
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, 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, WIND_SPD, WIND_DIR, EXEC_STTS_CD, RISK_CD, ANALYST_NM,
RSLT_DATA, REG_DTM RSLT_DATA, REG_DTM
@ -179,7 +186,7 @@ export async function listAnalyses(input: ListAnalysesInput): Promise<HnsAnalysi
export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> { export async function getAnalysis(sn: number): Promise<HnsAnalysisItem | null> {
const { rows } = await wingPool.query( const { rows } = await wingPool.query(
`SELECT HNS_ANLYS_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT, `SELECT HNS_ANLYS_SN, ACDNT_SN, ANLYS_NM, ACDNT_DTM, LOC_NM, LON, LAT,
SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD, SBST_NM, SPIL_QTY, SPIL_UNIT_CD, FCST_HR, ALGO_CD, CRIT_MDL_CD,
WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD,
EXEC_STTS_CD, RISK_CD, ANALYST_NM, EXEC_STTS_CD, RISK_CD, ANALYST_NM,

파일 보기

@ -25,6 +25,8 @@ interface IncidentListItem {
spilUnitCd: string | null; spilUnitCd: string | null;
fcstHr: number | null; fcstHr: number | null;
hasPredCompleted: boolean; hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number; mediaCnt: number;
hasImgAnalysis: boolean; hasImgAnalysis: boolean;
} }
@ -118,6 +120,18 @@ export async function listIncidents(filters: {
SELECT 1 FROM wing.PRED_EXEC pe SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed, ) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
@ -157,6 +171,8 @@ export async function listIncidents(filters: {
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean, hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
})); }));
@ -177,6 +193,18 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
SELECT 1 FROM wing.PRED_EXEC pe SELECT 1 FROM wing.PRED_EXEC pe
WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED' WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = 'COMPLETED'
) AS has_pred_completed, ) AS has_pred_completed,
EXISTS (
SELECT 1 FROM wing.HNS_ANALYSIS h
WHERE h.ACDNT_SN = a.ACDNT_SN
AND h.EXEC_STTS_CD = 'COMPLETED'
AND h.USE_YN = 'Y'
) AS has_hns_completed,
EXISTS (
SELECT 1 FROM wing.RESCUE_OPS r
WHERE r.ACDNT_SN = a.ACDNT_SN
AND r.STTS_CD = 'RESOLVED'
AND r.USE_YN = 'Y'
) AS has_rescue_completed,
COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0) COALESCE(m.PHOTO_CNT, 0) + COALESCE(m.VIDEO_CNT, 0)
+ COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt + COALESCE(m.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt
FROM wing.ACDNT a FROM wing.ACDNT a
@ -222,6 +250,8 @@ export async function getIncident(acdntSn: number): Promise<IncidentDetail | nul
spilUnitCd: (r.spil_unit_cd as string) ?? null, spilUnitCd: (r.spil_unit_cd as string) ?? null,
fcstHr: (r.fcst_hr as number) ?? null, fcstHr: (r.fcst_hr as number) ?? null,
hasPredCompleted: r.has_pred_completed as boolean, hasPredCompleted: r.has_pred_completed as boolean,
hasHnsCompleted: r.has_hns_completed as boolean,
hasRescueCompleted: r.has_rescue_completed as boolean,
mediaCnt: Number(r.media_cnt), mediaCnt: Number(r.media_cnt),
hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, hasImgAnalysis: (r.has_img_analysis as boolean) ?? false,
predictions, predictions,

파일 보기

@ -10,11 +10,13 @@ const router = express.Router();
// ============================================================ // ============================================================
router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => { router.get('/ops', requireAuth, requirePermission('rescue', 'READ'), async (req, res) => {
try { try {
const { sttsCd, acdntTpCd, search } = req.query; const { sttsCd, acdntTpCd, search, acdntSn } = req.query;
const acdntSnNum = acdntSn ? parseInt(acdntSn as string, 10) : undefined;
const items = await listOps({ const items = await listOps({
sttsCd: sttsCd as string | undefined, sttsCd: sttsCd as string | undefined,
acdntTpCd: acdntTpCd as string | undefined, acdntTpCd: acdntTpCd as string | undefined,
search: search as string | undefined, search: search as string | undefined,
acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined,
}); });
res.json(items); res.json(items);
} catch (err) { } catch (err) {

파일 보기

@ -59,6 +59,7 @@ interface ListOpsInput {
sttsCd?: string; sttsCd?: string;
acdntTpCd?: string; acdntTpCd?: string;
search?: string; search?: string;
acdntSn?: number;
} }
// ============================================================ // ============================================================
@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise<RescueOpsListItem[]
conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`); conditions.push(`VESSEL_NM ILIKE '%' || $${idx++} || '%'`);
params.push(input.search); params.push(input.search);
} }
if (input?.acdntSn != null) {
conditions.push(`ACDNT_SN = $${idx++}`);
params.push(input.acdntSn);
}
const where = 'WHERE ' + conditions.join(' AND '); const where = 'WHERE ' + conditions.join(' AND ');

파일 보기

@ -0,0 +1,118 @@
-- ============================================================
-- 032: gsc.tgs_acdnt_info → wing.ACDNT 동기화 (2026-04-10 이후)
-- ------------------------------------------------------------
-- 목적
-- 3개 예측 탭(유출유확산예측 / HNS 대기확산 / 긴급구난)의 사고
-- 선택 셀렉트박스에 노출되는 gsc 사고 레코드를 wing.ACDNT에
-- 이관하여 wing 운영 로직과 동일한 사고 마스터를 공유한다.
--
-- 필터 정책 (backend/src/gsc/gscAccidentsService.ts 의 listGscAccidents 와 동일)
-- - acdnt_asort_code IN (12개 코드)
-- - acdnt_title IS NOT NULL
-- - 좌표(tgs_acdnt_lc.la, lo) 존재
-- - rcept_dt >= '2026-04-10' (본 이관 추가 조건)
--
-- ACDNT_CD 생성 규칙
-- 'INC-YYYY-NNNN' (YYYY = rcept_dt 의 연도, NNNN = 해당 연도 내 순번 4자리)
-- 기존 wing.ACDNT 에 이미 부여된 'INC-YYYY-NNNN' 중 같은 연도의 최대 순번을
-- 구해 이어서 증가시킨다.
--
-- 중복 방지
-- (ACDNT_NM = acdnt_title, OCCRN_DTM = rcept_dt) 조합이 이미 존재하면 제외.
-- acdnt_mng_no 를 별도 컬럼으로 보관하지 않으므로 이 조합을 자연 키로 사용.
--
-- ACDNT_TP_CD
-- gsc.tcm_code.code_nm 으로 치환 (JOIN: tcm_code.code = acdnt_asort_code)
-- 매핑 누락 시 원본 코드값으로 폴백.
--
-- 사전 확인 쿼리 (실행 전 참고)
-- SELECT COUNT(DISTINCT a.acdnt_mng_no)
-- FROM gsc.tgs_acdnt_info a JOIN gsc.tgs_acdnt_lc b USING (acdnt_mng_no)
-- WHERE a.acdnt_asort_code = ANY(ARRAY[
-- '055001001','055001002','055001003','055001004','055001005','055001006',
-- '055003001','055003002','055003003','055003004','055003005','055004003'
-- ]::varchar[])
-- AND a.acdnt_title IS NOT NULL
-- AND a.rcept_dt >= '2026-04-10';
-- ============================================================
WITH src AS (
SELECT DISTINCT ON (a.acdnt_mng_no)
a.acdnt_mng_no,
a.acdnt_title,
a.acdnt_asort_code,
a.rcept_dt,
b.la,
b.lo
FROM gsc.tgs_acdnt_info AS a
JOIN gsc.tgs_acdnt_lc AS b ON a.acdnt_mng_no = b.acdnt_mng_no
WHERE a.acdnt_asort_code = ANY(ARRAY[
'055001001','055001002','055001003','055001004','055001005','055001006',
'055003001','055003002','055003003','055003004','055003005','055004003'
]::varchar[])
AND a.acdnt_title IS NOT NULL
AND a.rcept_dt >= '2026-04-10'::timestamptz
AND b.la IS NOT NULL AND b.lo IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM wing.ACDNT w
WHERE w.ACDNT_NM = a.acdnt_title
AND w.OCCRN_DTM = a.rcept_dt
)
ORDER BY a.acdnt_mng_no, b.acdnt_lc_sn ASC
),
numbered AS (
SELECT
src.*,
EXTRACT(YEAR FROM src.rcept_dt)::int AS yr,
ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM src.rcept_dt)
ORDER BY src.rcept_dt ASC, src.acdnt_mng_no ASC
) AS rn_in_year
FROM src
),
year_max AS (
SELECT
(split_part(ACDNT_CD, '-', 2))::int AS yr,
MAX((split_part(ACDNT_CD, '-', 3))::int) AS max_seq
FROM wing.ACDNT
WHERE ACDNT_CD ~ '^INC-[0-9]{4}-[0-9]+$'
GROUP BY split_part(ACDNT_CD, '-', 2)
)
INSERT INTO wing.ACDNT (
ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ACDNT_STTS_CD,
LAT, LNG, LOC_GEOM, LOC_DC, OCCRN_DTM, REG_DTM, MDFCN_DTM
)
SELECT
'INC-' || lpad(n.yr::text, 4, '0') || '-' ||
lpad((COALESCE(ym.max_seq, 0) + n.rn_in_year)::text, 4, '0') AS ACDNT_CD,
n.acdnt_title AS ACDNT_NM,
COALESCE(c.code_nm, n.acdnt_asort_code) AS ACDNT_TP_CD,
'ACTIVE' AS ACDNT_STTS_CD,
n.la::numeric AS LAT,
n.lo::numeric AS LNG,
ST_SetSRID(ST_MakePoint(n.lo::float8, n.la::float8), 4326) AS LOC_GEOM,
NULL AS LOC_DC,
n.rcept_dt AS OCCRN_DTM,
NOW(), NOW()
FROM numbered n
LEFT JOIN year_max ym ON ym.yr = n.yr
LEFT JOIN gsc.tcm_code c ON c.code = n.acdnt_asort_code
ORDER BY n.rcept_dt ASC, n.acdnt_mng_no ASC;
-- ============================================================
-- 사후 검증 (필요 시 주석 해제 실행)
-- SELECT COUNT(*) FROM wing.ACDNT WHERE OCCRN_DTM >= '2026-04-10';
--
-- SELECT ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, ST_AsText(LOC_GEOM), OCCRN_DTM
-- FROM wing.ACDNT
-- WHERE OCCRN_DTM >= '2026-04-10'
-- ORDER BY ACDNT_CD DESC
-- LIMIT 20;
--
-- SELECT ACDNT_TP_CD, COUNT(*)
-- FROM wing.ACDNT
-- WHERE OCCRN_DTM >= '2026-04-10'
-- GROUP BY 1
-- ORDER BY 2 DESC;
-- ============================================================

파일 보기

@ -6,6 +6,7 @@ import { api } from '@common/services/api';
export interface HnsAnalysisItem { export interface HnsAnalysisItem {
hnsAnlysSn: number; hnsAnlysSn: number;
acdntSn: number | null;
anlysNm: string; anlysNm: string;
acdntDtm: string | null; acdntDtm: string | null;
locNm: string | null; locNm: string | null;
@ -50,6 +51,7 @@ export async function fetchHnsAnalyses(params?: {
status?: string; status?: string;
substance?: string; substance?: string;
search?: string; search?: string;
acdntSn?: number;
}): Promise<HnsAnalysisItem[]> { }): Promise<HnsAnalysisItem[]> {
const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params }); const response = await api.get<HnsAnalysisItem[]>('/hns/analyses', { params });
return response.data; return response.data;

파일 보기

@ -12,6 +12,10 @@ import type {
} from '@tabs/prediction/services/predictionApi'; } from '@tabs/prediction/services/predictionApi';
import { fetchNearbyOrgs } from '../services/incidentsApi'; import { fetchNearbyOrgs } from '../services/incidentsApi';
import type { NearbyOrgItem } from '../services/incidentsApi'; import type { NearbyOrgItem } from '../services/incidentsApi';
import { fetchHnsAnalyses } from '@tabs/hns/services/hnsApi';
import type { HnsAnalysisItem } from '@tabs/hns/services/hnsApi';
import { fetchRescueOps } from '@tabs/rescue/services/rescueApi';
import type { RescueOpsItem } from '@tabs/rescue/services/rescueApi';
export type ViewMode = 'overlay' | 'split2' | 'split3'; export type ViewMode = 'overlay' | 'split2' | 'split3';
@ -35,6 +39,12 @@ interface IncidentsRightPanelProps {
onCheckedPredsChange?: ( onCheckedPredsChange?: (
checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>, checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>,
) => void; ) => void;
onCheckedHnsChange?: (
checked: Array<{ id: string; hnsAnlysSn: number; acdntSn: number | null }>,
) => void;
onCheckedRescueChange?: (
checked: Array<{ id: string; rescueOpsSn: number; acdntSn: number | null }>,
) => void;
onSensitiveDataChange?: ( onSensitiveDataChange?: (
geojson: SensitiveResourceFeatureCollection | null, geojson: SensitiveResourceFeatureCollection | null,
checkedCategories: Set<string>, checkedCategories: Set<string>,
@ -115,23 +125,21 @@ function getActiveModels(p: PredictionAnalysis): string {
return models || '분석중'; return models || '분석중';
} }
/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */ /* ── 섹션 메타 (색상/아이콘) ────── */
const STATIC_SECTIONS = [ const SECTION_META = {
{ hns: {
key: 'hns',
icon: '🧪', icon: '🧪',
title: 'HNS 대기확산', title: 'HNS 대기확산',
color: 'var(--color-accent)', color: 'var(--color-warning)',
colorRgb: '6,182,212', colorRgb: '249,115,22',
}, },
{ rescue: {
key: 'rsc',
icon: '🚨', icon: '🚨',
title: '긴급구난', title: '긴급구난',
color: 'var(--color-accent)', color: 'var(--color-accent)',
colorRgb: '6,182,212', colorRgb: '6,182,212',
}, },
]; };
/* ── Component ───────────────────────────────────── */ /* ── Component ───────────────────────────────────── */
@ -143,11 +151,17 @@ export function IncidentsRightPanel({
analysisActive, analysisActive,
onCloseAnalysis, onCloseAnalysis,
onCheckedPredsChange, onCheckedPredsChange,
onCheckedHnsChange,
onCheckedRescueChange,
onSensitiveDataChange, onSensitiveDataChange,
selectedVessel, selectedVessel,
}: IncidentsRightPanelProps) { }: IncidentsRightPanelProps) {
const [predItems, setPredItems] = useState<PredictionAnalysis[]>([]); const [predItems, setPredItems] = useState<PredictionAnalysis[]>([]);
const [checkedPredIds, setCheckedPredIds] = useState<Set<string>>(new Set()); const [checkedPredIds, setCheckedPredIds] = useState<Set<string>>(new Set());
const [hnsItems, setHnsItems] = useState<HnsAnalysisItem[]>([]);
const [checkedHnsIds, setCheckedHnsIds] = useState<Set<string>>(new Set());
const [rescueItems, setRescueItems] = useState<RescueOpsItem[]>([]);
const [checkedRescueIds, setCheckedRescueIds] = useState<Set<string>>(new Set());
const [sensCategories, setSensCategories] = useState<SensitiveResourceCategory[]>([]); const [sensCategories, setSensCategories] = useState<SensitiveResourceCategory[]>([]);
const [checkedSensCategories, setCheckedSensCategories] = useState<Set<string>>(new Set()); const [checkedSensCategories, setCheckedSensCategories] = useState<Set<string>>(new Set());
const [sensitiveGeojson, setSensitiveGeojson] = const [sensitiveGeojson, setSensitiveGeojson] =
@ -160,9 +174,13 @@ export function IncidentsRightPanel({
if (!incident) { if (!incident) {
void Promise.resolve().then(() => { void Promise.resolve().then(() => {
setPredItems([]); setPredItems([]);
setHnsItems([]);
setRescueItems([]);
setSensCategories([]); setSensCategories([]);
setSensitiveGeojson(null); setSensitiveGeojson(null);
onCheckedPredsChange?.([]); onCheckedPredsChange?.([]);
onCheckedHnsChange?.([]);
onCheckedRescueChange?.([]);
onSensitiveDataChange?.(null, new Set(), []); onSensitiveDataChange?.(null, new Set(), []);
}); });
return; return;
@ -183,6 +201,34 @@ export function IncidentsRightPanel({
); );
}) })
.catch(() => setPredItems([])); .catch(() => setPredItems([]));
fetchHnsAnalyses({ acdntSn, status: 'COMPLETED' })
.then((items) => {
setHnsItems(items);
const allIds = new Set(items.map((i) => String(i.hnsAnlysSn)));
setCheckedHnsIds(allIds);
onCheckedHnsChange?.(
items.map((h) => ({
id: String(h.hnsAnlysSn),
hnsAnlysSn: h.hnsAnlysSn,
acdntSn: h.acdntSn,
})),
);
})
.catch(() => setHnsItems([]));
fetchRescueOps({ acdntSn, sttsCd: 'RESOLVED' })
.then((items) => {
setRescueItems(items);
const allIds = new Set(items.map((i) => String(i.rescueOpsSn)));
setCheckedRescueIds(allIds);
onCheckedRescueChange?.(
items.map((r) => ({
id: String(r.rescueOpsSn),
rescueOpsSn: r.rescueOpsSn,
acdntSn: r.acdntSn,
})),
);
})
.catch(() => setRescueItems([]));
Promise.all([fetchSensitiveResources(acdntSn), fetchSensitiveResourcesGeojson(acdntSn)]) Promise.all([fetchSensitiveResources(acdntSn), fetchSensitiveResourcesGeojson(acdntSn)])
.then(([cats, geojson]) => { .then(([cats, geojson]) => {
const allCategories = new Set(cats.map((c) => c.category)); const allCategories = new Set(cats.map((c) => c.category));
@ -276,6 +322,76 @@ export function IncidentsRightPanel({
}); });
}; };
const toggleHnsItem = (id: string) => {
setCheckedHnsIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
onCheckedHnsChange?.(
hnsItems
.filter((h) => next.has(String(h.hnsAnlysSn)))
.map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn })),
);
return next;
});
};
const removeHnsItem = (id: string) => {
setHnsItems((prev) => {
const next = prev.filter((h) => String(h.hnsAnlysSn) !== id);
onCheckedHnsChange?.(
next
.filter((h) => checkedHnsIds.has(String(h.hnsAnlysSn)))
.map((h) => ({ id: String(h.hnsAnlysSn), hnsAnlysSn: h.hnsAnlysSn, acdntSn: h.acdntSn })),
);
return next;
});
setCheckedHnsIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
const toggleRescueItem = (id: string) => {
setCheckedRescueIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
onCheckedRescueChange?.(
rescueItems
.filter((r) => next.has(String(r.rescueOpsSn)))
.map((r) => ({
id: String(r.rescueOpsSn),
rescueOpsSn: r.rescueOpsSn,
acdntSn: r.acdntSn,
})),
);
return next;
});
};
const removeRescueItem = (id: string) => {
setRescueItems((prev) => {
const next = prev.filter((r) => String(r.rescueOpsSn) !== id);
onCheckedRescueChange?.(
next
.filter((r) => checkedRescueIds.has(String(r.rescueOpsSn)))
.map((r) => ({
id: String(r.rescueOpsSn),
rescueOpsSn: r.rescueOpsSn,
acdntSn: r.acdntSn,
})),
);
return next;
});
setCheckedRescueIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
};
/* 유출유 섹션을 AnalysisSection 형태로 변환 (통합 분석 실행 콜백용) */ /* 유출유 섹션을 AnalysisSection 형태로 변환 (통합 분석 실행 콜백용) */
const oilSection: AnalysisSection = { const oilSection: AnalysisSection = {
key: 'oil', key: 'oil',
@ -297,6 +413,48 @@ export function IncidentsRightPanel({
}), }),
}; };
const hnsSection: AnalysisSection = {
key: 'hns',
icon: SECTION_META.hns.icon,
title: SECTION_META.hns.title,
color: SECTION_META.hns.color,
colorRgb: SECTION_META.hns.colorRgb,
totalLabel: `전체 ${hnsItems.length}`,
items: hnsItems.map((h) => {
const id = String(h.hnsAnlysSn);
const dateStr = h.regDtm ? h.regDtm.slice(0, 10) : '';
const sbst = h.sbstNm || 'HNS';
const sub = [h.algoCd, h.fcstHr != null ? `${h.fcstHr}h` : null].filter(Boolean).join(' · ');
return {
id,
name: `${dateStr} ${sbst} 대기확산`.trim(),
sub: sub || '-',
checked: checkedHnsIds.has(id),
};
}),
};
const rescueSection: AnalysisSection = {
key: 'rescue',
icon: SECTION_META.rescue.icon,
title: SECTION_META.rescue.title,
color: SECTION_META.rescue.color,
colorRgb: SECTION_META.rescue.colorRgb,
totalLabel: `전체 ${rescueItems.length}`,
items: rescueItems.map((r) => {
const id = String(r.rescueOpsSn);
const dateStr = r.regDtm ? r.regDtm.slice(0, 10) : '';
const vessel = r.vesselNm || '선박';
const sub = [r.acdntTpCd, r.commanderNm].filter(Boolean).join(' · ');
return {
id,
name: `${dateStr} ${vessel} 긴급구난`.trim(),
sub: sub || '-',
checked: checkedRescueIds.has(id),
};
}),
};
if (!incident) { if (!incident) {
return ( return (
<div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full"> <div className="flex flex-col items-center justify-center bg-bg-surface border-l border-stroke w-full h-full">
@ -395,32 +553,76 @@ export function IncidentsRightPanel({
); );
})()} })()}
{/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */} {/* HNS 대기확산 / 긴급구난 섹션 */}
{STATIC_SECTIONS.map((sec) => ( {[
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5"> { sec: hnsSection, onToggle: toggleHnsItem, onRemove: removeHnsItem },
<div className="flex items-center justify-between mb-2"> { sec: rescueSection, onToggle: toggleRescueItem, onRemove: removeRescueItem },
<div className="flex items-center gap-1.5"> ].map(({ sec, onToggle, onRemove }) => {
{/* <span className="text-body-2">{sec.icon}</span> */} const checkedCount = sec.items.filter((it) => it.checked).length;
<span className="text-caption">{sec.title}</span> return (
<div key={sec.key} className="bg-bg-elevated border border-stroke rounded-md p-2.5">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1.5">
<span className="text-caption">{sec.title}</span>
</div>
<button
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
border: '1px solid var(--stroke-default)',
color: sec.color,
}}
>
</button>
</div>
<div className="flex flex-col gap-1">
{sec.items.length === 0 ? (
<div className="text-caption text-fg-disabled text-center py-1.5">
</div>
) : (
sec.items.map((item) => (
<div
key={item.id}
className="flex items-center gap-1.5"
style={{
padding: '5px 8px',
border: '1px solid var(--stroke-default)',
borderRadius: '4px',
}}
>
<input
type="checkbox"
checked={item.checked}
onChange={() => onToggle(item.id)}
className="shrink-0"
style={{ accentColor: sec.color }}
/>
<div className="flex-1 min-w-0">
<div className="text-caption font-semibold whitespace-nowrap overflow-hidden text-ellipsis">
{item.name}
</div>
<div className="text-fg-disabled font-mono text-caption">{item.sub}</div>
</div>
<span
onClick={() => onRemove(item.id)}
title="제거"
className="text-caption cursor-pointer text-fg-disabled shrink-0"
>
</span>
</div>
))
)}
</div>
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled">
: <b style={{ color: sec.color }}>{checkedCount}</b> · {sec.totalLabel}
</div> </div>
<button
className="text-caption font-semibold cursor-pointer"
style={{
padding: '3px 10px',
borderRadius: '4px',
border: '1px solid var(--stroke-default)',
color: sec.color,
}}
>
</button>
</div> </div>
<div className="text-caption text-fg-disabled text-center py-1.5"> </div> );
<div className="flex items-center gap-1.5 mt-1.5 text-caption text-fg-disabled"> })}
: <b style={{ color: sec.color }}>0</b> · 0
</div>
</div>
))}
{/* 민감자원 */} {/* 민감자원 */}
<div className="bg-bg-elevated border border-stroke rounded-md p-2.5"> <div className="bg-bg-elevated border border-stroke rounded-md p-2.5">

파일 보기

@ -25,6 +25,8 @@ export interface IncidentListItem {
spilUnitCd: string | null; spilUnitCd: string | null;
fcstHr: number | null; fcstHr: number | null;
hasPredCompleted: boolean; hasPredCompleted: boolean;
hasHnsCompleted: boolean;
hasRescueCompleted: boolean;
mediaCnt: number; mediaCnt: number;
hasImgAnalysis: boolean; hasImgAnalysis: boolean;
} }
@ -112,7 +114,10 @@ function toCompat(item: IncidentListItem): IncidentCompat {
location: { lat: item.lat, lon: item.lng }, location: { lat: item.lat, lon: item.lng },
causeType: item.acdntTpCd, causeType: item.acdntTpCd,
oilType: item.oilTpCd ?? undefined, oilType: item.oilTpCd ?? undefined,
prediction: item.hasPredCompleted ? '예측완료' : undefined, prediction:
item.hasPredCompleted || item.hasHnsCompleted || item.hasRescueCompleted
? '예측완료'
: undefined,
mediaCount: item.mediaCnt, mediaCount: item.mediaCnt,
hasImgAnalysis: item.hasImgAnalysis || undefined, hasImgAnalysis: item.hasImgAnalysis || undefined,
}; };

파일 보기

@ -56,6 +56,7 @@ export async function fetchRescueOps(params?: {
sttsCd?: string; sttsCd?: string;
acdntTpCd?: string; acdntTpCd?: string;
search?: string; search?: string;
acdntSn?: number;
}): Promise<RescueOpsItem[]> { }): Promise<RescueOpsItem[]> {
const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params }); const response = await api.get<RescueOpsItem[]>('/rescue/ops', { params });
return response.data; return response.data;