From 1f667230601d01a8bd948b518d23ffc91ecc7cc9 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 15 Apr 2026 17:31:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(incidents):=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8C=A8=EB=84=90=20HNS/=EA=B5=AC?= =?UTF-8?q?=EB=82=9C=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EC=82=AC=EA=B3=A0?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20wing.ACDNT=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 우측 패널에 HNS 대기확산/긴급구난 완료 이력 목록 및 체크박스 연동 - incidents 목록에 hasHnsCompleted/hasRescueCompleted 플래그 추가 - hns/rescue 목록 API에 acdntSn 필터 추가 - /gsc/accidents 셀렉트박스 소스를 gsc.tgs_acdnt_info → wing.ACDNT 로 전환 - gsc → wing.ACDNT 동기화 마이그레이션 032 추가 --- backend/src/gsc/gscAccidentsService.ts | 47 +-- backend/src/hns/hnsRouter.ts | 4 +- backend/src/hns/hnsService.ts | 13 +- backend/src/incidents/incidentsService.ts | 30 ++ backend/src/rescue/rescueRouter.ts | 4 +- backend/src/rescue/rescueService.ts | 5 + .../032_sync_gsc_accidents_to_wing.sql | 118 ++++++++ frontend/src/tabs/hns/services/hnsApi.ts | 2 + .../components/IncidentsRightPanel.tsx | 268 +++++++++++++++--- .../tabs/incidents/services/incidentsApi.ts | 7 +- .../src/tabs/rescue/services/rescueApi.ts | 1 + 11 files changed, 424 insertions(+), 75 deletions(-) create mode 100644 database/migration/032_sync_gsc_accidents_to_wing.sql diff --git a/backend/src/gsc/gscAccidentsService.ts b/backend/src/gsc/gscAccidentsService.ts index 69fbff0..38d21fb 100644 --- a/backend/src/gsc/gscAccidentsService.ts +++ b/backend/src/gsc/gscAccidentsService.ts @@ -8,43 +8,18 @@ export interface GscAccidentListItem { 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 { const sql = ` - SELECT DISTINCT ON (a.acdnt_mng_no) - a.acdnt_mng_no AS "acdntMngNo", - a.acdnt_title AS "pollNm", - to_char(a.rcept_dt, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", - a.rcept_dt AS "rceptDt", - b.la AS "lat", - b.lo AS "lon" - FROM gsc.tgs_acdnt_info AS a - LEFT JOIN gsc.tgs_acdnt_lc AS b - ON a.acdnt_mng_no = b.acdnt_mng_no - 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 + SELECT + ACDNT_CD AS "acdntMngNo", + ACDNT_NM AS "pollNm", + to_char(OCCRN_DTM, 'YYYY-MM-DD"T"HH24:MI') AS "pollDate", + LAT AS "lat", + LNG AS "lon" + FROM wing.ACDNT + WHERE ACDNT_NM IS NOT NULL + ORDER BY OCCRN_DTM DESC NULLS LAST + LIMIT $1 `; const result = await wingPool.query<{ @@ -53,7 +28,7 @@ export async function listGscAccidents(limit = 20): Promise(orderedSql, [ACDNT_ASORT_CODES, limit]); + }>(sql, [limit]); return result.rows.map((row) => ({ acdntMngNo: row.acdntMngNo, diff --git a/backend/src/hns/hnsRouter.ts b/backend/src/hns/hnsRouter.ts index 828ed7a..6f9946c 100644 --- a/backend/src/hns/hnsRouter.ts +++ b/backend/src/hns/hnsRouter.ts @@ -12,11 +12,13 @@ const router = express.Router() // GET /api/hns/analyses — 분석 목록 router.get('/analyses', requireAuth, requirePermission('hns', 'READ'), async (req, res) => { 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({ status: status as string | undefined, substance: substance as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }) res.json(items) } catch (err) { diff --git a/backend/src/hns/hnsService.ts b/backend/src/hns/hnsService.ts index 10a001c..fd1d86a 100644 --- a/backend/src/hns/hnsService.ts +++ b/backend/src/hns/hnsService.ts @@ -94,6 +94,7 @@ export async function searchSubstances(params: HnsSearchParams) { interface HnsAnalysisItem { hnsAnlysSn: number + acdntSn: number | null anlysNm: string acdntDtm: string | null locNm: string | null @@ -118,11 +119,13 @@ interface ListAnalysesInput { status?: string substance?: string search?: string + acdntSn?: number } function rowToAnalysis(r: Record): HnsAnalysisItem { return { hnsAnlysSn: r.hns_anlys_sn as number, + acdntSn: (r.acdnt_sn as number) ?? null, anlysNm: r.anlys_nm as string, acdntDtm: r.acdnt_dtm as string | null, locNm: r.loc_nm as string | null, @@ -146,7 +149,7 @@ function rowToAnalysis(r: Record): HnsAnalysisItem { export async function listAnalyses(input: ListAnalysesInput): Promise { const conditions: string[] = ["USE_YN = 'Y'"] - const params: string[] = [] + const params: (string | number)[] = [] let idx = 1 if (input.status) { @@ -162,9 +165,13 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { 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, WIND_SPD, WIND_DIR, TEMP, HUMID, ATM_STBL_CD, EXEC_STTS_CD, RISK_CD, ANALYST_NM, diff --git a/backend/src/incidents/incidentsService.ts b/backend/src/incidents/incidentsService.ts index a02534b..34b1ba8 100644 --- a/backend/src/incidents/incidentsService.ts +++ b/backend/src/incidents/incidentsService.ts @@ -25,6 +25,8 @@ interface IncidentListItem { spilUnitCd: string | null; fcstHr: number | null; hasPredCompleted: boolean; + hasHnsCompleted: boolean; + hasRescueCompleted: boolean; mediaCnt: number; hasImgAnalysis: boolean; } @@ -118,6 +120,18 @@ export async function listIncidents(filters: { SELECT 1 FROM wing.PRED_EXEC pe WHERE pe.ACDNT_SN = a.ACDNT_SN AND pe.EXEC_STTS_CD = '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.SAT_CNT, 0) + COALESCE(m.CCTV_CNT, 0) AS media_cnt FROM wing.ACDNT a @@ -157,6 +171,8 @@ export async function listIncidents(filters: { spilUnitCd: (r.spil_unit_cd as string) ?? null, fcstHr: (r.fcst_hr as number) ?? null, 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), hasImgAnalysis: (r.has_img_analysis as boolean) ?? false, })); @@ -177,6 +193,18 @@ export async function getIncident(acdntSn: number): Promise { 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({ sttsCd: sttsCd as string | undefined, acdntTpCd: acdntTpCd as string | undefined, search: search as string | undefined, + acdntSn: acdntSnNum && !Number.isNaN(acdntSnNum) ? acdntSnNum : undefined, }); res.json(items); } catch (err) { diff --git a/backend/src/rescue/rescueService.ts b/backend/src/rescue/rescueService.ts index 7ca4cb2..19ccdcd 100644 --- a/backend/src/rescue/rescueService.ts +++ b/backend/src/rescue/rescueService.ts @@ -59,6 +59,7 @@ interface ListOpsInput { sttsCd?: string; acdntTpCd?: string; search?: string; + acdntSn?: number; } // ============================================================ @@ -82,6 +83,10 @@ export async function listOps(input?: ListOpsInput): Promise= '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; +-- ============================================================ diff --git a/frontend/src/tabs/hns/services/hnsApi.ts b/frontend/src/tabs/hns/services/hnsApi.ts index 985957c..70bab2f 100644 --- a/frontend/src/tabs/hns/services/hnsApi.ts +++ b/frontend/src/tabs/hns/services/hnsApi.ts @@ -6,6 +6,7 @@ import { api } from '@common/services/api'; export interface HnsAnalysisItem { hnsAnlysSn: number; + acdntSn: number | null; anlysNm: string; acdntDtm: string | null; locNm: string | null; @@ -50,6 +51,7 @@ export async function fetchHnsAnalyses(params?: { status?: string; substance?: string; search?: string; + acdntSn?: number; }): Promise { const response = await api.get('/hns/analyses', { params }); return response.data; diff --git a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx index d056caf..57bb131 100755 --- a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx @@ -12,6 +12,10 @@ import type { } from '@tabs/prediction/services/predictionApi'; import { fetchNearbyOrgs } 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'; @@ -35,6 +39,12 @@ interface IncidentsRightPanelProps { onCheckedPredsChange?: ( checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>, ) => void; + onCheckedHnsChange?: ( + checked: Array<{ id: string; hnsAnlysSn: number; acdntSn: number | null }>, + ) => void; + onCheckedRescueChange?: ( + checked: Array<{ id: string; rescueOpsSn: number; acdntSn: number | null }>, + ) => void; onSensitiveDataChange?: ( geojson: SensitiveResourceFeatureCollection | null, checkedCategories: Set, @@ -115,23 +125,21 @@ function getActiveModels(p: PredictionAnalysis): string { return models || '분석중'; } -/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */ -const STATIC_SECTIONS = [ - { - key: 'hns', +/* ── 섹션 메타 (색상/아이콘) ────── */ +const SECTION_META = { + hns: { icon: '🧪', title: 'HNS 대기확산', - color: 'var(--color-accent)', - colorRgb: '6,182,212', + color: 'var(--color-warning)', + colorRgb: '249,115,22', }, - { - key: 'rsc', + rescue: { icon: '🚨', title: '긴급구난', color: 'var(--color-accent)', colorRgb: '6,182,212', }, -]; +}; /* ── Component ───────────────────────────────────── */ @@ -143,11 +151,17 @@ export function IncidentsRightPanel({ analysisActive, onCloseAnalysis, onCheckedPredsChange, + onCheckedHnsChange, + onCheckedRescueChange, onSensitiveDataChange, selectedVessel, }: IncidentsRightPanelProps) { const [predItems, setPredItems] = useState([]); const [checkedPredIds, setCheckedPredIds] = useState>(new Set()); + const [hnsItems, setHnsItems] = useState([]); + const [checkedHnsIds, setCheckedHnsIds] = useState>(new Set()); + const [rescueItems, setRescueItems] = useState([]); + const [checkedRescueIds, setCheckedRescueIds] = useState>(new Set()); const [sensCategories, setSensCategories] = useState([]); const [checkedSensCategories, setCheckedSensCategories] = useState>(new Set()); const [sensitiveGeojson, setSensitiveGeojson] = @@ -160,9 +174,13 @@ export function IncidentsRightPanel({ if (!incident) { void Promise.resolve().then(() => { setPredItems([]); + setHnsItems([]); + setRescueItems([]); setSensCategories([]); setSensitiveGeojson(null); onCheckedPredsChange?.([]); + onCheckedHnsChange?.([]); + onCheckedRescueChange?.([]); onSensitiveDataChange?.(null, new Set(), []); }); return; @@ -183,6 +201,34 @@ export function IncidentsRightPanel({ ); }) .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)]) .then(([cats, geojson]) => { 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 형태로 변환 (통합 분석 실행 콜백용) */ const oilSection: AnalysisSection = { 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) { return (
@@ -395,32 +553,76 @@ export function IncidentsRightPanel({ ); })()} - {/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */} - {STATIC_SECTIONS.map((sec) => ( -
-
-
- {/* {sec.icon} */} - {sec.title} + {/* HNS 대기확산 / 긴급구난 섹션 */} + {[ + { sec: hnsSection, onToggle: toggleHnsItem, onRemove: removeHnsItem }, + { sec: rescueSection, onToggle: toggleRescueItem, onRemove: removeRescueItem }, + ].map(({ sec, onToggle, onRemove }) => { + const checkedCount = sec.items.filter((it) => it.checked).length; + return ( +
+
+
+ {sec.title} +
+ +
+
+ {sec.items.length === 0 ? ( +
+ 예측 실행 이력이 없습니다 +
+ ) : ( + sec.items.map((item) => ( +
+ onToggle(item.id)} + className="shrink-0" + style={{ accentColor: sec.color }} + /> +
+
+ {item.name} +
+
{item.sub}
+
+ onRemove(item.id)} + title="제거" + className="text-caption cursor-pointer text-fg-disabled shrink-0" + > + ✕ + +
+ )) + )} +
+
+ 선택: {checkedCount}건 · {sec.totalLabel}
-
-
준비 중입니다
-
- 선택: 0건 · 전체 0건 -
-
- ))} + ); + })} {/* 민감자원 */}
diff --git a/frontend/src/tabs/incidents/services/incidentsApi.ts b/frontend/src/tabs/incidents/services/incidentsApi.ts index 88769d6..f82f906 100644 --- a/frontend/src/tabs/incidents/services/incidentsApi.ts +++ b/frontend/src/tabs/incidents/services/incidentsApi.ts @@ -25,6 +25,8 @@ export interface IncidentListItem { spilUnitCd: string | null; fcstHr: number | null; hasPredCompleted: boolean; + hasHnsCompleted: boolean; + hasRescueCompleted: boolean; mediaCnt: number; hasImgAnalysis: boolean; } @@ -112,7 +114,10 @@ function toCompat(item: IncidentListItem): IncidentCompat { location: { lat: item.lat, lon: item.lng }, causeType: item.acdntTpCd, oilType: item.oilTpCd ?? undefined, - prediction: item.hasPredCompleted ? '예측완료' : undefined, + prediction: + item.hasPredCompleted || item.hasHnsCompleted || item.hasRescueCompleted + ? '예측완료' + : undefined, mediaCount: item.mediaCnt, hasImgAnalysis: item.hasImgAnalysis || undefined, }; diff --git a/frontend/src/tabs/rescue/services/rescueApi.ts b/frontend/src/tabs/rescue/services/rescueApi.ts index b48bf5e..0274b3b 100644 --- a/frontend/src/tabs/rescue/services/rescueApi.ts +++ b/frontend/src/tabs/rescue/services/rescueApi.ts @@ -56,6 +56,7 @@ export async function fetchRescueOps(params?: { sttsCd?: string; acdntTpCd?: string; search?: string; + acdntSn?: number; }): Promise { const response = await api.get('/rescue/ops', { params }); return response.data;