Compare commits
3 커밋
37397d4d6c
...
e4cd57a56d
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| e4cd57a56d | |||
| 827061b17c | |||
| 932c8eca3f |
@ -16,11 +16,14 @@
|
||||
- 보고서: 유류유출 보고서 템플릿 전면 개선 (OilSpillReportTemplate)
|
||||
- 관리자: 실시간 기상·해상 모니터링 패널 추가 (MonitorRealtimePanel)
|
||||
- DB: 민감자원 평가 마이그레이션 추가 (027_sensitivity_evaluation)
|
||||
- 관리자: 방제선 보유자재 현황 패널 추가 (VesselMaterialsPanel)
|
||||
- 관리자: 방제장비 현황 패널에 장비 타입 필터 및 조건부 컬럼 강조 스타일 추가
|
||||
|
||||
### 변경
|
||||
- SCAT 지도 하드코딩 제주 해안선 제거, 인접 구간 기반 동적 방향 계산으로 전환
|
||||
- 예측: 분석 API를 예측 서비스로 통합 (analysisRouter 제거)
|
||||
- 예측: 예측 API 확장 (predictionRouter/Service, LeftPanel/RightPanel 연동)
|
||||
- 보고서: 유류유출 보고서 민감자원 지도 섹션 개선 (GeoJSON 자동 필터링, 6개 테이블 자동 채우기, 지도 캡처 기능)
|
||||
|
||||
### 문서
|
||||
- Foundation 탭 디자인 토큰 상세 문서화 (DESIGN-SYSTEM.md)
|
||||
|
||||
@ -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<string, () => JSX.Element> = {
|
||||
@ -33,6 +34,7 @@ const PANEL_MAP: Record<string, () => JSX.Element> = {
|
||||
'env-ecology': () => <SensitiveLayerPanel categoryCode="LYR001002001" title="환경/생태" />,
|
||||
'social-economy': () => <SensitiveLayerPanel categoryCode="LYR001002002" title="사회/경제" />,
|
||||
'dispersant-zone': () => <DispersingZonePanel />,
|
||||
'vessel-materials': () => <VesselMaterialsPanel />,
|
||||
'monitor-realtime': () => <MonitorRealtimePanel />,
|
||||
};
|
||||
|
||||
|
||||
@ -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<string, keyof AssetOrgCompat> = {
|
||||
'방제선': '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() {
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</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
|
||||
type="text"
|
||||
placeholder="기관명, 주소 검색..."
|
||||
@ -122,16 +144,16 @@ function CleanupEquipPanel() {
|
||||
<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">번호</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-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>
|
||||
<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>
|
||||
<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 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 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 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 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>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -161,20 +183,20 @@ function CleanupEquipPanel() {
|
||||
<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-text-2">
|
||||
{org.vessel > 0 ? <span className="text-text-1">{org.vessel}</span> : <span className="text-text-3">—</span>}
|
||||
<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 ? 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 ? <span className="text-text-1">{org.skimmer}</span> : <span className="text-text-3">—</span>}
|
||||
<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 ? 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 ? <span className="text-text-1">{org.pump}</span> : <span className="text-text-3">—</span>}
|
||||
<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 ? 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 ? <span className="text-text-1">{org.vehicle}</span> : <span className="text-text-3">—</span>}
|
||||
<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 ? 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 ? <span className="text-text-1">{org.sprayer}</span> : <span className="text-text-3">—</span>}
|
||||
<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 ? 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()}
|
||||
@ -186,6 +208,33 @@ function CleanupEquipPanel() {
|
||||
)}
|
||||
</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 && (
|
||||
<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 className="flex gap-4 mb-4">
|
||||
{data.step3MapImage
|
||||
? <img src={data.step3MapImage} alt="확산예측 3시간 지도" style={{ width: '100%', maxHeight: '240px', objectFit: 'contain', 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 style={S.mapPlaceholder}>확산예측 6시간 지도</div>
|
||||
}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
{data.step3MapImage
|
||||
? <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>
|
||||
}
|
||||
</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 style={S.subHeader}>시간별 상세정보</div>
|
||||
<table style={S.table}>
|
||||
@ -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<string, unknown>)['lcns_no'] ?? '');
|
||||
const bNo = String((b.properties as Record<string, unknown>)['lcns_no'] ?? '');
|
||||
return aNo.localeCompare(bNo, 'ko', { numeric: true });
|
||||
})
|
||||
.map(f => {
|
||||
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 (해수욕장)
|
||||
@ -643,7 +656,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi
|
||||
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
|
||||
if (!editing) {
|
||||
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>
|
||||
}
|
||||
@ -657,7 +670,7 @@ function SensitiveResourceMapSection({ data, editing, onChange }: { data: OilSpi
|
||||
if (data.sensitiveMapImage) {
|
||||
return (
|
||||
<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
|
||||
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' }}
|
||||
@ -727,7 +740,7 @@ function Page4({ data, editing, onChange }: { data: OilSpillReportData; editing:
|
||||
<div style={S.sectionTitle}>4. 민감자원 및 민감도 평가</div>
|
||||
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
|
||||
|
||||
<div style={S.subHeader}>양식장 분포</div>
|
||||
<div style={{ ...S.subHeader, marginTop: '16px' }}>양식장 분포</div>
|
||||
<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>
|
||||
<tbody>{data.aquaculture.map((a, i) => (
|
||||
@ -936,7 +949,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo
|
||||
|
||||
if (!editing) {
|
||||
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>
|
||||
}
|
||||
@ -946,7 +959,7 @@ function SensitivityMapSection({ data, editing, onChange }: { data: OilSpillRepo
|
||||
if (data.sensitivityMapImage) {
|
||||
return (
|
||||
<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
|
||||
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' }}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user