wing-ops/frontend/src/tabs/reports/components/OilSpillReportTemplate.tsx

2742 lines
87 KiB
TypeScript
Executable File
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useCallback, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { saveReport } from '../services/reportsApi';
import {
fetchSensitiveResourcesGeojson,
fetchPredictionParticlesGeojson,
fetchSensitivityEvaluationGeojson,
} from '@tabs/prediction/services/predictionApi';
// ─── Data Types ─────────────────────────────────────────────
export type ReportType = '초기보고서' | '지휘부 보고' | '예측보고서' | '종합보고서' | '유출유 보고';
export type ReportStatus = '완료' | '수행중' | '테스트';
export type Jurisdiction = '남해청' | '서해청' | '중부청' | '동해청' | '제주청';
export type AnalysisCategory = '유출유 확산예측' | 'HNS 대기확산' | '긴급구난' | '';
export interface OilSpillReportData {
id: string;
title: string;
createdAt: string;
updatedAt: string;
author: string;
reportType: ReportType;
analysisCategory: AnalysisCategory;
jurisdiction: Jurisdiction;
status: ReportStatus;
incident: {
name: string;
writeTime: string;
shipName: string;
agent: string;
location: string;
lat: string;
lon: string;
occurTime: string;
accidentType: string;
pollutant: string;
spillAmount: string;
depth: string;
seabed: string;
};
tide: {
date: string;
tideType: string;
lowTide1: string;
highTide1: string;
lowTide2: string;
highTide2: string;
}[];
weather: {
time: string;
sunrise: string;
sunset: string;
windDir: string;
windSpeed: string;
currentDir: string;
currentSpeed: string;
waveHeight: string;
}[];
spread: {
elapsed: string;
weathered: string;
seaRemain: string;
coastAttach: string;
area: string;
}[];
analysis: string;
aquaculture: { type: string; area: string; distance: string }[];
beaches: { name: string; distance: string }[];
markets: { name: string; distance: string }[];
esi: { code: string; type: string; length: string }[];
species: { category: string; species: string }[];
habitat: { type: string; area: string }[];
sensitivity: { level: string; area: string; color: string }[];
vessels: {
name: string;
org: string;
dist: string;
speed: string;
ton: string;
collectorType: string;
collectorCap: string;
boomType: string;
boomLength: string;
}[];
etcEquipment: string;
recovery: { shipName: string; period: string }[];
result: {
spillTotal: string;
weatheredTotal: string;
recoveredTotal: string;
seaRemainTotal: string;
coastAttachTotal: string;
};
capturedMapImage?: string;
step3MapImage?: string;
step6MapImage?: string;
hasMapCapture?: boolean;
acdntSn?: number;
sensitiveMapImage?: string;
sensitivityMapImage?: string;
}
// eslint-disable-next-line react-refresh/only-export-components
export function createEmptyReport(jurisdiction?: Jurisdiction): OilSpillReportData {
const now = new Date();
const ts = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
return {
id: `RPT-${Date.now()}`,
title: '',
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
author: '',
reportType: '예측보고서',
analysisCategory: '',
jurisdiction: jurisdiction || '',
status: '수행중',
incident: {
name: '',
writeTime: ts,
shipName: '',
agent: '',
location: '',
lat: '',
lon: '',
occurTime: '',
accidentType: '',
pollutant: '',
spillAmount: '',
depth: '',
seabed: '',
},
tide: [{ date: '', tideType: '', lowTide1: '', highTide1: '', lowTide2: '', highTide2: '' }],
weather: [
{
time: '',
sunrise: '',
sunset: '',
windDir: '',
windSpeed: '',
currentDir: '',
currentSpeed: '',
waveHeight: '',
},
],
spread: [
{ elapsed: '3시간', weathered: '', seaRemain: '', coastAttach: '', area: '' },
{ elapsed: '6시간', weathered: '', seaRemain: '', coastAttach: '', area: '' },
],
analysis: '',
aquaculture: [{ type: '', area: '', distance: '' }],
beaches: [{ name: '', distance: '' }],
markets: [{ name: '', distance: '' }],
esi: [
{ code: 'ESI 1', type: '수직암반', length: '' },
{ code: 'ESI 2', type: '수평암반', length: '' },
{ code: 'ESI 3', type: '세립질 모래', length: '' },
{ code: 'ESI 4', type: '조립질 모래', length: '' },
{ code: 'ESI 5', type: '모래·자갈', length: '' },
{ code: 'ESI 6A', type: '자갈', length: '' },
{ code: 'ESI 6B', type: '투과성 사석', length: '' },
{ code: 'ESI 7', type: '반폐쇄성 해안', length: '' },
{ code: 'ESI 8A', type: '갯벌', length: '' },
{ code: 'ESI 8B', type: '염습지', length: '' },
],
species: [
{ category: '양서파충류', species: '' },
{ category: '조류', species: '' },
{ category: '포유류', species: '' },
],
habitat: [{ type: '갯벌', area: '' }],
sensitivity: [
{ level: '매우 높음', area: '', color: '#ef4444' },
{ level: '높음', area: '', color: '#f97316' },
{ level: '보통', area: '', color: '#eab308' },
{ level: '낮음', area: '', color: '#22c55e' },
],
vessels: [
{
name: '',
org: '',
dist: '',
speed: '',
ton: '',
collectorType: '',
collectorCap: '',
boomType: '',
boomLength: '',
},
],
etcEquipment: '',
recovery: [{ shipName: '', period: '' }],
result: {
spillTotal: '',
weatheredTotal: '',
recoveredTotal: '',
seaRemainTotal: '',
coastAttachTotal: '',
},
};
}
// eslint-disable-next-line react-refresh/only-export-components
export function createSampleReport(): OilSpillReportData {
return {
id: `RPT-${Date.now()}`,
title: '여수 수변공원 Diesel 유출사고 대응지원',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
author: '남해청_방제과',
reportType: '초기보고서',
analysisCategory: '유출유 확산예측',
jurisdiction: '남해청',
status: '완료',
incident: {
name: '여수 수변공원 유출사고',
writeTime: '2025.07.16 13:48',
shipName: '수변공원',
agent: '',
location: '여수 돌산 남방',
lat: '34° 43 37.6″',
lon: '127° 43 32.6″',
occurTime: '2025. 07. 08. 17:55',
accidentType: '시설 파손',
pollutant: 'Diesel',
spillAmount: '1㎘',
depth: '12m',
seabed: '모래',
},
tide: [
{
date: '2025-07-08 20:55',
tideType: '배꼽사리',
lowTide1: '01:53 (146)',
highTide1: '13:19 (104)',
lowTide2: '07:17 (253)',
highTide2: '20:24 (310)',
},
{
date: '2025-07-08 23:55',
tideType: '배꼽사리',
lowTide1: '01:53 (146)',
highTide1: '13:19 (104)',
lowTide2: '07:17 (253)',
highTide2: '20:24 (310)',
},
],
weather: [
{
time: '2025-07-08 20:55',
sunrise: '05:22',
sunset: '19:46',
windDir: '북동',
windSpeed: '0.0',
currentDir: '북',
currentSpeed: '0.0 / 0.0',
waveHeight: '0.0',
},
{
time: '2025-07-08 23:55',
sunrise: '05:22',
sunset: '19:46',
windDir: '북동',
windSpeed: '0.0',
currentDir: '북',
currentSpeed: '0.0 / 0.0',
waveHeight: '0.0',
},
],
spread: [
{ elapsed: '3시간', weathered: '0.48', seaRemain: '0.52', coastAttach: '0.12', area: '1.75' },
{ elapsed: '6시간', weathered: '0.64', seaRemain: '0.36', coastAttach: '0.24', area: '3.49' },
],
analysis: '',
aquaculture: [
{ type: '', area: '3.19', distance: '0.56' },
{ type: '', area: '20.15', distance: '0.83' },
{ type: '어류', area: '3.84', distance: '1.14' },
{ type: '', area: '2.89', distance: '1.40' },
{ type: '어류', area: '0.90', distance: '1.42' },
],
beaches: [
{ name: '검은모래해수욕장', distance: '5.86' },
{ name: '무슬목해수욕장', distance: '6.69' },
{ name: '모사금해수욕장', distance: '8.18' },
],
markets: [{ name: '돌산대교 회타운', distance: '1.09' }],
esi: [
{ code: 'ESI 1', type: '수직암반', length: '29.05 km' },
{ code: 'ESI 2', type: '수평암반', length: '13.76 km' },
{ code: 'ESI 3', type: '세립질 모래', length: '1.03 km' },
{ code: 'ESI 4', type: '조립질 모래', length: '0.47 km' },
{ code: 'ESI 5', type: '모래·자갈', length: '21.10 km' },
{ code: 'ESI 6A', type: '자갈', length: '15.29 km' },
{ code: 'ESI 6B', type: '투과성 사석', length: '26.60 km' },
{ code: 'ESI 7', type: '반폐쇄성 해안', length: '9.39 km' },
{ code: 'ESI 8A', type: '갯벌', length: '0.00 km' },
{ code: 'ESI 8B', type: '염습지', length: '3.43 km' },
],
species: [
{ category: '양서파충류', species: '' },
{ category: '조류', species: '뿔논병아리(겨울철새), 꾀꼬리(여름철새)' },
{ category: '포유류', species: '수달' },
],
habitat: [{ type: '갯벌', area: '4.32 km²' }],
sensitivity: [
{ level: '매우 높음', area: '18.54', color: '#ef4444' },
{ level: '높음', area: '31.32', color: '#f97316' },
{ level: '보통', area: '59.22', color: '#eab308' },
{ level: '낮음', area: '32.13', color: '#22c55e' },
],
vessels: [
{
name: '방제15호',
org: '서해청',
dist: '0.41',
speed: '7.0',
ton: '450.0',
collectorType: '흡착',
collectorCap: '140.0',
boomType: 'B',
boomLength: '60',
},
{
name: '방제26호',
org: '서해청',
dist: '0.41',
speed: '13.0',
ton: '360.0',
collectorType: '흡착',
collectorCap: '100.0',
boomType: 'B',
boomLength: '300',
},
{
name: '방제1001호',
org: '서해청',
dist: '0.74',
speed: '10.0',
ton: '564.0',
collectorType: '흡착',
collectorCap: '30.0',
boomType: 'B',
boomLength: '20',
},
{
name: '전남939호',
org: '서해청',
dist: '0.74',
speed: '10.0',
ton: '149.0',
collectorType: '흡착',
collectorCap: '30.0',
boomType: 'B',
boomLength: '300',
},
{
name: '동양방제호',
org: '서해청',
dist: '1.78',
speed: '10.0',
ton: '179.0',
collectorType: '위어,흡착',
collectorCap: '66.0',
boomType: 'C',
boomLength: '300',
},
{
name: '화학방제2함',
org: '서해청',
dist: '3.00',
speed: '13.0',
ton: '501.0',
collectorType: '위어,브러쉬',
collectorCap: '150.0',
boomType: '특수',
boomLength: '60',
},
{
name: '우진방제호',
org: '서해청',
dist: '13.22',
speed: '9.0',
ton: '79.0',
collectorType: '위어',
collectorCap: '20.0',
boomType: 'C',
boomLength: '300',
},
],
etcEquipment: '',
recovery: [{ shipName: '방제15호', period: '0' }],
result: {
spillTotal: '1',
weatheredTotal: '0.64',
recoveredTotal: '0',
seaRemainTotal: '0.36',
coastAttachTotal: '0.24',
},
};
}
// ─── localStorage helpers 제거됨 — reportsApi.ts 사용 ────────
// ─── Styles ─────────────────────────────────────────────────
const S = {
page: {
background: 'var(--bg-surface)',
padding: '32px 40px',
marginBottom: '24px',
borderRadius: '6px',
border: '1px solid var(--stroke-default)',
fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif",
fontSize: '12px',
lineHeight: '1.6',
position: 'relative' as const,
width: '100%',
boxSizing: 'border-box' as const,
},
sectionTitle: {
background: 'rgba(6,182,212,0.12)',
color: 'var(--color-accent)',
padding: '8px 16px',
fontSize: '13px',
fontWeight: 700,
marginBottom: '12px',
borderRadius: '4px',
border: '1px solid rgba(6,182,212,0.2)',
},
subHeader: {
fontSize: '14px',
fontWeight: 700,
color: 'var(--color-accent)',
marginBottom: '12px',
borderBottom: '2px solid var(--stroke-default)',
paddingBottom: '6px',
},
table: {
width: '100%',
tableLayout: 'fixed' as const,
borderCollapse: 'collapse' as const,
fontSize: '11px',
marginBottom: '16px',
},
th: {
background: 'var(--bg-card)',
border: '1px solid var(--stroke-default)',
padding: '6px 10px',
fontWeight: 600,
color: 'var(--fg-sub)',
textAlign: 'center' as const,
fontSize: '10px',
},
td: {
border: '1px solid var(--stroke-default)',
padding: '5px 10px',
textAlign: 'center' as const,
fontSize: '11px',
color: 'var(--fg-sub)',
},
tdLeft: {
border: '1px solid var(--stroke-default)',
padding: '5px 10px',
textAlign: 'left' as const,
fontSize: '11px',
color: 'var(--fg-sub)',
},
thLabel: {
background: 'var(--bg-card)',
border: '1px solid var(--stroke-default)',
padding: '6px 10px',
fontWeight: 600,
color: 'var(--fg-sub)',
textAlign: 'left' as const,
fontSize: '11px',
width: '120px',
},
mapPlaceholder: {
width: '100%',
height: '240px',
background: 'var(--bg-base)',
border: '2px dashed var(--stroke-default)',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--fg-disabled)',
fontSize: '13px',
fontWeight: 600,
marginBottom: '16px',
},
};
// ─── Editable cell ──────────────────────────────────────────
const inputStyle: React.CSSProperties = {
width: '100%',
background: 'var(--bg-base)',
border: '1px solid var(--stroke-light)',
borderRadius: '3px',
padding: '4px 8px',
fontSize: '11px',
outline: 'none',
textAlign: 'center',
};
function ECell({
value,
editing,
onChange,
align,
placeholder,
}: {
value: string;
editing: boolean;
onChange?: (v: string) => void;
align?: 'left' | 'center';
placeholder?: string;
}) {
const style = align === 'left' ? S.tdLeft : S.td;
if (!editing) {
return (
<td
style={{
...style,
color: value ? 'var(--fg-default)' : 'var(--fg-disabled)',
fontStyle: value ? 'normal' : 'italic',
}}
>
{value || placeholder || '-'}
</td>
);
}
return (
<td style={style}>
<input
style={{ ...inputStyle, textAlign: align || 'center' }}
value={value}
onChange={(e) => onChange?.(e.target.value)}
placeholder={placeholder}
/>
</td>
);
}
function AddRowBtn({ onClick, label }: { onClick: () => void; label?: string }) {
return (
<button
onClick={onClick}
className="px-3 py-1 text-[10px] font-semibold text-color-accent bg-[rgba(6,182,212,0.08)] border border-dashed border-color-accent rounded-sm cursor-pointer mb-3"
>
+ {label || '행 추가'}
</button>
);
}
// ─── Page components ────────────────────────────────────────
function Page1({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const inc = data.incident;
const set = (k: keyof typeof inc, v: string) =>
onChange({ ...data, incident: { ...inc, [k]: v } });
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div
className="text-color-accent text-[18px] font-bold mb-5 rounded px-5 py-3 text-center tracking-wide border"
style={{ background: 'rgba(6,182,212,0.1)', border: '1px solid rgba(6,182,212,0.2)' }}
>
</div>
<div style={S.sectionTitle}>1. </div>
<table style={S.table}>
<colgroup>
<col style={{ width: '14%' }} />
<col style={{ width: '36%' }} />
<col style={{ width: '14%' }} />
<col style={{ width: '36%' }} />
</colgroup>
<tbody>
<tr>
<td style={S.thLabel}></td>
<ECell
value={inc.name}
editing={editing}
onChange={(v) => set('name', v)}
placeholder="사고명 입력"
/>
<td style={S.thLabel}></td>
<ECell value={inc.writeTime} editing={editing} onChange={(v) => set('writeTime', v)} />
</tr>
<tr>
<td style={S.thLabel}>()</td>
<ECell
value={inc.shipName}
editing={editing}
onChange={(v) => set('shipName', v)}
placeholder="선명"
/>
<td style={S.thLabel}></td>
<ECell
value={inc.agent}
editing={editing}
onChange={(v) => set('agent', v)}
placeholder="제원"
/>
</tr>
<tr>
<td style={S.thLabel}></td>
<ECell
value={inc.location}
editing={editing}
onChange={(v) => set('location', v)}
placeholder="위치"
/>
<td style={S.thLabel}></td>
<ECell value={`위도: ${inc.lat} 경도: ${inc.lon}`} editing={false} />
</tr>
{editing && (
<tr>
<td style={S.thLabel}></td>
<ECell
value={inc.lat}
editing
onChange={(v) => set('lat', v)}
placeholder="34° 43 37.6″"
/>
<td style={S.thLabel}></td>
<ECell
value={inc.lon}
editing
onChange={(v) => set('lon', v)}
placeholder="127° 43 32.6″"
/>
</tr>
)}
<tr>
<td style={S.thLabel}></td>
<ECell value={inc.occurTime} editing={editing} onChange={(v) => set('occurTime', v)} />
<td style={S.thLabel}></td>
<ECell
value={inc.accidentType}
editing={editing}
onChange={(v) => set('accidentType', v)}
placeholder="사고유형"
/>
</tr>
<tr>
<td style={S.thLabel}></td>
<ECell value={inc.pollutant} editing={editing} onChange={(v) => set('pollutant', v)} />
<td style={S.thLabel}> </td>
<ECell
value={inc.spillAmount}
editing={editing}
onChange={(v) => set('spillAmount', v)}
/>
</tr>
<tr>
<td style={S.thLabel}></td>
<ECell
value={inc.depth}
editing={editing}
onChange={(v) => set('depth', v)}
placeholder="수심"
/>
<td style={S.thLabel}></td>
<ECell
value={inc.seabed}
editing={editing}
onChange={(v) => set('seabed', v)}
placeholder="저질"
/>
</tr>
</tbody>
</table>
</div>
);
}
function Page2({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const setTide = (i: number, k: string, v: string) => {
const t = [...data.tide];
t[i] = { ...t[i], [k]: v };
onChange({ ...data, tide: t });
};
const setWeather = (i: number, k: string, v: string) => {
const w = [...data.weather];
w[i] = { ...w[i], [k]: v };
onChange({ ...data, weather: w });
};
const setSpread = (i: number, k: string, v: string) => {
const s = [...data.spread];
s[i] = { ...s[i], [k]: v };
onChange({ ...data, spread: s });
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>2. </div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}></th>
<th style={S.th} colSpan={2}>
</th>
<th style={S.th} colSpan={2}>
</th>
</tr>
</thead>
<tbody>
{data.tide.map((t, i) => (
<tr key={i}>
<ECell value={t.date} editing={editing} onChange={(v) => setTide(i, 'date', v)} />
<ECell
value={t.tideType}
editing={editing}
onChange={(v) => setTide(i, 'tideType', v)}
/>
<ECell
value={t.lowTide1}
editing={editing}
onChange={(v) => setTide(i, 'lowTide1', v)}
/>
<ECell
value={t.lowTide2}
editing={editing}
onChange={(v) => setTide(i, 'lowTide2', v)}
/>
<ECell
value={t.highTide1}
editing={editing}
onChange={(v) => setTide(i, 'highTide1', v)}
/>
<ECell
value={t.highTide2}
editing={editing}
onChange={(v) => setTide(i, 'highTide2', v)}
/>
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({
...data,
tide: [
...data.tide,
{
date: '',
tideType: '',
lowTide1: '',
highTide1: '',
lowTide2: '',
highTide2: '',
},
],
})
}
/>
)}
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}> </th>
<th style={S.th}></th>
<th style={S.th}></th>
<th style={S.th}></th>
<th style={S.th}>(m/s)</th>
<th style={S.th}></th>
<th style={S.th}>(knot/m/s)</th>
<th style={S.th}>(m)</th>
</tr>
</thead>
<tbody>
{data.weather.map((w, i) => (
<tr key={i}>
<ECell value={w.time} editing={editing} onChange={(v) => setWeather(i, 'time', v)} />
<ECell
value={w.sunrise}
editing={editing}
onChange={(v) => setWeather(i, 'sunrise', v)}
/>
<ECell
value={w.sunset}
editing={editing}
onChange={(v) => setWeather(i, 'sunset', v)}
/>
<ECell
value={w.windDir}
editing={editing}
onChange={(v) => setWeather(i, 'windDir', v)}
/>
<ECell
value={w.windSpeed}
editing={editing}
onChange={(v) => setWeather(i, 'windSpeed', v)}
/>
<ECell
value={w.currentDir}
editing={editing}
onChange={(v) => setWeather(i, 'currentDir', v)}
/>
<ECell
value={w.currentSpeed}
editing={editing}
onChange={(v) => setWeather(i, 'currentSpeed', v)}
/>
<ECell
value={w.waveHeight}
editing={editing}
onChange={(v) => setWeather(i, 'waveHeight', v)}
/>
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({
...data,
weather: [
...data.weather,
{
time: '',
sunrise: '',
sunset: '',
windDir: '',
windSpeed: '',
currentDir: '',
currentSpeed: '',
waveHeight: '',
},
],
})
}
/>
)}
<div style={S.sectionTitle}>3. </div>
<div className="flex gap-4 mb-4">
<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(--stroke-default)',
}}
/>
) : (
<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(--stroke-default)',
}}
/>
) : (
<div style={S.mapPlaceholder}> 6 </div>
)}
</div>
</div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(km²)</th>
</tr>
</thead>
<tbody>
{data.spread.map((s, i) => (
<tr key={i}>
<ECell
value={s.elapsed}
editing={editing}
onChange={(v) => setSpread(i, 'elapsed', v)}
/>
<ECell
value={s.weathered}
editing={editing}
onChange={(v) => setSpread(i, 'weathered', v)}
/>
<ECell
value={s.seaRemain}
editing={editing}
onChange={(v) => setSpread(i, 'seaRemain', v)}
/>
<ECell
value={s.coastAttach}
editing={editing}
onChange={(v) => setSpread(i, 'coastAttach', v)}
/>
<ECell value={s.area} editing={editing} onChange={(v) => setSpread(i, 'area', v)} />
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({
...data,
spread: [
...data.spread,
{ elapsed: '', weathered: '', seaRemain: '', coastAttach: '', area: '' },
],
})
}
/>
)}
</div>
);
}
function Page3({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}></div>
{editing ? (
<textarea
value={data.analysis}
onChange={(e) => onChange({ ...data, analysis: e.target.value })}
placeholder="분석 내용을 작성하세요..."
style={{
width: '100%',
minHeight: '300px',
background: 'var(--bg-base)',
border: '1px solid var(--stroke-light)',
borderRadius: '4px',
padding: '16px',
fontSize: '13px',
outline: 'none',
resize: 'vertical',
lineHeight: '1.8',
}}
/>
) : (
<div
style={{
minHeight: '300px',
border: data.analysis
? '1px solid var(--stroke-default)'
: '2px dashed var(--stroke-default)',
borderRadius: '4px',
padding: '16px',
color: data.analysis ? 'var(--fg-default)' : 'var(--fg-disabled)',
fontStyle: data.analysis ? 'normal' : 'italic',
fontSize: '13px',
whiteSpace: 'pre-wrap',
lineHeight: '1.8',
}}
>
{data.analysis || '분석 내용이 없습니다'}
</div>
)}
</div>
);
}
const getSeasonKey = (occurTime: string): 'SP_G' | 'SM_G' | 'FA_G' | 'WT_G' => {
const m = occurTime.match(/\d{4}[.\-\s]+(\d{1,2})[.\-\s]/);
const month = m ? parseInt(m[1]) : 0;
if (month >= 3 && month <= 5) return 'SP_G';
if (month >= 6 && month <= 8) return 'SM_G';
if (month >= 9 && month <= 11) return 'FA_G';
return 'WT_G';
};
const parseCoord = (s: string): number | null => {
const d = parseFloat(s);
if (!isNaN(d)) return d;
const m = s.match(/(\d+)[°]\s*(\d+)[']\s*([\d.]+)[″"]/);
if (m) return Number(m[1]) + Number(m[2]) / 60 + Number(m[3]) / 3600;
return null;
};
const haversineKm = (lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const getGeomCentroid = (geom: { type: string; coordinates: unknown }): [number, number] | null => {
if (geom.type === 'Point') return geom.coordinates as [number, number];
if (geom.type === 'Polygon') {
const pts = (geom.coordinates as [number, number][][])[0];
const avg = pts.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]);
return [avg[0] / pts.length, avg[1] / pts.length];
}
if (geom.type === 'MultiPolygon') {
const all = (geom.coordinates as [number, number][][][]).flatMap((p) => p[0]);
const avg = all.reduce((a, c) => [a[0] + c[0], a[1] + c[1]], [0, 0]);
return [avg[0] / all.length, avg[1] / all.length];
}
return null;
};
const CATEGORY_COLORS = [
{ keywords: ['어장', '양식'], color: '#f97316', label: '어장/양식장' },
{ keywords: ['해수욕'], color: '#3b82f6', label: '해수욕장' },
{ keywords: ['수산시장', '어시장'], color: '#a855f7', label: '수산시장' },
{ keywords: ['갯벌'], color: '#92400e', label: '갯벌' },
{ keywords: ['서식지'], color: '#16a34a', label: '서식지' },
{
keywords: ['보호종', '생물', '조류', '포유', '파충', '양서'],
color: '#ec4899',
label: '보호종/생물종',
},
] as const;
const getCategoryColor = (category: string): string => {
for (const { keywords, color } of CATEGORY_COLORS) {
if ((keywords as readonly string[]).some((kw) => category.includes(kw))) return color;
}
return '#06b6d4';
};
function SensitiveResourceMapSection({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [mapVisible, setMapVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [capturing, setCapturing] = useState(false);
const [legendLabels, setLegendLabels] = useState<string[]>([]);
const acdntSn = data.acdntSn;
const RELEVANT_KEYWORDS = [
'양식',
'어장',
'해수욕',
'수산시장',
'어시장',
'갯벌',
'서식지',
'보호종',
'생물',
'조류',
'포유',
'파충',
];
const handleLoad = async () => {
if (!acdntSn) return;
setLoading(true);
try {
const [geojson, particlesGeojson] = await Promise.all([
fetchSensitiveResourcesGeojson(acdntSn),
fetchPredictionParticlesGeojson(acdntSn),
]);
// 관련 카테고리만 필터링 + 카테고리별 색상 추가
const filteredGeojson = {
...geojson,
features: geojson.features
.filter((f) => {
const cat = (f.properties as { category?: string })?.category ?? '';
return RELEVANT_KEYWORDS.some((kw) => cat.includes(kw));
})
.map((f) => ({
...f,
properties: {
...f.properties,
_color: getCategoryColor((f.properties as { category?: string })?.category ?? ''),
},
})),
};
// 실제 존재하는 카테고리 라벨만 범례에 표시
const presentColors = new Set(
filteredGeojson.features.map((f) => (f.properties as { _color: string })._color),
);
setLegendLabels(
CATEGORY_COLORS.filter((c) => presentColors.has(c.color)).map((c) => c.label),
);
// 민감자원 GeoJSON → 6개 섹션 자동 채우기
const HABITAT_TYPES = [
'갯벌',
'해조류서식지',
'바다목장',
'바다숲',
'산호류서식지',
'인공어초',
'해초류서식지',
];
const incLat = parseCoord(data.incident.lat);
const incLon = parseCoord(data.incident.lon);
const calcDist = (geom: { type: string; coordinates: unknown }) => {
if (incLat == null || incLon == null) return '';
const centroid = getGeomCentroid(geom);
return centroid ? haversineKm(incLat, incLon, centroid[1], centroid[0]).toFixed(2) : '';
};
// 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>;
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 (해수욕장)
const beachRows = geojson.features
.filter((f) => ((f.properties as { category?: string })?.category ?? '').includes('해수욕'))
.map((f) => {
const p = f.properties as Record<string, unknown>;
return {
name: String(p['beach_nm'] ?? p['name'] ?? p['nm'] ?? ''),
distance: calcDist(f.geometry as { type: string; coordinates: unknown }),
};
});
// markets (수산시장·어시장)
const marketRows = geojson.features
.filter((f) => {
const cat = (f.properties as { category?: string })?.category ?? '';
return cat.includes('수산시장') || cat.includes('어시장');
})
.map((f) => {
const p = f.properties as Record<string, unknown>;
return {
name: String(p['name'] ?? p['market_nm'] ?? p['nm'] ?? ''),
distance: calcDist(f.geometry as { type: string; coordinates: unknown }),
};
});
// esi (해안선) — 기존 10개 행의 length만 갱신
const esiFeatures = geojson.features.filter((f) => {
const cat = (f.properties as { category?: string })?.category ?? '';
return cat.includes('해안선') || cat.includes('ESI');
});
const esiLengthMap: Record<string, string> = {};
esiFeatures.forEach((f) => {
const p = f.properties as Record<string, unknown>;
const code = String(p['esi_cd'] ?? '');
const len = p['length_km'] ?? p['len_km'];
if (code && len != null) esiLengthMap[code] = `${Number(len).toFixed(2)} km`;
});
const esiRows =
esiFeatures.length > 0
? data.esi.map((row) => ({
...row,
length:
esiLengthMap[row.code.replace('ESI ', '')] ?? esiLengthMap[row.code] ?? row.length,
}))
: data.esi;
// species (보호종·생물종) — 3개 고정 행 유지, species 컬럼만 갱신
const SPECIES_MAP: Record<string, string[]> = {
: ['파충', '양서'],
: ['조류'],
: ['포유'],
};
const speciesCollected: Record<string, string[]> = { : [], : [], : [] };
geojson.features.forEach((f) => {
const cat = (f.properties as { category?: string })?.category ?? '';
const p = f.properties as Record<string, unknown>;
const nm = String(p['name'] ?? p['species_nm'] ?? '');
if (!nm) return;
for (const [row, kws] of Object.entries(SPECIES_MAP)) {
if (kws.some((kw) => cat.includes(kw))) {
speciesCollected[row].push(nm);
break;
}
}
});
const hasSpecies = Object.values(speciesCollected).some((arr) => arr.length > 0);
const speciesRows = hasSpecies
? data.species.map((row) => ({
...row,
species: speciesCollected[row.category]?.join(', ') ?? row.species,
}))
: data.species;
// habitat (서식지) — 타입별 면적 합산
const habitatFeatures = geojson.features.filter((f) =>
HABITAT_TYPES.some((t) =>
((f.properties as { category?: string })?.category ?? '').includes(t),
),
);
const habitatMap: Record<string, number> = {};
habitatFeatures.forEach((f) => {
const cat = (f.properties as { category?: string })?.category ?? '';
const p = f.properties as Record<string, unknown>;
habitatMap[cat] = (habitatMap[cat] ?? 0) + (p['area'] != null ? Number(p['area']) : 0);
});
const habitatRows =
habitatFeatures.length > 0
? Object.entries(habitatMap).map(([type, area]) => ({
type,
area: area > 0 ? area.toFixed(2) : '',
}))
: data.habitat;
// 단일 onChange 일괄 업데이트
const updates: Partial<OilSpillReportData> = {};
if (aquacultureRows.length > 0) updates.aquaculture = aquacultureRows;
if (beachRows.length > 0) updates.beaches = beachRows;
if (marketRows.length > 0) updates.markets = marketRows;
if (esiFeatures.length > 0) updates.esi = esiRows;
if (hasSpecies) updates.species = speciesRows;
if (habitatFeatures.length > 0) updates.habitat = habitatRows;
if (Object.keys(updates).length > 0) onChange({ ...data, ...updates });
setMapVisible(true);
// 다음 렌더 사이클 후 지도 초기화
setTimeout(() => {
if (!mapContainerRef.current) return;
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
mapRef.current = map;
map.on('load', () => {
// 확산 파티클 — sensitive 레이어 아래 (예측 탭과 동일한 색상 로직)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('particles', { type: 'geojson', data: particlesGeojson as any });
// 과거 스텝: 회색 반투명
map.addLayer({
id: 'particles-past',
type: 'circle',
source: 'particles',
filter: ['==', ['get', 'isLastStep'], false],
paint: { 'circle-radius': 2.5, 'circle-color': '#828282', 'circle-opacity': 0.4 },
});
// 최신 스텝: 모델별 색상 (OpenDrift=파랑, POSEIDON=빨강)
map.addLayer({
id: 'particles-current',
type: 'circle',
source: 'particles',
filter: ['==', ['get', 'isLastStep'], true],
paint: {
'circle-radius': 3,
'circle-color': [
'case',
['==', ['get', 'model'], 'OpenDrift'],
'#3b82f6',
['==', ['get', 'model'], 'POSEIDON'],
'#ef4444',
'#06b6d4',
],
'circle-opacity': 0.85,
},
});
// 민감자원 레이어
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('sensitive', { type: 'geojson', data: filteredGeojson as any });
map.addLayer({
id: 'sensitive-fill',
type: 'fill',
source: 'sensitive',
filter: ['==', '$type', 'Polygon'],
paint: { 'fill-color': ['get', '_color'], 'fill-opacity': 0.4 },
});
map.addLayer({
id: 'sensitive-line',
type: 'line',
source: 'sensitive',
filter: ['==', '$type', 'Polygon'],
paint: { 'line-color': ['get', '_color'], 'line-width': 1.5 },
});
map.addLayer({
id: 'sensitive-circle',
type: 'circle',
source: 'sensitive',
filter: ['==', '$type', 'Point'],
paint: {
'circle-radius': 6,
'circle-color': ['get', '_color'],
'circle-stroke-width': 1.5,
'circle-stroke-color': '#fff',
},
});
// fit bounds — spread + sensitive 합산
const coords: [number, number][] = [];
const collectCoords = (geom: { type: string; coordinates: unknown }) => {
if (geom.type === 'Point') {
coords.push(geom.coordinates as [number, number]);
} else if (geom.type === 'Polygon') {
const rings = geom.coordinates as [number, number][][];
rings[0]?.forEach((c) => coords.push(c));
} else if (geom.type === 'MultiPolygon') {
const polys = geom.coordinates as [number, number][][][];
polys.forEach((rings) => rings[0]?.forEach((c) => coords.push(c)));
}
};
filteredGeojson.features.forEach((f) =>
collectCoords(f.geometry as { type: string; coordinates: unknown }),
);
particlesGeojson.features.forEach((f) => {
coords.push(f.geometry.coordinates as [number, number]);
});
if (coords.length > 0) {
const lngs = coords.map((c) => c[0]);
const lats = coords.map((c) => c[1]);
map.fitBounds(
[
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)],
],
{ padding: 60, maxZoom: 13 },
);
}
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
if (incLat != null && incLon != null) {
map.addSource('incident-point', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'Point', coordinates: [incLon, incLat] },
properties: {},
},
});
map.addLayer({
id: 'incident-circle',
type: 'circle',
source: 'incident-point',
paint: {
'circle-radius': 7,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
});
map.addLayer({
id: 'incident-label',
type: 'symbol',
source: 'incident-point',
layout: {
'text-field': '사고위치',
'text-size': 11,
'text-offset': [0, 1.6],
'text-anchor': 'top',
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#ef4444',
'text-halo-width': 3,
},
});
}
});
}, 100);
} catch {
alert('민감자원 지도 데이터를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
};
const handleCapture = () => {
const map = mapRef.current;
if (!map) return;
setCapturing(true);
map.once('idle', () => {
const mapCanvas = map.getCanvas();
const dpr = window.devicePixelRatio || 1;
const W = mapCanvas.width;
const H = mapCanvas.height;
const composite = document.createElement('canvas');
composite.width = W;
composite.height = H;
const ctx = composite.getContext('2d')!;
// 지도 그리기
ctx.drawImage(mapCanvas, 0, 0);
// 범례 그리기
const items = CATEGORY_COLORS.filter((c) => legendLabels.includes(c.label));
if (items.length > 0) {
const pad = 8 * dpr;
const swSize = 12 * dpr;
const lineH = 18 * dpr;
const fontSize = 11 * dpr;
const boxW = 130 * dpr;
const boxH = pad * 2 + items.length * lineH;
const lx = pad,
ly = pad;
ctx.fillStyle = 'rgba(255,255,255,0.88)';
ctx.fillRect(lx, ly, boxW, boxH);
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = dpr;
ctx.strokeRect(lx, ly, boxW, boxH);
items.forEach(({ color, label }, i) => {
const iy = ly + pad + i * lineH;
ctx.fillStyle = color;
ctx.fillRect(lx + pad, iy + (lineH - swSize) / 2, swSize, swSize);
ctx.fillStyle = '#1f2937';
ctx.font = `${fontSize}px sans-serif`;
ctx.fillText(label, lx + pad + swSize + 4 * dpr, iy + lineH / 2 + fontSize * 0.35);
});
}
onChange({ ...data, sensitiveMapImage: composite.toDataURL('image/png') });
setCapturing(false);
});
map.triggerRepaint();
};
const handleReset = () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
setMapVisible(false);
setLegendLabels([]);
onChange({ ...data, sensitiveMapImage: undefined });
};
useEffect(() => {
return () => {
mapRef.current?.remove();
};
}, []);
// 뷰 모드: 이미지 있으면 표시, 없으면 플레이스홀더
if (!editing) {
if (data.sensitiveMapImage) {
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>;
}
// 편집 모드: acdntSn 없음
if (!acdntSn) {
return <div style={S.mapPlaceholder}> (10km ) </div>;
}
// 편집 모드: 캡처 이미지 있음
if (data.sensitiveMapImage) {
return (
<div style={{ position: 'relative' }}>
<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',
}}
>
</button>
</div>
);
}
// 편집 모드: 지도 로드/캡처
return (
<div>
{!mapVisible ? (
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
<span> (10km ) </span>
<button
onClick={handleLoad}
disabled={loading}
style={{
background: '#0891b2',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '5px 14px',
fontSize: 12,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? '불러오는 중...' : '지도 불러오기'}
</button>
</div>
) : (
<div style={{ position: 'relative' }}>
<div
ref={mapContainerRef}
style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }}
/>
{legendLabels.length > 0 && (
<div
style={{
position: 'absolute',
top: 8,
left: 8,
background: 'rgba(255,255,255,0.88)',
border: '1px solid rgba(0,0,0,0.15)',
borderRadius: 4,
padding: '6px 10px',
pointerEvents: 'none',
}}
>
{CATEGORY_COLORS.filter((c) => legendLabels.includes(c.label)).map(
({ color, label }) => (
<div
key={label}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginBottom: 3,
fontSize: 11,
color: '#1f2937',
whiteSpace: 'nowrap',
}}
>
<div style={{ width: 12, height: 12, background: color, flexShrink: 0 }} />
{label}
</div>
),
)}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
background: 'transparent',
color: '#6b7280',
border: '1px solid #374151',
borderRadius: 4,
padding: '3px 10px',
fontSize: 11,
cursor: 'pointer',
}}
>
</button>
<button
onClick={handleCapture}
disabled={capturing}
style={{
background: '#0891b2',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '3px 12px',
fontSize: 11,
cursor: capturing ? 'not-allowed' : 'pointer',
opacity: capturing ? 0.7 : 1,
}}
>
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
</button>
</div>
</div>
)}
</div>
);
}
function Page4({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const setArr = <T extends Record<string, string>>(
key: keyof OilSpillReportData,
arr: T[],
i: number,
k: string,
v: string,
) => {
const copy = [...arr];
copy[i] = { ...copy[i], [k]: v };
onChange({ ...data, [key]: copy });
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>4. </div>
<SensitiveResourceMapSection data={data} editing={editing} onChange={onChange} />
<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) => (
<tr key={i}>
<ECell
value={a.type}
editing={editing}
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'type', v)}
placeholder="-"
/>
<ECell
value={a.area}
editing={editing}
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'area', v)}
/>
<ECell
value={a.distance}
editing={editing}
onChange={(v) => setArr('aquaculture', data.aquaculture, i, 'distance', v)}
/>
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({
...data,
aquaculture: [...data.aquaculture, { type: '', area: '', distance: '' }],
})
}
/>
)}
<div className="grid grid-cols-2 gap-5">
<div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}>(km)</th>
</tr>
</thead>
<tbody>
{data.beaches.map((b, i) => (
<tr key={i}>
<ECell
value={b.name}
editing={editing}
onChange={(v) => setArr('beaches', data.beaches, i, 'name', v)}
align="left"
/>
<ECell
value={b.distance}
editing={editing}
onChange={(v) => setArr('beaches', data.beaches, i, 'distance', v)}
/>
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({ ...data, beaches: [...data.beaches, { name: '', distance: '' }] })
}
/>
)}
</div>
<div>
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}>(km)</th>
</tr>
</thead>
<tbody>
{data.markets.map((m, i) => (
<tr key={i}>
<ECell
value={m.name}
editing={editing}
onChange={(v) => setArr('markets', data.markets, i, 'name', v)}
align="left"
/>
<ECell
value={m.distance}
editing={editing}
onChange={(v) => setArr('markets', data.markets, i, 'distance', v)}
/>
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({ ...data, markets: [...data.markets, { name: '', distance: '' }] })
}
/>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-5 mt-2">
<div>
<div style={S.subHeader}>(ESI) </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}></th>
<th style={S.th}> </th>
</tr>
</thead>
<tbody>
{data.esi.map((e, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600, fontSize: '10px' }}>{e.code}</td>
<td style={S.tdLeft}>{e.type}</td>
<ECell
value={e.length}
editing={editing}
onChange={(v) => setArr('esi', data.esi, i, 'length', v)}
/>
</tr>
))}
</tbody>
</table>
</div>
<div>
<div style={S.subHeader}>() </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}></th>
</tr>
</thead>
<tbody>
{data.species.map((s, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600 }}>{s.category}</td>
<ECell
value={s.species}
editing={editing}
onChange={(v) => setArr('species', data.species, i, 'species', v)}
align="left"
placeholder="-"
/>
</tr>
))}
</tbody>
</table>
<div style={{ ...S.subHeader, marginTop: '16px' }}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}></th>
</tr>
</thead>
<tbody>
{data.habitat.map((h, i) => (
<tr key={i}>
<ECell
value={h.type}
editing={editing}
onChange={(v) => setArr('habitat', data.habitat, i, 'type', v)}
/>
<ECell
value={h.area}
editing={editing}
onChange={(v) => setArr('habitat', data.habitat, i, 'area', v)}
/>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
const SENS_LEVELS = [
{ key: 5, level: '매우 높음', color: '#ef4444' },
{ key: 4, level: '높음', color: '#f97316' },
{ key: 3, level: '보통', color: '#eab308' },
{ key: 2, level: '낮음', color: '#22c55e' },
];
function SensitivityMapSection({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [mapVisible, setMapVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [capturing, setCapturing] = useState(false);
const acdntSn = data.acdntSn;
const handleLoad = async () => {
if (!acdntSn) return;
setLoading(true);
try {
const geojson = await fetchSensitivityEvaluationGeojson(acdntSn);
const seasonKey = getSeasonKey(data.incident.occurTime);
// 레벨 계산 및 면적 집계 (레벨 1 제외)
const areaByLevel: Record<number, number> = {};
geojson.features.forEach((f) => {
const p = (f as { properties: Record<string, unknown> }).properties;
const lvl = Number(p[seasonKey] ?? 1);
if (lvl >= 2) areaByLevel[lvl] = (areaByLevel[lvl] ?? 0) + Number(p['area_km2'] ?? 0);
});
const sensitivityRows = SENS_LEVELS.map((s) => ({
level: s.level,
color: s.color,
area: areaByLevel[s.key] != null ? areaByLevel[s.key].toFixed(2) : '',
}));
onChange({ ...data, sensitivity: sensitivityRows });
// 레벨 속성 추가 + 레벨 1 제외한 표시용 GeoJSON
const displayGeojson = {
...geojson,
features: geojson.features
.map((f) => {
const p = (f as { properties: Record<string, unknown> }).properties;
return { ...(f as object), properties: { ...p, level: Number(p[seasonKey] ?? 1) } };
})
.filter((f) => (f as { properties: { level: number } }).properties.level >= 2),
};
setMapVisible(true);
setTimeout(() => {
if (!mapContainerRef.current) return;
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
center: [127.5, 35.5],
zoom: 8,
preserveDrawingBuffer: true,
});
mapRef.current = map;
map.on('load', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
map.addSource('sensitivity', { type: 'geojson', data: displayGeojson as any });
const colorExpr: maplibregl.ExpressionSpecification = [
'case',
['==', ['get', 'level'], 5],
'#ef4444',
['==', ['get', 'level'], 4],
'#f97316',
['==', ['get', 'level'], 3],
'#eab308',
['==', ['get', 'level'], 2],
'#22c55e',
'transparent',
];
map.addLayer({
id: 'sensitivity-fill',
type: 'fill',
source: 'sensitivity',
filter: ['==', '$type', 'Polygon'],
paint: { 'fill-color': colorExpr, 'fill-opacity': 0.6 },
});
map.addLayer({
id: 'sensitivity-line',
type: 'line',
source: 'sensitivity',
filter: ['==', '$type', 'Polygon'],
paint: { 'line-color': colorExpr, 'line-width': 0.5, 'line-opacity': 0.4 },
});
map.addLayer({
id: 'sensitivity-circle',
type: 'circle',
source: 'sensitivity',
filter: ['==', '$type', 'Point'],
paint: { 'circle-radius': 5, 'circle-color': colorExpr },
});
// fitBounds
const coords: [number, number][] = [];
displayGeojson.features.forEach((f) => {
const geom = (f as { geometry: { type: string; coordinates: unknown } }).geometry;
if (geom.type === 'Point') coords.push(geom.coordinates as [number, number]);
else if (geom.type === 'Polygon')
(geom.coordinates as [number, number][][])[0]?.forEach((c) => coords.push(c));
else if (geom.type === 'MultiPolygon')
(geom.coordinates as [number, number][][][]).forEach((rings) =>
rings[0]?.forEach((c) => coords.push(c)),
);
});
if (coords.length > 0) {
const lngs = coords.map((c) => c[0]);
const lats = coords.map((c) => c[1]);
map.fitBounds(
[
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)],
],
{ padding: 60, maxZoom: 13 },
);
}
// 사고 위치 마커 (캔버스 레이어 — 캡처에 포함)
const incLat = parseCoord(data.incident.lat);
const incLon = parseCoord(data.incident.lon);
if (incLat != null && incLon != null) {
map.addSource('incident-point', {
type: 'geojson',
data: {
type: 'Feature',
geometry: { type: 'Point', coordinates: [incLon, incLat] },
properties: {},
},
});
map.addLayer({
id: 'incident-circle',
type: 'circle',
source: 'incident-point',
paint: {
'circle-radius': 7,
'circle-color': '#ef4444',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
},
});
map.addLayer({
id: 'incident-label',
type: 'symbol',
source: 'incident-point',
layout: {
'text-field': '사고위치',
'text-size': 11,
'text-offset': [0, 1.6],
'text-anchor': 'top',
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#ef4444',
'text-halo-width': 3,
},
});
}
});
}, 100);
} catch {
alert('통합민감도 평가 데이터를 불러오지 못했습니다.');
} finally {
setLoading(false);
}
};
const handleCapture = () => {
const map = mapRef.current;
if (!map) return;
setCapturing(true);
map.once('idle', () => {
const dataUrl = map.getCanvas().toDataURL('image/png');
onChange({ ...data, sensitivityMapImage: dataUrl });
setCapturing(false);
});
map.triggerRepaint();
};
const handleReset = () => {
if (mapRef.current) {
mapRef.current.remove();
mapRef.current = null;
}
setMapVisible(false);
onChange({ ...data, sensitivityMapImage: undefined });
};
useEffect(() => {
return () => {
mapRef.current?.remove();
};
}, []);
if (!editing) {
if (data.sensitivityMapImage) {
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>;
}
if (!acdntSn) return <div style={S.mapPlaceholder}> </div>;
if (data.sensitivityMapImage) {
return (
<div style={{ position: 'relative' }}>
<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',
}}
>
</button>
</div>
);
}
return (
<div style={{ position: 'relative' }}>
{!mapVisible ? (
<div style={{ ...S.mapPlaceholder, flexDirection: 'column', gap: 8 }}>
<span> (10km내) </span>
<button
onClick={handleLoad}
disabled={loading}
style={{
background: '#0891b2',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '5px 14px',
fontSize: 12,
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? '불러오는 중...' : '지도 불러오기'}
</button>
</div>
) : (
<div style={{ position: 'relative' }}>
<div
ref={mapContainerRef}
style={{ width: '100%', height: 440, borderRadius: 4, overflow: 'hidden' }}
/>
<div style={{ display: 'flex', gap: 8, marginTop: 6, justifyContent: 'flex-end' }}>
<button
onClick={handleReset}
style={{
background: 'transparent',
color: '#6b7280',
border: '1px solid #374151',
borderRadius: 4,
padding: '3px 10px',
fontSize: 11,
cursor: 'pointer',
}}
>
</button>
<button
onClick={handleCapture}
disabled={capturing}
style={{
background: '#0891b2',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '3px 12px',
fontSize: 11,
cursor: capturing ? 'not-allowed' : 'pointer',
opacity: capturing ? 0.7 : 1,
}}
>
{capturing ? '캡처 중...' : '이 화면으로 캡처'}
</button>
</div>
</div>
)}
</div>
);
}
function Page5({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const setSens = (i: number, v: string) => {
const s = [...data.sensitivity];
s[i] = { ...s[i], area: v };
onChange({ ...data, sensitivity: s });
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}> ( )</div>
<SensitivityMapSection data={data} editing={editing} onChange={onChange} />
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}> (km²)</th>
</tr>
</thead>
<tbody>
{data.sensitivity.map((s, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 700 }}>
<span
style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: 2,
background: s.color,
marginRight: 8,
verticalAlign: 'middle',
}}
/>
{s.level}
</td>
<ECell value={s.area} editing={editing} onChange={(v) => setSens(i, v)} />
</tr>
))}
</tbody>
</table>
</div>
);
}
function Page6({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const setVessel = (i: number, k: string, v: string) => {
const vs = [...data.vessels];
vs[i] = { ...vs[i], [k]: v };
onChange({ ...data, vessels: vs });
};
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>5. ·</div>
<div style={S.subHeader}> ( 30km내)</div>
<div className="overflow-x-auto">
<table style={S.table}>
<thead>
<tr>
<th style={S.th} rowSpan={2}>
#
</th>
<th style={S.th} rowSpan={2}>
</th>
<th style={S.th} rowSpan={2}>
</th>
<th style={S.th} rowSpan={2}>
(km)
</th>
<th style={S.th} rowSpan={2}>
(knots)
</th>
<th style={S.th} rowSpan={2}>
()
</th>
<th style={S.th} colSpan={2}>
</th>
<th style={S.th} colSpan={2}>
</th>
</tr>
<tr>
<th style={S.th}></th>
<th style={S.th}>(kl)</th>
<th style={S.th}></th>
<th style={S.th}>(m)</th>
</tr>
</thead>
<tbody>
{data.vessels.map((v, i) => (
<tr key={i}>
<td style={{ ...S.td, fontWeight: 600 }}>{i + 1}</td>
<ECell
value={v.name}
editing={editing}
onChange={(val) => setVessel(i, 'name', val)}
/>
<ECell
value={v.org}
editing={editing}
onChange={(val) => setVessel(i, 'org', val)}
/>
<ECell
value={v.dist}
editing={editing}
onChange={(val) => setVessel(i, 'dist', val)}
/>
<ECell
value={v.speed}
editing={editing}
onChange={(val) => setVessel(i, 'speed', val)}
/>
<ECell
value={v.ton}
editing={editing}
onChange={(val) => setVessel(i, 'ton', val)}
/>
<ECell
value={v.collectorType}
editing={editing}
onChange={(val) => setVessel(i, 'collectorType', val)}
/>
<ECell
value={v.collectorCap}
editing={editing}
onChange={(val) => setVessel(i, 'collectorCap', val)}
/>
<ECell
value={v.boomType}
editing={editing}
onChange={(val) => setVessel(i, 'boomType', val)}
/>
<ECell
value={v.boomLength}
editing={editing}
onChange={(val) => setVessel(i, 'boomLength', val)}
/>
</tr>
))}
</tbody>
</table>
</div>
{editing && (
<AddRowBtn
onClick={() =>
onChange({
...data,
vessels: [
...data.vessels,
{
name: '',
org: '',
dist: '',
speed: '',
ton: '',
collectorType: '',
collectorCap: '',
boomType: '',
boomLength: '',
},
],
})
}
/>
)}
<div style={S.subHeader}> </div>
{editing ? (
<textarea
value={data.etcEquipment}
onChange={(e) => onChange({ ...data, etcEquipment: e.target.value })}
placeholder="기타 장비 입력"
style={{
width: '100%',
minHeight: '60px',
background: 'var(--bg-base)',
border: '1px solid var(--stroke-light)',
borderRadius: '4px',
padding: '12px',
fontSize: '12px',
outline: 'none',
resize: 'vertical',
}}
/>
) : (
<div
style={{
minHeight: '60px',
border: data.etcEquipment
? '1px solid var(--stroke-default)'
: '2px dashed var(--stroke-default)',
borderRadius: '4px',
padding: '12px',
color: data.etcEquipment ? 'var(--fg-default)' : 'var(--fg-disabled)',
fontStyle: data.etcEquipment ? 'normal' : 'italic',
fontSize: '12px',
}}
>
{data.etcEquipment || '-'}
</div>
)}
</div>
);
}
function Page7({
data,
editing,
onChange,
}: {
data: OilSpillReportData;
editing: boolean;
onChange: (d: OilSpillReportData) => void;
}) {
const setRec = (i: number, k: string, v: string) => {
const r = [...data.recovery];
r[i] = { ...r[i], [k]: v };
onChange({ ...data, recovery: r });
};
const setRes = (k: string, v: string) =>
onChange({ ...data, result: { ...data.result, [k]: v } });
return (
<div style={S.page}>
<div className="absolute top-2.5 right-4 text-[9px] text-fg-disabled font-semibold">
</div>
<div style={S.sectionTitle}>/ </div>
<div style={S.mapPlaceholder}>/ </div>
<div style={S.subHeader}></div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}></th>
<th style={S.th}>( +)</th>
</tr>
</thead>
<tbody>
{data.recovery.map((r, i) => (
<tr key={i}>
<ECell
value={r.shipName}
editing={editing}
onChange={(v) => setRec(i, 'shipName', v)}
/>
<ECell value={r.period} editing={editing} onChange={(v) => setRec(i, 'period', v)} />
</tr>
))}
</tbody>
</table>
{editing && (
<AddRowBtn
onClick={() =>
onChange({ ...data, recovery: [...data.recovery, { shipName: '', period: '' }] })
}
/>
)}
<div style={S.subHeader}> </div>
<table style={S.table}>
<thead>
<tr>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
<th style={S.th}>(kl)</th>
</tr>
</thead>
<tbody>
<tr>
<ECell
value={data.result.spillTotal}
editing={editing}
onChange={(v) => setRes('spillTotal', v)}
/>
<ECell
value={data.result.weatheredTotal}
editing={editing}
onChange={(v) => setRes('weatheredTotal', v)}
/>
<ECell
value={data.result.recoveredTotal}
editing={editing}
onChange={(v) => setRes('recoveredTotal', v)}
/>
<ECell
value={data.result.seaRemainTotal}
editing={editing}
onChange={(v) => setRes('seaRemainTotal', v)}
/>
<ECell
value={data.result.coastAttachTotal}
editing={editing}
onChange={(v) => setRes('coastAttachTotal', v)}
/>
</tr>
</tbody>
</table>
</div>
);
}
// ─── Main Template Component ────────────────────────────────
interface Props {
mode: 'preview' | 'edit' | 'view';
initialData?: OilSpillReportData;
onSave?: (data: OilSpillReportData) => void;
onBack?: () => void;
}
export function OilSpillReportTemplate({ mode, initialData, onSave, onBack }: Props) {
const [data, setData] = useState<OilSpillReportData>(() => initialData || createSampleReport());
const [currentPage, setCurrentPage] = useState(0);
const [viewMode, setViewMode] = useState<'page' | 'all'>('all');
const editing = mode === 'edit';
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
if (initialData) setData(initialData);
}, [initialData]);
const handleSave = useCallback(async () => {
let reportData = data;
if (!data.title) {
const title = data.incident.name || `보고서 ${new Date().toLocaleDateString('ko-KR')}`;
reportData = { ...data, title };
}
try {
await saveReport(reportData);
onSave?.(reportData);
} catch (err) {
console.error('[reports] 저장 오류:', err);
alert('보고서 저장 중 오류가 발생했습니다.');
}
}, [data, onSave]);
const pages = [
{ label: '1. 사고 정보', node: <Page1 data={data} editing={editing} onChange={setData} /> },
{
label: '2. 해양기상 + 확산예측',
node: <Page2 data={data} editing={editing} onChange={setData} />,
},
{ label: '3. 분석', node: <Page3 data={data} editing={editing} onChange={setData} /> },
{ label: '4. 민감자원', node: <Page4 data={data} editing={editing} onChange={setData} /> },
{ label: '5. 통합민감도', node: <Page5 data={data} editing={editing} onChange={setData} /> },
{ label: '6. 방제전략', node: <Page6 data={data} editing={editing} onChange={setData} /> },
{ label: '7. 동원 결과', node: <Page7 data={data} editing={editing} onChange={setData} /> },
];
return (
<div className="w-full">
{/* Toolbar */}
<div className="flex items-center justify-between mb-5 flex-wrap gap-3">
<div className="flex items-center gap-2.5">
{onBack && (
<button
onClick={onBack}
className="px-3 py-1.5 text-[12px] font-semibold text-fg-sub bg-transparent border-none cursor-pointer"
>
</button>
)}
<h2 className="text-[18px] font-bold">
{editing ? (
<input
value={data.title}
onChange={(e) => setData({ ...data, title: e.target.value })}
placeholder="보고서 제목 입력"
className="text-[18px] font-bold bg-bg-base border border-[var(--stroke-light)] rounded px-2.5 py-1 outline-none w-full max-w-[600px]"
/>
) : (
data.title || '유류오염사고 대응지원 상황도'
)}
</h2>
<span
className="px-2.5 py-[3px] text-[10px] font-semibold rounded border"
style={
editing
? {
background: 'rgba(251,191,36,0.15)',
color: '#f59e0b',
borderColor: 'rgba(251,191,36,0.3)',
}
: {
background: 'rgba(6,182,212,0.15)',
color: 'var(--color-accent)',
borderColor: 'rgba(6,182,212,0.3)',
}
}
>
{editing ? '편집 중' : mode === 'preview' ? '샘플' : '보기'}
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('all')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'all'
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: viewMode === 'all' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
color: viewMode === 'all' ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
</button>
<button
onClick={() => setViewMode('page')}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
style={{
border:
viewMode === 'page'
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: viewMode === 'page' ? 'rgba(6,182,212,0.1)' : 'var(--bg-elevated)',
color: viewMode === 'page' ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
</button>
{editing && (
<button
onClick={handleSave}
className="px-4 py-1.5 text-[11px] font-bold rounded cursor-pointer border border-[#22c55e] bg-[rgba(34,197,94,0.15)] text-color-success"
>
</button>
)}
<button
onClick={() => window.print()}
className="px-3.5 py-1.5 text-[11px] font-semibold rounded cursor-pointer border border-[var(--color-danger)] bg-[rgba(239,68,68,0.1)] text-color-danger"
>
/ PDF
</button>
</div>
</div>
{/* Page tabs */}
{viewMode === 'page' && (
<div className="flex gap-1 mb-4 flex-wrap">
{pages.map((p, i) => (
<button
key={i}
onClick={() => setCurrentPage(i)}
className="px-3 py-1.5 text-[11px] font-semibold rounded cursor-pointer"
style={{
border:
currentPage === i
? '1px solid var(--color-accent)'
: '1px solid var(--stroke-default)',
background: currentPage === i ? 'rgba(6,182,212,0.15)' : 'transparent',
color: currentPage === i ? 'var(--color-accent)' : 'var(--fg-disabled)',
}}
>
{p.label}
</button>
))}
</div>
)}
{/* Pages */}
<div id="report-print-area" className="w-full">
{viewMode === 'all' ? (
pages.map((p, i) => <div key={i}>{p.node}</div>)
) : (
<div>
{pages[currentPage].node}
<div className="flex justify-center gap-2 mt-4">
<button
onClick={() => setCurrentPage((p) => Math.max(0, p - 1))}
disabled={currentPage === 0}
className="px-5 py-2 text-[12px] font-semibold rounded border border-stroke bg-bg-elevated cursor-pointer"
style={{
color: currentPage === 0 ? 'var(--fg-disabled)' : 'var(--fg-default)',
opacity: currentPage === 0 ? 0.4 : 1,
}}
>
</button>
<span className="px-4 py-2 text-[12px] text-fg-sub">
{currentPage + 1} / {pages.length}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(pages.length - 1, p + 1))}
disabled={currentPage === pages.length - 1}
className="px-5 py-2 text-[12px] font-semibold rounded cursor-pointer"
style={{
border: '1px solid var(--color-accent)',
background: 'rgba(6,182,212,0.1)',
color:
currentPage === pages.length - 1 ? 'var(--fg-disabled)' : 'var(--color-accent)',
opacity: currentPage === pages.length - 1 ? 0.4 : 1,
}}
>
</button>
</div>
</div>
)}
</div>
</div>
);
}