feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue) #173

병합
jhkang feature/prediction-gsc-accident-select 에서 develop 로 4 commits 를 머지했습니다 2026-04-15 08:25:35 +09:00
14개의 변경된 파일251개의 추가작업 그리고 59개의 파일을 삭제

파일 보기

@ -5,7 +5,30 @@
},
"permissions": {
"allow": [
"Bash(*)"
"Bash(*)",
"Bash(npm run *)",
"Bash(npm install *)",
"Bash(npm test *)",
"Bash(npx *)",
"Bash(node *)",
"Bash(git status)",
"Bash(git diff *)",
"Bash(git log *)",
"Bash(git branch *)",
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit *)",
"Bash(git pull *)",
"Bash(git fetch *)",
"Bash(git merge *)",
"Bash(git stash *)",
"Bash(git remote *)",
"Bash(git config *)",
"Bash(git rev-parse *)",
"Bash(git show *)",
"Bash(git tag *)",
"Bash(curl -s *)",
"Bash(fnm *)"
],
"deny": [
"Bash(git push --force*)",

파일 보기

@ -1,6 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-03-31",
"applied_date": "2026-04-14",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true

파일 보기

@ -0,0 +1,20 @@
import { Router } from 'express';
import { requireAuth } from '../auth/authMiddleware.js';
import { listGscAccidents } from './gscAccidentsService.js';
const router = Router();
// ============================================================
// GET /api/gsc/accidents — 외부 수집 사고 목록 (최신 20건)
// ============================================================
router.get('/', requireAuth, async (_req, res) => {
try {
const accidents = await listGscAccidents(20);
res.json(accidents);
} catch (err) {
console.error('[gsc] 사고 목록 조회 오류:', err);
res.status(500).json({ error: '사고 목록 조회 중 오류가 발생했습니다.' });
}
});
export default router;

파일 보기

@ -0,0 +1,65 @@
import { wingPool } from '../db/wingDb.js';
export interface GscAccidentListItem {
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: 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[]> {
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
`;
const result = await wingPool.query<{
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: string | null;
lon: string | null;
}>(orderedSql, [ACDNT_ASORT_CODES, limit]);
return result.rows.map((row) => ({
acdntMngNo: row.acdntMngNo,
pollNm: row.pollNm,
pollDate: row.pollDate,
lat: row.lat != null ? Number(row.lat) : null,
lon: row.lon != null ? Number(row.lon) : null,
}));
}

파일 보기

@ -19,6 +19,7 @@ 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 gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
import scatRouter from './scat/scatRouter.js'
import predictionRouter from './prediction/predictionRouter.js'
import aerialRouter from './aerial/aerialRouter.js'
@ -168,6 +169,7 @@ app.use('/api/hns', hnsRouter)
app.use('/api/reports', reportsRouter)
app.use('/api/assets', assetsRouter)
app.use('/api/incidents', incidentsRouter)
app.use('/api/gsc/accidents', gscAccidentsRouter)
app.use('/api/scat', scatRouter)
app.use('/api/prediction', predictionRouter)
app.use('/api/aerial', aerialRouter)

파일 보기

@ -4,6 +4,9 @@
## [Unreleased]
### 추가
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
### 변경
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용

파일 보기

@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox';
import { useWeatherFetch } from '../hooks/useWeatherFetch';
import { getSubstanceToxicity } from '../utils/toxicityData';
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/** HNS 분석 입력 파라미터 (부모에 전달) */
export interface HNSInputParams {
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
onParamsChange?: (params: HNSInputParams) => void;
onReset?: () => void;
loadedParams?: Partial<HNSInputParams> | null;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
}
/** 십진 좌표 → 도분초 변환 */
@ -67,8 +68,9 @@ export function HNSLeftPanel({
onParamsChange,
onReset,
loadedParams,
onFlyToCoord,
}: HNSLeftPanelProps) {
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
const toggleSection = (key: 'accident' | 'params') =>
@ -138,21 +140,26 @@ export function HNSLeftPanel({
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
if (incidentsPromiseRef.current == null) {
incidentsPromiseRef.current = fetchIncidentsRaw()
incidentsPromiseRef.current = fetchGscAccidents()
.then((data) => setIncidents(data))
.catch(() => setIncidents([]));
}
// 사고 선택 시 필드 자동 채움
const handleSelectIncident = (snStr: string) => {
setSelectedIncidentSn(snStr);
const sn = parseInt(snStr);
const incident = incidents.find((i) => i.acdntSn === sn);
const handleSelectIncident = (mngNo: string) => {
setSelectedIncidentSn(mngNo);
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
if (!incident) return;
setAccidentName(incident.acdntNm);
if (incident.lat && incident.lng) {
onCoordChange({ lat: incident.lat, lon: incident.lng });
setAccidentName(incident.pollNm);
if (incident.pollDate) {
const [d, t] = incident.pollDate.split('T');
if (d) setAccidentDate(d);
if (t) setAccidentTime(t);
}
if (incident.lat != null && incident.lon != null) {
onCoordChange({ lat: incident.lat, lon: incident.lon });
onFlyToCoord?.({ lat: incident.lat, lon: incident.lon });
}
};
@ -266,8 +273,8 @@ export function HNSLeftPanel({
onChange={handleSelectIncident}
placeholder="또는 사고 리스트에서 선택"
options={incidents.map((inc) => ({
value: String(inc.acdntSn),
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
value: inc.acdntMngNo,
label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
}))}
/>

파일 보기

@ -265,6 +265,7 @@ export function HNSView() {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -904,6 +905,7 @@ export function HNSView() {
onParamsChange={handleParamsChange}
onReset={handleReset}
loadedParams={loadedParams}
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
/>
</div>
)}
@ -963,6 +965,8 @@ export function HNSView() {
<>
<MapView
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}

파일 보기

@ -114,6 +114,7 @@ export function LeftPanel({
onLayerColorChange,
sensitiveResources = [],
onImageAnalysisResult,
onFlyToCoord,
validationErrors,
}: LeftPanelProps) {
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
@ -168,6 +169,7 @@ export function LeftPanel({
spillUnit={spillUnit}
onSpillUnitChange={onSpillUnitChange}
onImageAnalysisResult={onImageAnalysisResult}
onFlyToCoord={onFlyToCoord}
validationErrors={validationErrors}
/>

파일 보기

@ -1211,6 +1211,9 @@ export function OilSpillView() {
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
sensitiveResources={sensitiveResourceCategories}
onImageAnalysisResult={handleImageAnalysisResult}
onFlyToCoord={(c: { lon: number; lat: number }) =>
setFlyToCoord({ lat: c.lat, lon: c.lon })
}
validationErrors={validationErrors}
/>
</div>

파일 보기

@ -1,8 +1,8 @@
import { useState, useRef, useEffect } from 'react';
import { ComboBox } from '@common/components/ui/ComboBox';
import type { PredictionModel } from './OilSpillView';
import { analyzeImage } from '../services/predictionApi';
import type { ImageAnalyzeResult } from '../services/predictionApi';
import { analyzeImage, fetchGscAccidents } from '../services/predictionApi';
import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi';
interface PredictionInputSectionProps {
expanded: boolean;
@ -33,6 +33,7 @@ interface PredictionInputSectionProps {
spillUnit: string;
onSpillUnitChange: (unit: string) => void;
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
validationErrors?: Set<string>;
}
@ -64,6 +65,7 @@ const PredictionInputSection = ({
spillUnit,
onSpillUnitChange,
onImageAnalysisResult,
onFlyToCoord,
validationErrors,
}: PredictionInputSectionProps) => {
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
@ -71,8 +73,41 @@ const PredictionInputSection = ({
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
const [gscAccidents, setGscAccidents] = useState<GscAccidentListItem[]>([]);
const [selectedGscMngNo, setSelectedGscMngNo] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
fetchGscAccidents()
.then((list) => {
if (!cancelled) setGscAccidents(list);
})
.catch((err) => {
console.error('[prediction] GSC 사고 목록 조회 실패:', err);
});
return () => {
cancelled = true;
};
}, []);
const handleGscAccidentSelect = (mngNo: string) => {
setSelectedGscMngNo(mngNo);
const item = gscAccidents.find((a) => a.acdntMngNo === mngNo);
if (!item) return;
onIncidentNameChange(item.pollNm);
if (item.pollDate) onAccidentTimeChange(item.pollDate);
if (item.lat != null && item.lon != null) {
onCoordChange({ lat: item.lat, lon: item.lon });
onFlyToCoord?.({ lat: item.lat, lon: item.lon });
}
};
const gscOptions = gscAccidents.map((a) => ({
value: a.acdntMngNo,
label: `${a.pollNm} (${a.pollDate ? a.pollDate.replace('T', ' ') : '-'})`,
}));
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] ?? null;
setUploadedFile(file);
@ -161,7 +196,13 @@ const PredictionInputSection = ({
: undefined
}
/>
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
<ComboBox
className="prd-i"
value={selectedGscMngNo}
onChange={handleGscAccidentSelect}
options={gscOptions}
placeholder="또는 사고 리스트에서 선택"
/>
{/* Image Upload Mode */}
{inputMode === 'upload' && (

파일 보기

@ -62,6 +62,8 @@ export interface LeftPanelProps {
sensitiveResources?: SensitiveResourceCategory[];
// 이미지 분석 결과 콜백
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
// 사고 리스트 선택 시 지도 이동 콜백
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
// 유효성 검증 에러 필드
validationErrors?: Set<string>;
}

파일 보기

@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
});
return response.data;
};
// ============================================================
// GSC 외부 수집 사고 목록 (확산 예측 입력 셀렉트용)
// ============================================================
export interface GscAccidentListItem {
acdntMngNo: string;
pollNm: string;
pollDate: string | null;
lat: number | null;
lon: number | null;
}
export const fetchGscAccidents = async (): Promise<GscAccidentListItem[]> => {
const response = await api.get<GscAccidentListItem[]>('/gsc/accidents');
return response.data;
};

파일 보기

@ -5,8 +5,8 @@ import { RescueTheoryView } from './RescueTheoryView';
import { RescueScenarioView } from './RescueScenarioView';
import { fetchRescueOps } from '../services/rescueApi';
import type { RescueOpsItem } from '../services/rescueApi';
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
/* ─── Types ─── */
type AccidentType =
@ -230,9 +230,9 @@ function LeftPanel({
}: {
activeType: AccidentType;
onTypeChange: (t: AccidentType) => void;
incidents: IncidentListItem[];
selectedAcdnt: IncidentListItem | null;
onSelectAcdnt: (item: IncidentListItem | null) => void;
incidents: GscAccidentListItem[];
selectedAcdnt: GscAccidentListItem | null;
onSelectAcdnt: (item: GscAccidentListItem | null) => void;
}) {
const [acdntName, setAcdntName] = useState('');
const [acdntDate, setAcdntDate] = useState('');
@ -242,18 +242,25 @@ function LeftPanel({
const [showList, setShowList] = useState(false);
// 사고 선택 시 필드 자동 채움
const handlePickIncident = (item: IncidentListItem) => {
const handlePickIncident = (item: GscAccidentListItem) => {
onSelectAcdnt(item);
setAcdntName(item.acdntNm);
const dt = new Date(item.occrnDtm);
setAcdntDate(
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
);
setAcdntTime(
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
);
setAcdntLat(String(item.lat));
setAcdntLon(String(item.lng));
setAcdntName(item.pollNm);
if (item.pollDate) {
const [d, t] = item.pollDate.split('T');
if (d) {
const [y, m, day] = d.split('-');
setAcdntDate(`${y}. ${m}. ${day}.`);
}
if (t) {
const [hhStr, mmStr] = t.split(':');
const hh = parseInt(hhStr, 10);
const ampm = hh >= 12 ? '오후' : '오전';
const hh12 = String(hh % 12 || 12).padStart(2, '0');
setAcdntTime(`${ampm} ${hh12}:${mmStr}`);
}
}
if (item.lat != null) setAcdntLat(String(item.lat));
if (item.lon != null) setAcdntLon(String(item.lon));
setShowList(false);
};
@ -283,7 +290,7 @@ function LeftPanel({
className="w-full px-2 py-1.5 text-caption bg-bg-card border border-stroke rounded font-korean text-left cursor-pointer hover:border-[var(--stroke-light)] flex items-center justify-between"
>
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
{selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
</span>
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
</button>
@ -296,13 +303,13 @@ function LeftPanel({
)}
{incidents.map((item) => (
<button
key={item.acdntSn}
key={item.acdntMngNo}
onClick={() => handlePickIncident(item)}
className="w-full text-left px-2 py-1.5 text-caption font-korean hover:bg-bg-surface cursor-pointer border-b border-stroke last:border-b-0"
>
<div className="text-fg font-semibold truncate">{item.acdntNm}</div>
<div className="text-fg font-semibold truncate">{item.pollNm}</div>
<div className="text-fg-disabled text-[10px]">
{item.acdntCd} · {item.regionNm}
{item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
</div>
</button>
))}
@ -1523,13 +1530,14 @@ export function RescueView() {
const { activeSubTab } = useSubMenu('rescue');
const [activeType, setActiveType] = useState<AccidentType>('collision');
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | null>(null);
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | null>(null);
const [flyToCoord, setFlyToCoord] = useState<{ lon: number; lat: number } | undefined>(undefined);
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
useEffect(() => {
fetchIncidentsRaw()
fetchGscAccidents()
.then((items) => setIncidents(items))
.catch(() => setIncidents([]));
}, []);
@ -1540,24 +1548,17 @@ export function RescueView() {
setIsSelectingLocation(false);
}, []);
// 사고 선택 시 사고유형 자동 매핑
const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => {
// 사고 선택 시 좌표 자동 반영 + 지도 이동
const handleSelectAcdnt = useCallback(
(item: GscAccidentListItem | null) => {
setSelectedAcdnt(item);
if (item) {
const typeMap: Record<string, AccidentType> = {
collision: 'collision',
grounding: 'grounding',
turning: 'turning',
capsizing: 'capsizing',
sharpTurn: 'sharpTurn',
flooding: 'flooding',
sinking: 'sinking',
};
const mapped = typeMap[item.acdntTpCd];
if (mapped) setActiveType(mapped);
setIncidentCoord({ lon: item.lng, lat: item.lat });
if (item && item.lat != null && item.lon != null) {
setIncidentCoord({ lon: item.lon, lat: item.lat });
setFlyToCoord({ lon: item.lon, lat: item.lat });
}
}, []);
},
[],
);
if (activeSubTab === 'list') {
return (
@ -1592,6 +1593,8 @@ export function RescueView() {
<div className="flex-1 relative overflow-hidden">
<MapView
incidentCoord={incidentCoord ?? undefined}
flyToIncident={flyToCoord}
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
isSelectingLocation={isSelectingLocation}
onMapClick={handleMapClick}
oilTrajectory={[]}