diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 95a6138..661428e 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -12,10 +12,13 @@ - 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate) - 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel) - DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation) +- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel) +- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가 ### 변경 - 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거) - 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동) +- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능) ## [2026-03-20.3] diff --git a/frontend/src/tabs/admin/components/AdminView.tsx b/frontend/src/tabs/admin/components/AdminView.tsx index be9e70b..f39a80b 100755 --- a/frontend/src/tabs/admin/components/AdminView.tsx +++ b/frontend/src/tabs/admin/components/AdminView.tsx @@ -15,6 +15,7 @@ import LayerPanel from './LayerPanel'; import SensitiveLayerPanel from './SensitiveLayerPanel'; import DispersingZonePanel from './DispersingZonePanel'; import MonitorRealtimePanel from './MonitorRealtimePanel'; +import VesselMaterialsPanel from './VesselMaterialsPanel'; /** 기존 패널이 있는 메뉴 ID 매핑 */ const PANEL_MAP: Record JSX.Element> = { @@ -33,6 +34,7 @@ const PANEL_MAP: Record JSX.Element> = { 'env-ecology': () => , 'social-economy': () => , 'dispersant-zone': () => , + 'vessel-materials': () => , 'monitor-realtime': () => , }; diff --git a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx index 4708041..adce38e 100644 --- a/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx +++ b/frontend/src/tabs/admin/components/CleanupEquipPanel.tsx @@ -16,6 +16,7 @@ function CleanupEquipPanel() { const [searchTerm, setSearchTerm] = useState(''); const [regionFilter, setRegionFilter] = useState('전체'); const [typeFilter, setTypeFilter] = useState('전체'); + const [equipFilter, setEquipFilter] = useState('전체'); const [currentPage, setCurrentPage] = useState(1); const load = () => { @@ -40,12 +41,21 @@ function CleanupEquipPanel() { return Array.from(set).sort(); }, [organizations]); + const EQUIP_FIELDS: Record = { + '방제선': 'vessel', + '유회수기': 'skimmer', + '이송펌프': 'pump', + '방제차량': 'vehicle', + '살포장치': 'sprayer', + }; + const filtered = useMemo(() => organizations .filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter)) .filter(o => typeFilter === '전체' || o.type === typeFilter) + .filter(o => equipFilter === '전체' || (o[EQUIP_FIELDS[equipFilter]] as number) > 0) .filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)), - [organizations, regionFilter, typeFilter, searchTerm] + [organizations, regionFilter, typeFilter, equipFilter, searchTerm] ); const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); @@ -96,6 +106,18 @@ function CleanupEquipPanel() { ))} + - 번호 + 번호 유형 관할청 기관명 주소 - 방제선 - 유회수기 - 이송펌프 - 방제차량 - 살포장치 + 방제선 + 유회수기 + 이송펌프 + 방제차량 + 살포장치 총자산 @@ -161,20 +183,20 @@ function CleanupEquipPanel() { {org.address} - - {org.vessel > 0 ? {org.vessel} : } + + {org.vessel > 0 ? org.vessel : } - - {org.skimmer > 0 ? {org.skimmer} : } + + {org.skimmer > 0 ? org.skimmer : } - - {org.pump > 0 ? {org.pump} : } + + {org.pump > 0 ? org.pump : } - - {org.vehicle > 0 ? {org.vehicle} : } + + {org.vehicle > 0 ? org.vehicle : } - - {org.sprayer > 0 ? {org.sprayer} : } + + {org.sprayer > 0 ? org.sprayer : } {org.totalAssets.toLocaleString()} @@ -186,6 +208,33 @@ function CleanupEquipPanel() { )} + {/* 합계 */} + {!loading && filtered.length > 0 && ( +
+ + 합계 ({filtered.length}개 기관) + + {[ + { label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척' }, + { label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대' }, + { label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대' }, + { label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대' }, + { label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대' }, + { label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '' }, + ].map((t) => { + const isActive = t.label === equipFilter || t.label === '총자산'; + return ( +
+ {t.label} + + {t.value.toLocaleString()}{t.unit} + +
+ ); + })} +
+ )} + {/* 페이지네이션 */} {!loading && filtered.length > 0 && (
diff --git a/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx b/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx new file mode 100644 index 0000000..f9fb9dd --- /dev/null +++ b/frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx @@ -0,0 +1,255 @@ +import { useState, useEffect, useMemo } from 'react'; +import { fetchOrganizations } from '@tabs/assets/services/assetsApi'; +import type { AssetOrgCompat } from '@tabs/assets/services/assetsApi'; +import { typeTagCls } from '@tabs/assets/components/assetTypes'; + +const PAGE_SIZE = 20; + +const regionShort = (j: string) => + j.includes('남해') ? '남해청' : j.includes('서해') ? '서해청' : + j.includes('중부') ? '중부청' : j.includes('동해') ? '동해청' : + j.includes('제주') ? '제주청' : j; + +function VesselMaterialsPanel() { + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + const [searchTerm, setSearchTerm] = useState(''); + const [regionFilter, setRegionFilter] = useState('전체'); + const [typeFilter, setTypeFilter] = useState('전체'); + const [currentPage, setCurrentPage] = useState(1); + + const load = () => { + setLoading(true); + fetchOrganizations() + .then(setOrganizations) + .catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err)) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + let cancelled = false; + fetchOrganizations() + .then(data => { if (!cancelled) setOrganizations(data); }) + .catch(err => console.error('[VesselMaterialsPanel] 데이터 로드 실패:', err)) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); + + const typeOptions = useMemo(() => { + const set = new Set(organizations.map(o => o.type)); + return Array.from(set).sort(); + }, [organizations]); + + const filtered = useMemo(() => + organizations + .filter(o => o.vessel > 0) + .filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter)) + .filter(o => typeFilter === '전체' || o.type === typeFilter) + .filter(o => !searchTerm || o.name.includes(searchTerm) || o.address.includes(searchTerm)), + [organizations, regionFilter, typeFilter, searchTerm] + ); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(currentPage, totalPages); + const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE); + + const handleFilterChange = (setter: (v: string) => void) => (e: React.ChangeEvent) => { + setter(e.target.value); + setCurrentPage(1); + }; + + const pageNumbers = (() => { + const range: number[] = []; + const start = Math.max(1, safePage - 2); + const end = Math.min(totalPages, safePage + 2); + for (let i = start; i <= end; i++) range.push(i); + return range; + })(); + + return ( +
+ {/* 헤더 */} +
+
+

방제선 보유자재 현황

+

총 {filtered.length}개 기관 (방제선 보유)

+
+
+ + + { setSearchTerm(e.target.value); setCurrentPage(1); }} + className="w-56 px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 placeholder-text-3 focus:border-primary-cyan focus:outline-none font-korean" + /> + +
+
+ + {/* 테이블 */} +
+ {loading ? ( +
+ 불러오는 중... +
+ ) : ( + + + + + + + + + + + + + + + + + + {paged.length === 0 ? ( + + + + ) : paged.map((org, idx) => ( + + + + + + + + + + + + + + ))} + +
번호유형관할청기관명주소방제선유회수기이송펌프방제차량살포장치총자산
+ 조회된 기관이 없습니다. +
+ {(safePage - 1) * PAGE_SIZE + idx + 1} + + + {org.type} + + + {regionShort(org.jurisdiction)} + + {org.name} + + {org.address} + + {org.vessel > 0 ? org.vessel : } + + {org.skimmer > 0 ? org.skimmer : } + + {org.pump > 0 ? org.pump : } + + {org.vehicle > 0 ? org.vehicle : } + + {org.sprayer > 0 ? org.sprayer : } + + {org.totalAssets.toLocaleString()} +
+ )} +
+ + {/* 합계 */} + {!loading && filtered.length > 0 && ( +
+ + 합계 ({filtered.length}개 기관) + + {[ + { label: '방제선', value: filtered.reduce((s, o) => s + o.vessel, 0), unit: '척', active: true }, + { label: '유회수기', value: filtered.reduce((s, o) => s + o.skimmer, 0), unit: '대', active: false }, + { label: '이송펌프', value: filtered.reduce((s, o) => s + o.pump, 0), unit: '대', active: false }, + { label: '방제차량', value: filtered.reduce((s, o) => s + o.vehicle, 0), unit: '대', active: false }, + { label: '살포장치', value: filtered.reduce((s, o) => s + o.sprayer, 0), unit: '대', active: false }, + { label: '총자산', value: filtered.reduce((s, o) => s + o.totalAssets, 0), unit: '', active: true }, + ].map((t) => ( +
+ {t.label} + + {t.value.toLocaleString()}{t.unit} + +
+ ))} +
+ )} + + {/* 페이지네이션 */} + {!loading && filtered.length > 0 && ( +
+ + {(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개 + +
+ + {pageNumbers.map(p => ( + + ))} + +
+
+ )} +
+ ); +} + +export default VesselMaterialsPanel; diff --git a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx index fe73419..3f25e03 100755 --- a/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx +++ b/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx @@ -276,14 +276,18 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
3. 유출유 확산예측
- {data.step3MapImage - ? 확산예측 3시간 지도 - :
확산예측 3시간 지도
- } - {data.step6MapImage - ? 확산예측 6시간 지도 - :
확산예측 6시간 지도
- } +
+ {data.step3MapImage + ? 확산예측 3시간 지도 + :
확산예측 3시간 지도
+ } +
+
+ {data.step6MapImage + ? 확산예측 6시간 지도 + :
확산예측 6시간 지도
+ } +
시간별 상세정보
@@ -433,9 +437,18 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi // aquaculture (어장) const aquacultureRows = geojson.features .filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장')) + .sort((a, b) => { + const aNo = String((a.properties as Record)['lcns_no'] ?? ''); + const bNo = String((b.properties as Record)['lcns_no'] ?? ''); + return aNo.localeCompare(bNo, 'ko', { numeric: true }); + }) .map(f => { const p = f.properties as Record - return { type: String(p['fids_knd'] ?? ''), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } + const lcnsNo = String(p['lcns_no'] ?? ''); + const fidsKnd = String(p['fids_knd'] ?? ''); + const farmKnd = String(p['farm_knd'] ?? ''); + const parts = [lcnsNo, fidsKnd, farmKnd].filter(Boolean); + return { type: parts.join('_'), area: p['area'] != null ? Number(p['area']).toFixed(2) : '', distance: calcDist(f.geometry as { type: string; coordinates: unknown }) } }) // beaches (해수욕장) @@ -643,7 +656,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi // 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더 if (!editing) { if (data.sensitiveMapImage) { - return 민감자원 분포 지도 + return 민감자원 분포 지도 } return
민감자원 분포(10km 내) 지도
} @@ -657,7 +670,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi if (data.sensitiveMapImage) { return (
- 민감자원 분포 지도 + 민감자원 분포 지도
-
양식장 분포
+
양식장 분포
{data.aquaculture.map((a, i) => ( @@ -936,7 +949,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo if (!editing) { if (data.sensitivityMapImage) { - return 통합민감도 평가 지도 + return 통합민감도 평가 지도 } return
통합민감도 평가 지도
} @@ -946,7 +959,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo if (data.sensitivityMapImage) { return (
- 통합민감도 평가 지도 + 통합민감도 평가 지도
구분면적(ha)사고지점과의 거리(km)