From 1be8c188f79f5e7d773b99dd10957598c443d9d7 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 25 Mar 2026 18:17:42 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(incidents):=20=EC=82=AC=EA=B3=A0=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8C=A8=EB=84=90=20=EC=8B=A4=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99,=20=EC=9D=B8=EA=B7=BC?= =?UTF-8?q?=20=EA=B8=B0=EA=B4=80=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- backend/src/assets/assetsRouter.ts | 22 +- backend/src/assets/assetsService.ts | 48 ++ backend/src/prediction/predictionRouter.ts | 7 +- backend/src/prediction/predictionService.ts | 17 +- backend/src/routes/simulation.ts | 24 +- backend/src/users/userService.ts | 1 - database/migration/029_pred_exec_user.sql | 3 + .../components/IncidentsLeftPanel.tsx | 4 +- .../components/IncidentsRightPanel.tsx | 446 ++++++++++++------ .../incidents/components/IncidentsView.tsx | 292 +++++++++++- .../tabs/incidents/services/incidentsApi.ts | 27 ++ .../prediction/components/OilSpillView.tsx | 13 - .../tabs/prediction/services/predictionApi.ts | 1 + .../components/OilSpillReportTemplate.tsx | 4 +- .../reports/components/ReportGenerator.tsx | 5 +- .../reports/components/TemplateFormEditor.tsx | 7 +- .../src/tabs/reports/services/reportsApi.ts | 2 +- 17 files changed, 722 insertions(+), 201 deletions(-) create mode 100644 database/migration/029_pred_exec_user.sql diff --git a/backend/src/assets/assetsRouter.ts b/backend/src/assets/assetsRouter.ts index 9de8afc..db38fe4 100644 --- a/backend/src/assets/assetsRouter.ts +++ b/backend/src/assets/assetsRouter.ts @@ -1,6 +1,6 @@ import { Router } from 'express'; import { requireAuth } from '../auth/authMiddleware.js'; -import { listOrganizations, getOrganization, listUploadLogs, listInsurance } from './assetsService.js'; +import { listOrganizations, getOrganization, listUploadLogs, listInsurance, listNearbyOrganizations } from './assetsService.js'; const router = Router(); @@ -22,6 +22,26 @@ router.get('/orgs', requireAuth, async (req, res) => { } }); +// ============================================================ +// GET /api/assets/orgs/nearby — 근처 기관 목록 (PostGIS 반경 검색) +// ============================================================ +router.get('/orgs/nearby', requireAuth, async (req, res) => { + try { + const lat = parseFloat(req.query.lat as string); + const lng = parseFloat(req.query.lng as string); + const radius = parseFloat(req.query.radius as string); + if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius <= 0) { + res.status(400).json({ error: '유효하지 않은 좌표 또는 반경입니다.' }); + return; + } + const orgs = await listNearbyOrganizations(lat, lng, radius); + res.json(orgs); + } catch (err) { + console.error('[assets] 근처 기관 조회 오류:', err); + res.status(500).json({ error: '근처 기관 조회 중 오류가 발생했습니다.' }); + } +}); + // ============================================================ // GET /api/assets/orgs/:sn — 기관 상세 (장비 + 담당자) // ============================================================ diff --git a/backend/src/assets/assetsService.ts b/backend/src/assets/assetsService.ts index 8ee4197..483894a 100644 --- a/backend/src/assets/assetsService.ts +++ b/backend/src/assets/assetsService.ts @@ -162,6 +162,54 @@ export async function getOrganization(orgSn: number): Promise }; } +// ============================================================ +// 근처 기관 조회 (PostGIS ST_DWithin) +// ============================================================ + +export interface NearbyOrgItem extends OrgListItem { + distanceNm: number; +} + +export async function listNearbyOrganizations( + lat: number, + lng: number, + radiusNm: number, +): Promise { + const radiusMeters = radiusNm * 1852; + const sql = ` + SELECT ORG_SN, ORG_TP, JRSD_NM, AREA_NM, ORG_NM, ADDR, TEL, + LAT, LNG, PIN_SIZE, + VESSEL_CNT, SKIMMER_CNT, PUMP_CNT, VEHICLE_CNT, SPRAYER_CNT, TOTAL_ASSETS, + ST_Distance(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography) / 1852.0 AS distance_nm + FROM wing.ASSET_ORG + WHERE USE_YN = 'Y' + AND GEOM IS NOT NULL + AND ST_DWithin(GEOM::geography, ST_SetSRID(ST_MakePoint($2, $1), 4326)::geography, $3) + ORDER BY distance_nm + `; + const { rows } = await wingPool.query(sql, [lat, lng, radiusMeters]); + + return rows.map((r: Record) => ({ + orgSn: r.org_sn as number, + orgTp: r.org_tp as string, + jrsdNm: r.jrsd_nm as string, + areaNm: r.area_nm as string, + orgNm: r.org_nm as string, + addr: r.addr as string, + tel: r.tel as string, + lat: parseFloat(r.lat as string), + lng: parseFloat(r.lng as string), + pinSize: r.pin_size as string, + vesselCnt: r.vessel_cnt as number, + skimmerCnt: r.skimmer_cnt as number, + pumpCnt: r.pump_cnt as number, + vehicleCnt: r.vehicle_cnt as number, + sprayerCnt: r.sprayer_cnt as number, + totalAssets: r.total_assets as number, + distanceNm: parseFloat(r.distance_nm as string), + })); +} + // ============================================================ // 선박보험(유류오염보장계약) 조회 // ============================================================ diff --git a/backend/src/prediction/predictionRouter.ts b/backend/src/prediction/predictionRouter.ts index 6c0e562..6d6c357 100644 --- a/backend/src/prediction/predictionRouter.ts +++ b/backend/src/prediction/predictionRouter.ts @@ -17,8 +17,11 @@ 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 }); + const { search, acdntSn } = req.query; + const items = await listAnalyses({ + search: search as string | undefined, + acdntSn: acdntSn ? parseInt(acdntSn as string, 10) : undefined, + }); res.json(items); } catch (err) { console.error('[prediction] 분석 목록 오류:', err); diff --git a/backend/src/prediction/predictionService.ts b/backend/src/prediction/predictionService.ts index 649c92d..63a6845 100644 --- a/backend/src/prediction/predictionService.ts +++ b/backend/src/prediction/predictionService.ts @@ -115,12 +115,18 @@ interface BoomLineItem { interface ListAnalysesInput { search?: string; + acdntSn?: number; } export async function listAnalyses(input: ListAnalysesInput): Promise { const params: unknown[] = []; const conditions: string[] = ["A.USE_YN = 'Y'"]; + if (input.acdntSn) { + params.push(input.acdntSn); + conditions.push(`A.ACDNT_SN = $${params.length}`); + } + if (input.search) { params.push(`%${input.search}%`); conditions.push(`(A.ACDNT_NM ILIKE $${params.length} OR A.LOC_DC ILIKE $${params.length})`); @@ -149,7 +155,9 @@ export async function listAnalyses(input: ListAnalysesInput): Promise { try { const kospsExecNm = `${execNmBase}_KOSPS` const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, 'KOSPS', 'PENDING', $3, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm] + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, req.user!.sub] ) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) } catch (dbErr) { @@ -267,10 +267,10 @@ router.post('/run', requireAuth, async (req: Request, res: Response) => { let predExecSn: number try { const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, BGNG_DTM) - VALUES ($1, $2, $3, 'PENDING', $4, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, EXEC_USER_ID, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm] + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, req.user!.sub] ) predExecSn = insertRes.rows[0].pred_exec_sn as number } catch (dbErr) { @@ -589,10 +589,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { try { const kospsExecNm = `${execNmBase}_KOSPS` const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) - VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM) + VALUES ($1, $2, 'KOSPS', 'PENDING', $3, $4, $5, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn] + [resolvedAcdntSn, resolvedSpilDataSn, kospsExecNm, predRunSn, req.user!.sub] ) execSns.push({ model: 'KOSPS', execSn: insertRes.rows[0].pred_exec_sn as number }) } catch (dbErr) { @@ -626,10 +626,10 @@ router.post('/run-model', requireAuth, async (req: Request, res: Response) => { let predExecSn: number try { const insertRes = await wingPool.query( - `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, BGNG_DTM) - VALUES ($1, $2, $3, 'PENDING', $4, $5, NOW()) + `INSERT INTO wing.PRED_EXEC (ACDNT_SN, SPIL_DATA_SN, ALGO_CD, EXEC_STTS_CD, EXEC_NM, PRED_RUN_SN, EXEC_USER_ID, BGNG_DTM) + VALUES ($1, $2, $3, 'PENDING', $4, $5, $6, NOW()) RETURNING PRED_EXEC_SN`, - [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn] + [resolvedAcdntSn, resolvedSpilDataSn, algoCd, execNm, predRunSn, req.user!.sub] ) predExecSn = insertRes.rows[0].pred_exec_sn as number } catch (dbErr) { diff --git a/backend/src/users/userService.ts b/backend/src/users/userService.ts index 2a34ace..365898f 100644 --- a/backend/src/users/userService.ts +++ b/backend/src/users/userService.ts @@ -307,7 +307,6 @@ export async function listOrgs(): Promise { const { rows } = await authPool.query( `SELECT ORG_SN, ORG_NM, ORG_ABBR_NM, ORG_TP_CD, UPPER_ORG_SN FROM AUTH_ORG - WHERE USE_YN = 'Y' ORDER BY ORG_SN` ) return rows.map((r: Record) => ({ diff --git a/database/migration/029_pred_exec_user.sql b/database/migration/029_pred_exec_user.sql new file mode 100644 index 0000000..3faf559 --- /dev/null +++ b/database/migration/029_pred_exec_user.sql @@ -0,0 +1,3 @@ +-- 029_pred_exec_user.sql +-- PRED_EXEC 테이블에 예측 실행자 ID 컬럼 추가 +ALTER TABLE wing.PRED_EXEC ADD COLUMN IF NOT EXISTS EXEC_USER_ID UUID; diff --git a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx index f3bedbc..5355e1e 100755 --- a/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsLeftPanel.tsx @@ -20,7 +20,7 @@ export interface Incident { interface IncidentsLeftPanelProps { incidents: Incident[] selectedIncidentId: string | null - onIncidentSelect: (id: string) => void + onIncidentSelect: (id: string | null) => void } const PERIOD_PRESETS = ['오늘', '1주일', '1개월', '3개월', '6개월', '1년'] as const @@ -290,7 +290,7 @@ export function IncidentsLeftPanel({ active: '대응중', investigating: '조사중', closed: '종료', } return ( -
onIncidentSelect(inc.id)} +
onIncidentSelect(isSel ? null : inc.id)} className="px-4 py-3 border-b border-border cursor-pointer" style={{ background: isSel ? 'rgba(6,182,212,0.04)' : undefined, diff --git a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx index 8d64189..ad2172d 100755 --- a/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx +++ b/frontend/src/tabs/incidents/components/IncidentsRightPanel.tsx @@ -1,5 +1,9 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import type { Incident } from './IncidentsLeftPanel' +import { fetchPredictionAnalyses, fetchSensitiveResources, fetchSensitiveResourcesGeojson } from '@tabs/prediction/services/predictionApi' +import type { PredictionAnalysis, SensitiveResourceCategory, SensitiveResourceFeatureCollection } from '@tabs/prediction/services/predictionApi' +import { fetchNearbyOrgs } from '../services/incidentsApi' +import type { NearbyOrgItem } from '../services/incidentsApi' export type ViewMode = 'overlay' | 'split2' | 'split3' @@ -20,10 +24,11 @@ interface IncidentsRightPanelProps { onRunAnalysis: (sections: AnalysisSection[], sensitiveCount: number) => void analysisActive: boolean onCloseAnalysis: () => void + onCheckedPredsChange?: (checked: Array<{ id: string; acdntSn: number; predRunSn: number | null; occurredAt: string }>) => void + onSensitiveDataChange?: (geojson: SensitiveResourceFeatureCollection | null, checkedCategories: Set, categoryOrder: string[]) => void + selectedVessel: { lat: number; lng: number; name?: string } | null } -/* ── Analysis section data ───────────────────────── */ - interface AnalysisItem { id: string name: string @@ -31,95 +36,184 @@ interface AnalysisItem { checked: boolean } -const SECTION_DATA: { - key: string - icon: string - title: string - color: string - colorRgb: string - totalLabel: string - items: AnalysisItem[] -}[] = [ - { - key: 'oil', - icon: '🛢', - title: '유출유 확산예측', - color: '#f97316', - colorRgb: '249,115,22', - totalLabel: '전체 8건', - items: [ - { id: 'o1', name: '여수항 BUNKER-C 확산', sub: 'KOSPS+OpenDrift · 150kL', checked: true }, - { id: 'o2', name: '여수항 DIESEL 확산', sub: 'KOSPS · 30kL', checked: false }, - ], - }, - { - key: 'hns', - icon: '🧪', - title: 'HNS 대기확산', - color: '#a855f7', - colorRgb: '168,85,247', - totalLabel: '전체 6건', - items: [ - { id: 'h1', name: '울산 톨루엔 대기확산', sub: 'ALOHA · IDLH 1.2km', checked: true }, - ], - }, - { - key: 'rsc', - icon: '🚨', - title: '긴급구난', - color: '#06b6d4', - colorRgb: '6,182,212', - totalLabel: '전체 5건', - items: [ - { id: 'r1', name: '여수 해상구난 SAR #12', sub: 'SAROPS · 확률맵', checked: true }, - ], - }, +/* ── 카테고리별 고유 색상 (목록 순서 인덱스 기반 — 중복 없음) ── */ +const CATEGORY_PALETTE: [number, number, number][] = [ + [239, 68, 68 ], // red + [249, 115, 22 ], // orange + [234, 179, 8 ], // yellow + [132, 204, 22 ], // lime + [20, 184, 166], // teal + [6, 182, 212], // cyan + [59, 130, 246], // blue + [99, 102, 241], // indigo + [168, 85, 247], // purple + [236, 72, 153], // pink + [244, 63, 94 ], // rose + [16, 185, 129], // emerald + [14, 165, 233], // sky + [139, 92, 246], // violet + [217, 119, 6 ], // amber + [45, 212, 191], // turquoise ] -interface SensitiveResource { - id: string - name: string - area: string - checked: boolean +function getCategoryColor(index: number): [number, number, number] { + return CATEGORY_PALETTE[index % CATEGORY_PALETTE.length] } -const MOCK_SENSITIVE: SensitiveResource[] = [ - { id: 's1', name: '돌산 어장 (김·전복)', area: '131ha', checked: true }, - { id: 's2', name: '여수 갯벌 생태계', area: '4,013ha', checked: true }, - { id: 's3', name: '여수 해수욕장 3개소', area: '', checked: false }, - { id: 's4', name: '오동도 해상공원', area: '125ha', checked: false }, - { id: 's5', name: '여수 취수시설', area: '2개소', checked: false }, +/* ── 카테고리 → 이모지 매핑 (prediction LeftPanel의 CATEGORY_ICON_MAP 기반) ── */ +const CATEGORY_ICON: Record = { + '어장정보': '🐟', '양식장': '🦪', '양식어업': '🦪', '어류양식장': '🐟', + '패류양식장': '🦪', '해조류양식장': '🌿', '가두리양식장': '🔲', '갑각류양식장': '🦐', + '기타양식장': '📦', '영세어업': '🎣', '유어장': '🎣', '수산시장': '🐟', + '인공어초': '🪸', '암초': '🪨', '침선': '🚢', + '해수욕장': '🏖', '갯바위낚시': '🪨', '선상낚시': '🚤', '마리나항': '⛵', + '무역항': '🚢', '연안항': '⛵', '국가어항': '⚓', '지방어항': '⚓', + '어항': '⚓', '항만구역': '⚓', '항로': '🚢', '정박지': '⛵', + '항로표지': '🔴', '해수취수시설': '💧', '취수구·배수구': '🚰', + 'LNG': '⚡', '발전소': '🔌', '발전소·산단': '🏭', '임해공단': '🏭', + '저유시설': '🛢', '해저케이블·배관': '🔌', + '갯벌': '🪨', '해안선_ESI': '🏖', '보호지역': '🛡', '해양보호구역': '🌿', + '철새도래지': '🐦', '습지보호구역': '🏖', '보호종서식지': '🐢', '보호종 서식지': '🐢', +} + +/* ── 헬퍼: 활성 모델 문자열 ─────────────────────── */ +function getActiveModels(p: PredictionAnalysis): string { + const models = [ + p.kospsStatus && p.kospsStatus !== 'pending' && p.kospsStatus !== 'none' ? 'KOSPS' : null, + p.poseidonStatus && p.poseidonStatus !== 'pending' && p.poseidonStatus !== 'none' ? 'POSEIDON' : null, + p.opendriftStatus && p.opendriftStatus !== 'pending' && p.opendriftStatus !== 'none' ? 'OpenDrift' : null, + ].filter(Boolean).join('+') + return models || '분석중' +} + +/* ── HNS/구난 섹션 (미개발, 고정 구조만 유지) ────── */ +const STATIC_SECTIONS = [ + { key: 'hns', icon: '🧪', title: 'HNS 대기확산', color: '#a855f7', colorRgb: '168,85,247' }, + { key: 'rsc', icon: '🚨', title: '긴급구난', color: '#06b6d4', colorRgb: '6,182,212' }, ] /* ── Component ───────────────────────────────────── */ export function IncidentsRightPanel({ - incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis + incident, viewMode, onViewModeChange, onRunAnalysis, analysisActive, onCloseAnalysis, + onCheckedPredsChange, onSensitiveDataChange, selectedVessel, }: IncidentsRightPanelProps) { - const [sections, setSections] = useState(SECTION_DATA) - const [sensitive, setSensitive] = useState(MOCK_SENSITIVE) + const [predItems, setPredItems] = useState([]) + const [checkedPredIds, setCheckedPredIds] = useState>(new Set()) + const [sensCategories, setSensCategories] = useState([]) + const [checkedSensCategories, setCheckedSensCategories] = useState>(new Set()) + const [sensitiveGeojson, setSensitiveGeojson] = useState(null) const [nearbyRadius, setNearbyRadius] = useState(50) + const [nearbyOrgs, setNearbyOrgs] = useState([]) + const [nearbyLoading, setNearbyLoading] = useState(false) - const toggleItem = (sectionKey: string, itemId: string) => { - setSections(prev => - prev.map(s => - s.key === sectionKey - ? { ...s, items: s.items.map(it => (it.id === itemId ? { ...it, checked: !it.checked } : it)) } - : s - ) - ) + useEffect(() => { + if (!incident) { + void Promise.resolve().then(() => { + setPredItems([]) + setSensCategories([]) + setSensitiveGeojson(null) + onCheckedPredsChange?.([]) + onSensitiveDataChange?.(null, new Set(), []) + }) + return + } + const acdntSn = parseInt(incident.id, 10) + fetchPredictionAnalyses({ acdntSn }) + .then(items => { + setPredItems(items) + const allIds = new Set(items.map(i => String(i.predRunSn ?? i.acdntSn))) + setCheckedPredIds(allIds) + onCheckedPredsChange?.( + items.map(p => ({ + id: String(p.predRunSn ?? p.acdntSn), + acdntSn: p.acdntSn, + predRunSn: p.predRunSn, + occurredAt: p.occurredAt, + })) + ) + }) + .catch(() => setPredItems([])) + Promise.all([ + fetchSensitiveResources(acdntSn), + fetchSensitiveResourcesGeojson(acdntSn), + ]).then(([cats, geojson]) => { + const allCategories = new Set(cats.map(c => c.category)) + setSensCategories(cats) + setCheckedSensCategories(allCategories) + setSensitiveGeojson(geojson) + onSensitiveDataChange?.(geojson, allCategories, cats.map(c => c.category)) + }).catch(() => { + setSensCategories([]) + setSensitiveGeojson(null) + }) + }, [incident?.id]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!selectedVessel) { + void Promise.resolve().then(() => { setNearbyOrgs([]); setNearbyLoading(false) }) + return + } + void Promise.resolve().then(() => setNearbyLoading(true)) + fetchNearbyOrgs(selectedVessel.lat, selectedVessel.lng, nearbyRadius) + .then(setNearbyOrgs) + .catch(() => setNearbyOrgs([])) + .finally(() => setNearbyLoading(false)) + }, [selectedVessel, nearbyRadius]) + + const togglePredItem = (id: string) => { + setCheckedPredIds(prev => { + const next = new Set(prev) + if (next.has(id)) { next.delete(id) } else { next.add(id) } + const newChecked = predItems + .filter(p => next.has(String(p.predRunSn ?? p.acdntSn))) + .map(p => ({ id: String(p.predRunSn ?? p.acdntSn), acdntSn: p.acdntSn, predRunSn: p.predRunSn, occurredAt: p.occurredAt })) + onCheckedPredsChange?.(newChecked) + return next + }) } - const removeItem = (sectionKey: string, itemId: string) => { - setSections(prev => - prev.map(s => - s.key === sectionKey ? { ...s, items: s.items.filter(it => it.id !== itemId) } : s + const removePredItem = (id: string) => { + setPredItems(prev => { + const next = prev.filter(p => String(p.predRunSn ?? p.acdntSn) !== id) + onCheckedPredsChange?.( + next + .filter(p => checkedPredIds.has(String(p.predRunSn ?? p.acdntSn))) + .map(p => ({ id: String(p.predRunSn ?? p.acdntSn), acdntSn: p.acdntSn, predRunSn: p.predRunSn, occurredAt: p.occurredAt })) ) - ) + return next + }) + setCheckedPredIds(prev => { const next = new Set(prev); next.delete(id); return next }) } - const toggleSensitive = (id: string) => { - setSensitive(prev => prev.map(s => (s.id === id ? { ...s, checked: !s.checked } : s))) + const toggleSensCategory = (category: string) => { + setCheckedSensCategories(prev => { + const next = new Set(prev) + if (next.has(category)) { next.delete(category) } else { next.add(category) } + onSensitiveDataChange?.(sensitiveGeojson, next, sensCategories.map(c => c.category)) + return next + }) + } + + /* 유출유 섹션을 AnalysisSection 형태로 변환 (통합 분석 실행 콜백용) */ + const oilSection: AnalysisSection = { + key: 'oil', + icon: '🛢', + title: '유출유 확산예측', + color: '#f97316', + colorRgb: '249,115,22', + totalLabel: `전체 ${predItems.length}건`, + items: predItems.map(p => { + const id = String(p.predRunSn ?? p.acdntSn) + const dateStr = p.runDtm ? p.runDtm.slice(0, 10) : '' + const oilLabel = p.oilType || '유출유' + return { + id, + name: `${dateStr} ${oilLabel} 확산예측`.trim(), + sub: `${getActiveModels(p)}${p.volume != null ? ` · ${p.volume}kL` : ''}`, + checked: checkedPredIds.has(id), + } + }), } if (!incident) { @@ -147,18 +241,17 @@ export function IncidentsRightPanel({ {/* Scrollable Content */}
- {/* Analysis Sections (oil / hns / rsc) */} - {sections.map(sec => { + + {/* 유출유 확산예측 섹션 */} + {(() => { + const sec = oilSection const checkedCount = sec.items.filter(it => it.checked).length return ( -
- {/* Section Header */} +
{sec.icon} - - {sec.title} - + {sec.title}
- - {/* Items */}
- {sec.items.map(item => ( -
- toggleItem(sec.key, item.id)} - className="shrink-0" - style={{ accentColor: sec.color }} - /> -
-
- {item.name} -
-
- {item.sub} + {sec.items.length === 0 ? ( +
예측 실행 이력이 없습니다
+ ) : ( + sec.items.map(item => ( +
+ togglePredItem(item.id)} + className="shrink-0" + style={{ accentColor: sec.color }} + /> +
+
+ {item.name} +
+
{item.sub}
+ removePredItem(item.id)} + title="제거" + className="text-[10px] cursor-pointer text-text-3 shrink-0" + > + ✕ +
- removeItem(sec.key, item.id)} - title="제거" - className="text-[10px] cursor-pointer text-text-3 shrink-0" - > - ✕ - -
- ))} + )) + )}
- - {/* Status */}
선택: {checkedCount}건 · {sec.totalLabel}
) - })} + })()} + + {/* HNS 대기확산 / 긴급구난 섹션 (미개발 - 구조 유지) */} + {STATIC_SECTIONS.map(sec => ( +
+
+
+ {sec.icon} + {sec.title} +
+ +
+
준비 중입니다
+
+ 선택: 0건 · 전체 0건 +
+
+ ))} {/* 민감자원 */}
🐟 - - 민감자원 - + 민감자원
- {sensitive.map(res => ( - - ))} + {sensCategories.length === 0 ? ( +
해당 사고 영역의 민감자원이 없습니다
+ ) : ( + sensCategories.map((cat, i) => { + const icon = CATEGORY_ICON[cat.category] ?? '🌊' + const areaLabel = cat.totalArea != null + ? `${cat.totalArea.toLocaleString('ko-KR', { maximumFractionDigits: 0 })}ha` + : `${cat.count}개소` + const [r, g, b] = getCategoryColor(i) + const hex = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` + return ( + + ) + }) + )}
@@ -251,13 +375,44 @@ export function IncidentsRightPanel({ 근처 방제자원 + {nearbyOrgs.length > 0 && ( + {nearbyOrgs.length}개 + )}
- {/* Empty state */} -
-
🚢
- 지도에서 선박을 클릭하면
부근 방제자원이 표시됩니다 -
+ {!selectedVessel ? ( +
+
🚢
+ 지도에서 선박을 클릭하면
부근 방제자원이 표시됩니다 +
+ ) : nearbyLoading ? ( +
조회 중...
+ ) : nearbyOrgs.length === 0 ? ( +
반경 내 방제자원 없음
+ ) : ( +
+ {nearbyOrgs.map(org => ( +
+
+
+ + {org.orgTp} + + {org.orgNm} +
+
+ {org.areaNm}{org.totalAssets > 0 ? ` · 장비 ${org.totalAssets}개` : ''} +
+
+ + {org.distanceNm.toFixed(1)} nm + +
+ ))} +
+ )} {/* Radius slider */}
@@ -313,8 +468,11 @@ export function IncidentsRightPanel({ {/* Execute */}
) diff --git a/frontend/src/tabs/incidents/services/incidentsApi.ts b/frontend/src/tabs/incidents/services/incidentsApi.ts index 9bdbb65..5170b00 100644 --- a/frontend/src/tabs/incidents/services/incidentsApi.ts +++ b/frontend/src/tabs/incidents/services/incidentsApi.ts @@ -170,3 +170,30 @@ export async function fetchIncidentPredictions(sn: number): Promise(`/incidents/${sn}/predictions`); return data; } + +export interface NearbyOrgItem { + orgSn: number; + orgTp: string; + jrsdNm: string; + areaNm: string; + orgNm: string; + addr: string; + tel: string; + lat: number; + lng: number; + pinSize: string; + vesselCnt: number; + skimmerCnt: number; + pumpCnt: number; + vehicleCnt: number; + sprayerCnt: number; + totalAssets: number; + distanceNm: number; +} + +export async function fetchNearbyOrgs(lat: number, lng: number, radiusNm: number): Promise { + const { data } = await api.get('/assets/orgs/nearby', { + params: { lat, lng, radius: radiusNm }, + }); + return data; +} diff --git a/frontend/src/tabs/prediction/components/OilSpillView.tsx b/frontend/src/tabs/prediction/components/OilSpillView.tsx index cc84007..9819253 100755 --- a/frontend/src/tabs/prediction/components/OilSpillView.tsx +++ b/frontend/src/tabs/prediction/components/OilSpillView.tsx @@ -218,19 +218,6 @@ export function OilSpillView() { const analysisCircleCenter = analysisTab === 'circle' && incidentCoord ? incidentCoord : null const analysisCircleRadiusM = circleRadiusNm * 1852 - // 분석 탭 초기 진입 시 기본 데모 자동 표시 - useEffect(() => { - if (activeSubTab === 'analysis' && oilTrajectory.length === 0 && !selectedAnalysis) { - const models = Array.from(selectedModels.size > 0 ? selectedModels : new Set(['OpenDrift'])) - const coord = incidentCoord ?? { lat: 37.39, lon: 126.64 } - const demoTrajectory = generateDemoTrajectory(coord, models, predictionTime) - setOilTrajectory(demoTrajectory) - const demoBooms = generateAIBoomLines(demoTrajectory, coord, algorithmSettings) - setBoomLines(demoBooms) - setSensitiveResources([]) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeSubTab]) const handleToggleLayer = (layerId: string, enabled: boolean) => { setEnabledLayers(prev => { diff --git a/frontend/src/tabs/prediction/services/predictionApi.ts b/frontend/src/tabs/prediction/services/predictionApi.ts index 025c2a1..94d9bcd 100644 --- a/frontend/src/tabs/prediction/services/predictionApi.ts +++ b/frontend/src/tabs/prediction/services/predictionApi.ts @@ -84,6 +84,7 @@ export interface BacktrackResult { export const fetchPredictionAnalyses = async (params?: { search?: string; + acdntSn?: number; }): Promise => { const response = await api.get('/prediction/analyses', { params }); return response.data; diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index 3f25e03..947153e 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -51,7 +51,7 @@ export interface OilSpillReportData { } // eslint-disable-next-line react-refresh/only-export-components -export function createEmptyReport(): OilSpillReportData { +export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportData { const now = new Date() const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}` return { @@ -62,7 +62,7 @@ export function createEmptyReport(): OilSpillReportData { author: '', reportType: '예측보고서', analysisCategory: '', - jurisdiction: '남해청', + jurisdiction: jurisdiction || '', status: '수행중', incident: { name: '', writeTime: ts, shipName: '', agent: '', location: '', lat: '', lon: '', occurTime: '', accidentType: '', pollutant: '', spillAmount: '', depth: '', seabed: '' }, tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }], diff --git a/frontend/src/tabs/reports/components/ReportGenerator.tsx b/frontend/src/tabs/reports/components/ReportGenerator.tsx index ca2eb70..e199f8d 100644 --- a/frontend/src/tabs/reports/components/ReportGenerator.tsx +++ b/frontend/src/tabs/reports/components/ReportGenerator.tsx @@ -1,8 +1,10 @@ import { useState, useEffect } from 'react'; import { createEmptyReport, + type Jurisdiction, } from './OilSpillReportTemplate'; import { consumeReportGenCategory, consumeHnsReportPayload, type HnsReportPayload, consumeOilReportPayload, type OilReportPayload } from '@common/hooks/useSubMenu'; +import { useAuthStore } from '@common/store/authStore'; import { useWeatherSnapshotStore } from '@common/store/weatherSnapshotStore'; import OilSpreadMapPanel from './OilSpreadMapPanel'; import { saveReport } from '../services/reportsApi'; @@ -18,6 +20,7 @@ interface ReportGeneratorProps { } function ReportGenerator({ onSave }: ReportGeneratorProps) { + const user = useAuthStore(s => s.user); const [activeCat, setActiveCat] = useState(() => { const hint = consumeReportGenCategory() return (hint === 0 || hint === 1 || hint === 2) ? hint : 0 @@ -67,7 +70,7 @@ function ReportGenerator({ onSave }: ReportGeneratorProps) { } const handleSave = async () => { - const report = createEmptyReport() + const report = createEmptyReport((user?.org?.abbr || '') as Jurisdiction) report.reportType = activeCat === 0 ? '유출유 보고' : activeCat === 1 ? '종합보고서' : '초기보고서' report.analysisCategory = activeCat === 0 ? '유출유 확산예측' : activeCat === 1 ? 'HNS 대기확산' : '긴급구난' report.title = cat.reportName diff --git a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx index b3bf134..7e24922 100644 --- a/frontend/src/tabs/reports/components/TemplateFormEditor.tsx +++ b/frontend/src/tabs/reports/components/TemplateFormEditor.tsx @@ -4,6 +4,7 @@ import { type ReportType, type Jurisdiction, } from './OilSpillReportTemplate'; +import { useAuthStore } from '@common/store/authStore'; import { templateTypes } from './reportTypes'; import { generateReportHTML, exportAsPDF, exportAsHWP } from './reportUtils'; import { saveReport } from '../services/reportsApi'; @@ -14,6 +15,7 @@ interface TemplateFormEditorProps { } function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) { + const user = useAuthStore(s => s.user); const [selectedType, setSelectedType] = useState('초기보고서') const [formData, setFormData] = useState>({}) const [reportMeta, setReportMeta] = useState(() => { @@ -21,7 +23,7 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) { return { title: '', author: '', - jurisdiction: '남해청' as Jurisdiction, + jurisdiction: (user?.org?.abbr || '') as Jurisdiction, writeTime: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`, } }) @@ -42,9 +44,8 @@ function TemplateFormEditor({ onSave, onBack }: TemplateFormEditorProps) { } const handleSave = async () => { - const report = createEmptyReport() + const report = createEmptyReport(reportMeta.jurisdiction) report.reportType = selectedType - report.jurisdiction = reportMeta.jurisdiction report.author = reportMeta.author report.title = formData['incident.name'] || `${selectedType} ${reportMeta.writeTime}` report.status = '완료' diff --git a/frontend/src/tabs/reports/services/reportsApi.ts b/frontend/src/tabs/reports/services/reportsApi.ts index cf50a6a..e958cf6 100644 --- a/frontend/src/tabs/reports/services/reportsApi.ts +++ b/frontend/src/tabs/reports/services/reportsApi.ts @@ -288,7 +288,7 @@ export function apiListItemToReportData(item: ApiReportListItem): OilSpillReport author: item.authorName || '', reportType: (item.tmplCd ? TMPL_CODE_TO_TYPE[item.tmplCd] : '초기보고서') || '초기보고서', analysisCategory: (item.ctgrCd ? CTGR_CODE_TO_CAT[item.ctgrCd] : '') || '', - jurisdiction: (item.jrsdCd as Jurisdiction) || '남해청', + jurisdiction: (item.jrsdCd as Jurisdiction) || '', status: CODE_TO_STATUS[item.sttsCd] || '테스트', hasMapCapture: item.hasMapCapture, acdntSn: item.acdntSn ?? undefined, -- 2.45.2 From c07de4251eafa17395236e0e8f8caf0b84430ee4 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 25 Mar 2026 18:20:39 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 6f5a1f5..1d56f6b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,15 @@ ## [Unreleased] +### 추가 +- 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) +- 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) +- DB: PRED_EXEC 테이블 EXEC_USER_ID 컬럼 추가 (029 마이그레이션) + +### 변경 +- 사고: 지도에서 사고 선택 시 FlyTo 애니메이션 적용 +- 사고: 선택된 항목 재클릭 시 선택 해제 지원 + ## [2026-03-25] ### 추가 -- 2.45.2 From 696c2d5b7c882374a026cdd69c444d3f9b658517 Mon Sep 17 00:00:00 2001 From: "jeonghyo.k" Date: Wed, 25 Mar 2026 18:28:15 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.json | 5 +++-- .claude/workflow-version.json | 2 +- docs/RELEASE-NOTES.md | 2 ++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index 868df2d..43453f7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -83,5 +83,6 @@ ] } ] - } -} + }, + "allow": [] +} \ No newline at end of file diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index 839d5f4..863bec2 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -4,4 +4,4 @@ "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true -} +} \ No newline at end of file diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1d56f6b..df94b69 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-25.2] + ### 추가 - 사고: 분석 패널 실데이터 연동 (확산예측·민감자원 API 연동, 카테고리 색상·이모지 매핑) - 자산: 인근 기관 조회 API 추가 (/assets/orgs/nearby, PostGIS ST_DWithin) -- 2.45.2