feat(관리자): 방제선 보유자재 패널 추가 + 방제장비 필터 개선 + 보고서 민감자원 지도 개선

This commit is contained in:
jeonghyo.k 2026-03-24 18:43:40 +09:00
부모 d4b3bbdc99
커밋 932c8eca3f
4개의 변경된 파일350개의 추가작업 그리고 31개의 파일을 삭제

파일 보기

@ -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">

파일 보기

@ -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"
>
&lt;
</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"
>
&gt;
</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' }}