Merge pull request 'feat: 방제선 보유자재 패널 추가, 방제장비 필터 개선, 보고서 민감자원 지도 개선' (#116) from feature/layer-data-table-mapping into develop
This commit is contained in:
커밋
e4cd57a56d
@ -16,11 +16,14 @@
|
|||||||
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||||
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||||
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||||
|
- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel)
|
||||||
|
- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가
|
||||||
|
|
||||||
### 변경
|
### 변경
|
||||||
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
||||||
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||||
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||||
|
- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능)
|
||||||
|
|
||||||
### 문서
|
### 문서
|
||||||
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import LayerPanel from './LayerPanel';
|
|||||||
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
import SensitiveLayerPanel from './SensitiveLayerPanel';
|
||||||
import DispersingZonePanel from './DispersingZonePanel';
|
import DispersingZonePanel from './DispersingZonePanel';
|
||||||
import MonitorRealtimePanel from './MonitorRealtimePanel';
|
import MonitorRealtimePanel from './MonitorRealtimePanel';
|
||||||
|
import VesselMaterialsPanel from './VesselMaterialsPanel';
|
||||||
|
|
||||||
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
/** 기존 패널이 있는 메뉴 ID 매핑 */
|
||||||
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||||
@ -33,6 +34,7 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
|||||||
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||||
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||||
'dispersant-zone': () => <DispersingZonePanel />,
|
'dispersant-zone': () => <DispersingZonePanel />,
|
||||||
|
'vessel-materials': () => <VesselMaterialsPanel />,
|
||||||
'monitor-realtime': () => <MonitorRealtimePanel />,
|
'monitor-realtime': () => <MonitorRealtimePanel />,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,7 @@ function CleanupEquipPanel() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [regionFilter, setRegionFilter] = useState('전체');
|
const [regionFilter, setRegionFilter] = useState('전체');
|
||||||
const [typeFilter, setTypeFilter] = useState('전체');
|
const [typeFilter, setTypeFilter] = useState('전체');
|
||||||
|
const [equipFilter, setEquipFilter] = useState('전체');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
const load = () => {
|
const load = () => {
|
||||||
@ -40,12 +41,21 @@ function CleanupEquipPanel() {
|
|||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [organizations]);
|
}, [organizations]);
|
||||||
|
|
||||||
|
const EQUIP_FIELDS: Record<string, keyof AssetOrgCompat> = {
|
||||||
|
'방제선': 'vessel',
|
||||||
|
'유회수기': 'skimmer',
|
||||||
|
'이송펌프': 'pump',
|
||||||
|
'방제차량': 'vehicle',
|
||||||
|
'살포장치': 'sprayer',
|
||||||
|
};
|
||||||
|
|
||||||
const filtered = useMemo(() =>
|
const filtered = useMemo(() =>
|
||||||
organizations
|
organizations
|
||||||
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
.filter(o => regionFilter === '전체' || o.jurisdiction.includes(regionFilter))
|
||||||
.filter(o => typeFilter === '전체' || o.type === typeFilter)
|
.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)),
|
.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));
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
@ -96,6 +106,18 @@ function CleanupEquipPanel() {
|
|||||||
<option key={t} value={t}>{t}</option>
|
<option key={t} value={t}>{t}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
value={equipFilter}
|
||||||
|
onChange={handleFilterChange(setEquipFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 장비</option>
|
||||||
|
<option value="방제선">방제선</option>
|
||||||
|
<option value="유회수기">유회수기</option>
|
||||||
|
<option value="이송펌프">이송펌프</option>
|
||||||
|
<option value="방제차량">방제차량</option>
|
||||||
|
<option value="살포장치">살포장치</option>
|
||||||
|
</select>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="기관명, 주소 검색..."
|
placeholder="기관명, 주소 검색..."
|
||||||
@ -122,16 +144,16 @@ function CleanupEquipPanel() {
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-border bg-bg-1">
|
<tr className="border-b border-border bg-bg-1">
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10">번호</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||||
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제선</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제선' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제선</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">유회수기</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '유회수기' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>유회수기</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">이송펌프</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '이송펌프' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>이송펌프</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">방제차량</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '방제차량' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>방제차량</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">살포장치</th>
|
<th className={`px-4 py-3 text-center text-[11px] font-semibold font-korean ${equipFilter === '살포장치' ? 'text-primary-cyan bg-primary-cyan/5' : 'text-text-3'}`}>살포장치</th>
|
||||||
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -161,20 +183,20 @@ function CleanupEquipPanel() {
|
|||||||
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||||
{org.address}
|
{org.address}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제선' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3">—</span>}
|
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '유회수기' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.skimmer > 0 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3">—</span>}
|
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '이송펌프' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.pump > 0 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3">—</span>}
|
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '방제차량' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.vehicle > 0 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3">—</span>}
|
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
<td className={`px-4 py-3 text-[11px] font-mono text-center ${equipFilter === '살포장치' ? 'text-primary-cyan font-semibold bg-primary-cyan/5' : 'text-text-2'}`}>
|
||||||
{org.sprayer > 0 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3">—</span>}
|
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||||
{org.totalAssets.toLocaleString()}
|
{org.totalAssets.toLocaleString()}
|
||||||
@ -186,6 +208,33 @@ function CleanupEquipPanel() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||||
|
합계 ({filtered.length}개 기관)
|
||||||
|
</span>
|
||||||
|
{[
|
||||||
|
{ 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 (
|
||||||
|
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${isActive ? 'bg-primary-cyan/10' : ''}`}>
|
||||||
|
<span className={`text-[9px] font-korean ${isActive ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||||
|
<span className={`text-[10px] font-mono font-bold ${isActive ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||||
|
{t.value.toLocaleString()}{t.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
{/* 페이지네이션 */}
|
||||||
{!loading && filtered.length > 0 && (
|
{!loading && filtered.length > 0 && (
|
||||||
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||||
|
|||||||
255
frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx
Normal file
255
frontend/src/tabs/admin/components/VesselMaterialsPanel.tsx
Normal file
@ -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<AssetOrgCompat[]>([]);
|
||||||
|
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<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-text-1 font-korean">방제선 보유자재 현황</h1>
|
||||||
|
<p className="text-xs text-text-3 mt-1 font-korean">총 {filtered.length}개 기관 (방제선 보유)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={regionFilter}
|
||||||
|
onChange={handleFilterChange(setRegionFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 관할청</option>
|
||||||
|
<option value="남해">남해청</option>
|
||||||
|
<option value="서해">서해청</option>
|
||||||
|
<option value="중부">중부청</option>
|
||||||
|
<option value="동해">동해청</option>
|
||||||
|
<option value="제주">제주청</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={handleFilterChange(setTypeFilter)}
|
||||||
|
className="px-3 py-2 text-xs bg-bg-2 border border-border rounded-md text-text-1 focus:border-primary-cyan focus:outline-none font-korean"
|
||||||
|
>
|
||||||
|
<option value="전체">전체 유형</option>
|
||||||
|
{typeOptions.map(t => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="기관명, 주소 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => { 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={load}
|
||||||
|
className="px-4 py-2 text-xs font-semibold rounded-md bg-bg-2 border border-border text-text-2 hover:border-primary-cyan hover:text-primary-cyan transition-all font-korean"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-text-3 text-sm font-korean">
|
||||||
|
불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border bg-bg-1">
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean w-10 whitespace-nowrap">번호</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">유형</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">관할청</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">기관명</th>
|
||||||
|
<th className="px-4 py-3 text-left text-[11px] font-semibold text-text-3 font-korean">주소</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-primary-cyan bg-primary-cyan/5">방제선</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">유회수기</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">이송펌프</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">방제차량</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold font-korean text-text-3">살포장치</th>
|
||||||
|
<th className="px-4 py-3 text-center text-[11px] font-semibold text-text-3 font-korean">총자산</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="px-6 py-10 text-center text-xs text-text-3 font-korean">
|
||||||
|
조회된 기관이 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paged.map((org, idx) => (
|
||||||
|
<tr key={org.id} className="border-b border-border hover:bg-[rgba(255,255,255,0.02)] transition-colors">
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-mono text-center">
|
||||||
|
{(safePage - 1) * PAGE_SIZE + idx + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-bold font-korean ${typeTagCls(org.type)}`}>
|
||||||
|
{org.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-2 font-korean">
|
||||||
|
{regionShort(org.jurisdiction)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-1 font-korean font-semibold">
|
||||||
|
{org.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] text-text-3 font-korean max-w-[200px] truncate">
|
||||||
|
{org.address}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-primary-cyan font-semibold bg-primary-cyan/5">
|
||||||
|
{org.vessel > 0 ? org.vessel : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.skimmer > 0 ? org.skimmer : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.pump > 0 ? org.pump : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.vehicle > 0 ? org.vehicle : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center text-text-2">
|
||||||
|
{org.sprayer > 0 ? org.sprayer : <span className="text-text-3">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-[11px] font-mono text-center font-bold text-primary-cyan">
|
||||||
|
{org.totalAssets.toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 합계 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-2 border-t border-border bg-bg-0/80">
|
||||||
|
<span className="text-[10px] text-text-3 font-korean font-semibold mr-auto">
|
||||||
|
합계 ({filtered.length}개 기관)
|
||||||
|
</span>
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<div key={t.label} className={`flex items-center gap-1 px-1.5 py-0.5 rounded ${t.active ? 'bg-primary-cyan/10' : ''}`}>
|
||||||
|
<span className={`text-[9px] font-korean ${t.active ? 'text-primary-cyan' : 'text-text-3'}`}>{t.label}</span>
|
||||||
|
<span className={`text-[10px] font-mono font-bold ${t.active ? 'text-primary-cyan' : 'text-text-1'}`}>
|
||||||
|
{t.value.toLocaleString()}{t.unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{!loading && filtered.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between px-6 py-3 border-t border-border">
|
||||||
|
<span className="text-[11px] text-text-3 font-korean">
|
||||||
|
{(safePage - 1) * PAGE_SIZE + 1}–{Math.min(safePage * PAGE_SIZE, filtered.length)} / 전체 {filtered.length}개
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={safePage === 1}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
{pageNumbers.map(p => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setCurrentPage(p)}
|
||||||
|
className="px-2.5 py-1 text-[11px] border rounded transition-colors"
|
||||||
|
style={p === safePage
|
||||||
|
? { borderColor: 'var(--cyan)', color: 'var(--cyan)', background: 'rgba(6,182,212,0.1)' }
|
||||||
|
: { borderColor: 'var(--border)', color: 'var(--text-2)' }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={safePage === totalPages}
|
||||||
|
className="px-2.5 py-1 text-[11px] border border-border rounded text-text-2 hover:border-primary-cyan hover:text-primary-cyan disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VesselMaterialsPanel;
|
||||||
@ -276,14 +276,18 @@ function Page2({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
|
|
||||||
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
<div style={S.sectionTitle}>3. 유출유 확산예측</div>
|
||||||
<div className="flex gap-4 mb-4">
|
<div className="flex gap-4 mb-4">
|
||||||
{data.step3MapImage
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
{data.step3MapImage
|
||||||
: <div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', height: 'auto', display: 'block', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||||||
}
|
: <div style={S.mapPlaceholder}>확산예측 3시간 지도</div>
|
||||||
{data.step6MapImage
|
}
|
||||||
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
</div>
|
||||||
: <div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
}
|
{data.step6MapImage
|
||||||
|
? <img src={data.step6MapImage} alt="확산예측 6시간 지도" style={{ width: '100%', height: 'auto', display: 'block', borderRadius: '4px', border: '1px solid var(--bd)' }} />
|
||||||
|
: <div style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={S.subHeader}>시간별 상세정보</div>
|
<div style={S.subHeader}>시간별 상세정보</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
@ -433,9 +437,18 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi
|
|||||||
// aquaculture (어장)
|
// aquaculture (어장)
|
||||||
const aquacultureRows = geojson.features
|
const aquacultureRows = geojson.features
|
||||||
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
|
.filter(f => ((f.properties as { category?: string })?.category ?? '').includes('어장'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aNo = String((a.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||||||
|
const bNo = String((b.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||||||
|
return aNo.localeCompare(bNo, 'ko', { numeric: true });
|
||||||
|
})
|
||||||
.map(f => {
|
.map(f => {
|
||||||
const p = f.properties as Record<string, unknown>
|
const p = f.properties as Record<string, unknown>
|
||||||
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 (해수욕장)
|
// beaches (해수욕장)
|
||||||
@ -643,7 +656,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi
|
|||||||
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
if (data.sensitiveMapImage) {
|
if (data.sensitiveMapImage) {
|
||||||
return <img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
return <img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
}
|
}
|
||||||
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
return <div style={S.mapPlaceholder}>민감자원 분포(10km 내) 지도</div>
|
||||||
}
|
}
|
||||||
@ -657,7 +670,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi
|
|||||||
if (data.sensitiveMapImage) {
|
if (data.sensitiveMapImage) {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
<img src={data.sensitiveMapImage} alt="민감자원 분포 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
@ -727,7 +740,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
|||||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||||
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||||||
|
|
||||||
<div style={S.subHeader}>양식장 분포</div>
|
<div style={{ ...S.subHeader, marginTop: '16px' }}>양식장 분포</div>
|
||||||
<table style={S.table}>
|
<table style={S.table}>
|
||||||
<thead><tr><th style={S.th}>구분</th><th style={S.th}>면적(ha)</th><th style={S.th}>사고지점과의 거리(km)</th></tr></thead>
|
<thead><tr><th style={S.th}>구분</th><th style={S.th}>면적(ha)</th><th style={S.th}>사고지점과의 거리(km)</th></tr></thead>
|
||||||
<tbody>{data.aquaculture.map((a, i) => (
|
<tbody>{data.aquaculture.map((a, i) => (
|
||||||
@ -936,7 +949,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo
|
|||||||
|
|
||||||
if (!editing) {
|
if (!editing) {
|
||||||
if (data.sensitivityMapImage) {
|
if (data.sensitivityMapImage) {
|
||||||
return <img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
|
return <img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4, marginBottom: 16 }} />
|
||||||
}
|
}
|
||||||
return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
return <div style={S.mapPlaceholder}>통합민감도 평가 지도</div>
|
||||||
}
|
}
|
||||||
@ -946,7 +959,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo
|
|||||||
if (data.sensitivityMapImage) {
|
if (data.sensitivityMapImage) {
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
<img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', maxHeight: 440, objectFit: 'contain', border: '1px solid #ddd', borderRadius: 4 }} />
|
<img src={data.sensitivityMapImage} alt="통합민감도 평가 지도" style={{ width: '100%', height: 'auto', display: 'block', border: '1px solid #ddd', borderRadius: 4 }} />
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
style={{ position: 'absolute', top: 8, right: 8, background: 'rgba(0,0,0,0.6)', color: '#fff', border: 'none', borderRadius: 4, padding: '3px 8px', fontSize: 11, cursor: 'pointer' }}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user