Merge pull request 'feat(prediction): GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (prediction/hns/rescue)' (#173) from feature/prediction-gsc-accident-select into develop
This commit is contained in:
커밋
ae0a17990b
@ -5,7 +5,30 @@
|
|||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"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": [
|
"deny": [
|
||||||
"Bash(git push --force*)",
|
"Bash(git push --force*)",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.6.1",
|
||||||
"applied_date": "2026-03-31",
|
"applied_date": "2026-04-14",
|
||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
|
|||||||
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
20
backend/src/gsc/gscAccidentsRouter.ts
Normal file
@ -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;
|
||||||
65
backend/src/gsc/gscAccidentsService.ts
Normal file
65
backend/src/gsc/gscAccidentsService.ts
Normal file
@ -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 reportsRouter from './reports/reportsRouter.js'
|
||||||
import assetsRouter from './assets/assetsRouter.js'
|
import assetsRouter from './assets/assetsRouter.js'
|
||||||
import incidentsRouter from './incidents/incidentsRouter.js'
|
import incidentsRouter from './incidents/incidentsRouter.js'
|
||||||
|
import gscAccidentsRouter from './gsc/gscAccidentsRouter.js'
|
||||||
import scatRouter from './scat/scatRouter.js'
|
import scatRouter from './scat/scatRouter.js'
|
||||||
import predictionRouter from './prediction/predictionRouter.js'
|
import predictionRouter from './prediction/predictionRouter.js'
|
||||||
import aerialRouter from './aerial/aerialRouter.js'
|
import aerialRouter from './aerial/aerialRouter.js'
|
||||||
@ -168,6 +169,7 @@ app.use('/api/hns', hnsRouter)
|
|||||||
app.use('/api/reports', reportsRouter)
|
app.use('/api/reports', reportsRouter)
|
||||||
app.use('/api/assets', assetsRouter)
|
app.use('/api/assets', assetsRouter)
|
||||||
app.use('/api/incidents', incidentsRouter)
|
app.use('/api/incidents', incidentsRouter)
|
||||||
|
app.use('/api/gsc/accidents', gscAccidentsRouter)
|
||||||
app.use('/api/scat', scatRouter)
|
app.use('/api/scat', scatRouter)
|
||||||
app.use('/api/prediction', predictionRouter)
|
app.use('/api/prediction', predictionRouter)
|
||||||
app.use('/api/aerial', aerialRouter)
|
app.use('/api/aerial', aerialRouter)
|
||||||
|
|||||||
@ -4,6 +4,9 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- 확산예측·HNS 대기확산·긴급구난: GSC 외부 사고 목록 API 연동 및 셀렉트박스 자동 채움 (사고명·발생시각·위경도 자동 입력 + 지도 이동)
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
- MapView 컴포넌트 분리 및 전체 탭 디자인 시스템 토큰 적용
|
||||||
|
|
||||||
|
|||||||
@ -3,8 +3,8 @@ import { ComboBox } from '@common/components/ui/ComboBox';
|
|||||||
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
import { useWeatherFetch } from '../hooks/useWeatherFetch';
|
||||||
import { getSubstanceToxicity } from '../utils/toxicityData';
|
import { getSubstanceToxicity } from '../utils/toxicityData';
|
||||||
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
import type { WeatherFetchResult, ReleaseType } from '../utils/dispersionTypes';
|
||||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
/** HNS 분석 입력 파라미터 (부모에 전달) */
|
||||||
export interface HNSInputParams {
|
export interface HNSInputParams {
|
||||||
@ -44,6 +44,7 @@ interface HNSLeftPanelProps {
|
|||||||
onParamsChange?: (params: HNSInputParams) => void;
|
onParamsChange?: (params: HNSInputParams) => void;
|
||||||
onReset?: () => void;
|
onReset?: () => void;
|
||||||
loadedParams?: Partial<HNSInputParams> | null;
|
loadedParams?: Partial<HNSInputParams> | null;
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 십진 좌표 → 도분초 변환 */
|
/** 십진 좌표 → 도분초 변환 */
|
||||||
@ -67,8 +68,9 @@ export function HNSLeftPanel({
|
|||||||
onParamsChange,
|
onParamsChange,
|
||||||
onReset,
|
onReset,
|
||||||
loadedParams,
|
loadedParams,
|
||||||
|
onFlyToCoord,
|
||||||
}: HNSLeftPanelProps) {
|
}: HNSLeftPanelProps) {
|
||||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||||
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
const [selectedIncidentSn, setSelectedIncidentSn] = useState('');
|
||||||
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
const [expandedSections, setExpandedSections] = useState({ accident: true, params: true });
|
||||||
const toggleSection = (key: 'accident' | 'params') =>
|
const toggleSection = (key: 'accident' | 'params') =>
|
||||||
@ -138,21 +140,26 @@ export function HNSLeftPanel({
|
|||||||
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
// 사고 목록 불러오기 (마운트 시 1회, ref 초기화 패턴)
|
||||||
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
const incidentsPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
if (incidentsPromiseRef.current == null) {
|
if (incidentsPromiseRef.current == null) {
|
||||||
incidentsPromiseRef.current = fetchIncidentsRaw()
|
incidentsPromiseRef.current = fetchGscAccidents()
|
||||||
.then((data) => setIncidents(data))
|
.then((data) => setIncidents(data))
|
||||||
.catch(() => setIncidents([]));
|
.catch(() => setIncidents([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사고 선택 시 필드 자동 채움
|
// 사고 선택 시 필드 자동 채움
|
||||||
const handleSelectIncident = (snStr: string) => {
|
const handleSelectIncident = (mngNo: string) => {
|
||||||
setSelectedIncidentSn(snStr);
|
setSelectedIncidentSn(mngNo);
|
||||||
const sn = parseInt(snStr);
|
const incident = incidents.find((i) => i.acdntMngNo === mngNo);
|
||||||
const incident = incidents.find((i) => i.acdntSn === sn);
|
|
||||||
if (!incident) return;
|
if (!incident) return;
|
||||||
|
|
||||||
setAccidentName(incident.acdntNm);
|
setAccidentName(incident.pollNm);
|
||||||
if (incident.lat && incident.lng) {
|
if (incident.pollDate) {
|
||||||
onCoordChange({ lat: incident.lat, lon: incident.lng });
|
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}
|
onChange={handleSelectIncident}
|
||||||
placeholder="또는 사고 리스트에서 선택"
|
placeholder="또는 사고 리스트에서 선택"
|
||||||
options={incidents.map((inc) => ({
|
options={incidents.map((inc) => ({
|
||||||
value: String(inc.acdntSn),
|
value: inc.acdntMngNo,
|
||||||
label: `${inc.acdntNm} (${new Date(inc.occrnDtm).toLocaleDateString('ko-KR')})`,
|
label: `${inc.pollNm} (${inc.pollDate ? inc.pollDate.replace('T', ' ') : '-'})`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -265,6 +265,7 @@ export function HNSView() {
|
|||||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | 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);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
const [isRunningPrediction, setIsRunningPrediction] = useState(false);
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
@ -904,6 +905,7 @@ export function HNSView() {
|
|||||||
onParamsChange={handleParamsChange}
|
onParamsChange={handleParamsChange}
|
||||||
onReset={handleReset}
|
onReset={handleReset}
|
||||||
loadedParams={loadedParams}
|
loadedParams={loadedParams}
|
||||||
|
onFlyToCoord={(c) => setFlyToCoord({ lat: c.lat, lon: c.lon })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -963,6 +965,8 @@ export function HNSView() {
|
|||||||
<>
|
<>
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
|
flyToIncident={flyToCoord}
|
||||||
|
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
|
|||||||
@ -114,6 +114,7 @@ export function LeftPanel({
|
|||||||
onLayerColorChange,
|
onLayerColorChange,
|
||||||
sensitiveResources = [],
|
sensitiveResources = [],
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
|
onFlyToCoord,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
}: LeftPanelProps) {
|
}: LeftPanelProps) {
|
||||||
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
const [expandedSections, setExpandedSections] = useState<ExpandedSections>({
|
||||||
@ -168,6 +169,7 @@ export function LeftPanel({
|
|||||||
spillUnit={spillUnit}
|
spillUnit={spillUnit}
|
||||||
onSpillUnitChange={onSpillUnitChange}
|
onSpillUnitChange={onSpillUnitChange}
|
||||||
onImageAnalysisResult={onImageAnalysisResult}
|
onImageAnalysisResult={onImageAnalysisResult}
|
||||||
|
onFlyToCoord={onFlyToCoord}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1211,6 +1211,9 @@ export function OilSpillView() {
|
|||||||
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
onLayerColorChange={(id, color) => setLayerColors((prev) => ({ ...prev, [id]: color }))}
|
||||||
sensitiveResources={sensitiveResourceCategories}
|
sensitiveResources={sensitiveResourceCategories}
|
||||||
onImageAnalysisResult={handleImageAnalysisResult}
|
onImageAnalysisResult={handleImageAnalysisResult}
|
||||||
|
onFlyToCoord={(c: { lon: number; lat: number }) =>
|
||||||
|
setFlyToCoord({ lat: c.lat, lon: c.lon })
|
||||||
|
}
|
||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { ComboBox } from '@common/components/ui/ComboBox';
|
import { ComboBox } from '@common/components/ui/ComboBox';
|
||||||
import type { PredictionModel } from './OilSpillView';
|
import type { PredictionModel } from './OilSpillView';
|
||||||
import { analyzeImage } from '../services/predictionApi';
|
import { analyzeImage, fetchGscAccidents } from '../services/predictionApi';
|
||||||
import type { ImageAnalyzeResult } from '../services/predictionApi';
|
import type { ImageAnalyzeResult, GscAccidentListItem } from '../services/predictionApi';
|
||||||
|
|
||||||
interface PredictionInputSectionProps {
|
interface PredictionInputSectionProps {
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@ -33,6 +33,7 @@ interface PredictionInputSectionProps {
|
|||||||
spillUnit: string;
|
spillUnit: string;
|
||||||
onSpillUnitChange: (unit: string) => void;
|
onSpillUnitChange: (unit: string) => void;
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
validationErrors?: Set<string>;
|
validationErrors?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,6 +65,7 @@ const PredictionInputSection = ({
|
|||||||
spillUnit,
|
spillUnit,
|
||||||
onSpillUnitChange,
|
onSpillUnitChange,
|
||||||
onImageAnalysisResult,
|
onImageAnalysisResult,
|
||||||
|
onFlyToCoord,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
}: PredictionInputSectionProps) => {
|
}: PredictionInputSectionProps) => {
|
||||||
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
|
const [inputMode, setInputMode] = useState<'direct' | 'upload'>('direct');
|
||||||
@ -71,8 +73,41 @@ const PredictionInputSection = ({
|
|||||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||||
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
const [analyzeError, setAnalyzeError] = useState<string | null>(null);
|
||||||
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
|
const [analyzeResult, setAnalyzeResult] = useState<ImageAnalyzeResult | null>(null);
|
||||||
|
const [gscAccidents, setGscAccidents] = useState<GscAccidentListItem[]>([]);
|
||||||
|
const [selectedGscMngNo, setSelectedGscMngNo] = useState<string>('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
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 handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0] ?? null;
|
const file = e.target.files?.[0] ?? null;
|
||||||
setUploadedFile(file);
|
setUploadedFile(file);
|
||||||
@ -161,7 +196,13 @@ const PredictionInputSection = ({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<input className="prd-i" placeholder="또는 사고 리스트에서 선택" readOnly />
|
<ComboBox
|
||||||
|
className="prd-i"
|
||||||
|
value={selectedGscMngNo}
|
||||||
|
onChange={handleGscAccidentSelect}
|
||||||
|
options={gscOptions}
|
||||||
|
placeholder="또는 사고 리스트에서 선택"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Image Upload Mode */}
|
{/* Image Upload Mode */}
|
||||||
{inputMode === 'upload' && (
|
{inputMode === 'upload' && (
|
||||||
|
|||||||
@ -62,6 +62,8 @@ export interface LeftPanelProps {
|
|||||||
sensitiveResources?: SensitiveResourceCategory[];
|
sensitiveResources?: SensitiveResourceCategory[];
|
||||||
// 이미지 분석 결과 콜백
|
// 이미지 분석 결과 콜백
|
||||||
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
onImageAnalysisResult?: (result: ImageAnalyzeResult) => void;
|
||||||
|
// 사고 리스트 선택 시 지도 이동 콜백
|
||||||
|
onFlyToCoord?: (coord: { lon: number; lat: number }) => void;
|
||||||
// 유효성 검증 에러 필드
|
// 유효성 검증 에러 필드
|
||||||
validationErrors?: Set<string>;
|
validationErrors?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -321,3 +321,20 @@ export const analyzeImage = async (file: File, acdntNm?: string): Promise<ImageA
|
|||||||
});
|
});
|
||||||
return response.data;
|
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 { RescueScenarioView } from './RescueScenarioView';
|
||||||
import { fetchRescueOps } from '../services/rescueApi';
|
import { fetchRescueOps } from '../services/rescueApi';
|
||||||
import type { RescueOpsItem } from '../services/rescueApi';
|
import type { RescueOpsItem } from '../services/rescueApi';
|
||||||
import { fetchIncidentsRaw } from '@tabs/incidents/services/incidentsApi';
|
import { fetchGscAccidents } from '@tabs/prediction/services/predictionApi';
|
||||||
import type { IncidentListItem } from '@tabs/incidents/services/incidentsApi';
|
import type { GscAccidentListItem } from '@tabs/prediction/services/predictionApi';
|
||||||
|
|
||||||
/* ─── Types ─── */
|
/* ─── Types ─── */
|
||||||
type AccidentType =
|
type AccidentType =
|
||||||
@ -230,9 +230,9 @@ function LeftPanel({
|
|||||||
}: {
|
}: {
|
||||||
activeType: AccidentType;
|
activeType: AccidentType;
|
||||||
onTypeChange: (t: AccidentType) => void;
|
onTypeChange: (t: AccidentType) => void;
|
||||||
incidents: IncidentListItem[];
|
incidents: GscAccidentListItem[];
|
||||||
selectedAcdnt: IncidentListItem | null;
|
selectedAcdnt: GscAccidentListItem | null;
|
||||||
onSelectAcdnt: (item: IncidentListItem | null) => void;
|
onSelectAcdnt: (item: GscAccidentListItem | null) => void;
|
||||||
}) {
|
}) {
|
||||||
const [acdntName, setAcdntName] = useState('');
|
const [acdntName, setAcdntName] = useState('');
|
||||||
const [acdntDate, setAcdntDate] = useState('');
|
const [acdntDate, setAcdntDate] = useState('');
|
||||||
@ -242,18 +242,25 @@ function LeftPanel({
|
|||||||
const [showList, setShowList] = useState(false);
|
const [showList, setShowList] = useState(false);
|
||||||
|
|
||||||
// 사고 선택 시 필드 자동 채움
|
// 사고 선택 시 필드 자동 채움
|
||||||
const handlePickIncident = (item: IncidentListItem) => {
|
const handlePickIncident = (item: GscAccidentListItem) => {
|
||||||
onSelectAcdnt(item);
|
onSelectAcdnt(item);
|
||||||
setAcdntName(item.acdntNm);
|
setAcdntName(item.pollNm);
|
||||||
const dt = new Date(item.occrnDtm);
|
if (item.pollDate) {
|
||||||
setAcdntDate(
|
const [d, t] = item.pollDate.split('T');
|
||||||
`${dt.getFullYear()}. ${String(dt.getMonth() + 1).padStart(2, '0')}. ${String(dt.getDate()).padStart(2, '0')}.`,
|
if (d) {
|
||||||
);
|
const [y, m, day] = d.split('-');
|
||||||
setAcdntTime(
|
setAcdntDate(`${y}. ${m}. ${day}.`);
|
||||||
`${dt.getHours() >= 12 ? '오후' : '오전'} ${String(dt.getHours() % 12 || 12).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`,
|
}
|
||||||
);
|
if (t) {
|
||||||
setAcdntLat(String(item.lat));
|
const [hhStr, mmStr] = t.split(':');
|
||||||
setAcdntLon(String(item.lng));
|
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);
|
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"
|
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'}>
|
<span className={selectedAcdnt ? 'text-fg' : 'text-fg-disabled/50'}>
|
||||||
{selectedAcdnt ? selectedAcdnt.acdntCd : '또는 사고 리스트에서 선택'}
|
{selectedAcdnt ? selectedAcdnt.pollNm : '또는 사고 리스트에서 선택'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
<span className="text-fg-disabled text-[10px]">{showList ? '▲' : '▼'}</span>
|
||||||
</button>
|
</button>
|
||||||
@ -296,13 +303,13 @@ function LeftPanel({
|
|||||||
)}
|
)}
|
||||||
{incidents.map((item) => (
|
{incidents.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.acdntSn}
|
key={item.acdntMngNo}
|
||||||
onClick={() => handlePickIncident(item)}
|
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"
|
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]">
|
<div className="text-fg-disabled text-[10px]">
|
||||||
{item.acdntCd} · {item.regionNm}
|
{item.pollDate ? item.pollDate.replace('T', ' ') : '-'}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@ -1523,13 +1530,14 @@ export function RescueView() {
|
|||||||
const { activeSubTab } = useSubMenu('rescue');
|
const { activeSubTab } = useSubMenu('rescue');
|
||||||
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
const [activeType, setActiveType] = useState<AccidentType>('collision');
|
||||||
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
const [activeAnalysis, setActiveAnalysis] = useState<AnalysisTab>('rescue');
|
||||||
const [incidents, setIncidents] = useState<IncidentListItem[]>([]);
|
const [incidents, setIncidents] = useState<GscAccidentListItem[]>([]);
|
||||||
const [selectedAcdnt, setSelectedAcdnt] = useState<IncidentListItem | null>(null);
|
const [selectedAcdnt, setSelectedAcdnt] = useState<GscAccidentListItem | null>(null);
|
||||||
const [incidentCoord, setIncidentCoord] = useState<{ lon: number; lat: number } | 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);
|
const [isSelectingLocation, setIsSelectingLocation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchIncidentsRaw()
|
fetchGscAccidents()
|
||||||
.then((items) => setIncidents(items))
|
.then((items) => setIncidents(items))
|
||||||
.catch(() => setIncidents([]));
|
.catch(() => setIncidents([]));
|
||||||
}, []);
|
}, []);
|
||||||
@ -1540,24 +1548,17 @@ export function RescueView() {
|
|||||||
setIsSelectingLocation(false);
|
setIsSelectingLocation(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사고 선택 시 사고유형 자동 매핑
|
// 사고 선택 시 좌표 자동 반영 + 지도 이동
|
||||||
const handleSelectAcdnt = useCallback((item: IncidentListItem | null) => {
|
const handleSelectAcdnt = useCallback(
|
||||||
|
(item: GscAccidentListItem | null) => {
|
||||||
setSelectedAcdnt(item);
|
setSelectedAcdnt(item);
|
||||||
if (item) {
|
if (item && item.lat != null && item.lon != null) {
|
||||||
const typeMap: Record<string, AccidentType> = {
|
setIncidentCoord({ lon: item.lon, lat: item.lat });
|
||||||
collision: 'collision',
|
setFlyToCoord({ lon: item.lon, lat: item.lat });
|
||||||
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 (activeSubTab === 'list') {
|
if (activeSubTab === 'list') {
|
||||||
return (
|
return (
|
||||||
@ -1592,6 +1593,8 @@ export function RescueView() {
|
|||||||
<div className="flex-1 relative overflow-hidden">
|
<div className="flex-1 relative overflow-hidden">
|
||||||
<MapView
|
<MapView
|
||||||
incidentCoord={incidentCoord ?? undefined}
|
incidentCoord={incidentCoord ?? undefined}
|
||||||
|
flyToIncident={flyToCoord}
|
||||||
|
onIncidentFlyEnd={() => setFlyToCoord(undefined)}
|
||||||
isSelectingLocation={isSelectingLocation}
|
isSelectingLocation={isSelectingLocation}
|
||||||
onMapClick={handleMapClick}
|
onMapClick={handleMapClick}
|
||||||
oilTrajectory={[]}
|
oilTrajectory={[]}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user